Merge Agent attachment panel and explicit delivered-file filtering
This commit is contained in:
commit
be53e44dcf
14 files changed
+1010
-98
No files matched your search
@@ -23,9 +23,9 @@ const active = (status?: string) => Boolean(status && ['queued', 'dispatching',
|
||||
const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作';
|
||||
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
|
||||
|
||||
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true,
|
||||
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true,
|
||||
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
|
||||
slug: string; assistantName?: string; assistantAvatarUrl?: string | null; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void;
|
||||
slug: string; assistantName?: string; assistantAvatarUrl?: string | null; headerAction?: ReactNode; attachmentHeader?: HTMLElement | null; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void;
|
||||
examples?: readonly string[]; hasUnsavedChanges?: boolean; saveDisabled?: boolean; onSaveForPreview?: () => Promise<number | undefined>;
|
||||
}) {
|
||||
const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision);
|
||||
@@ -54,6 +54,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const [attachmentBusy, setAttachmentBusy] = useState(false);
|
||||
const [localAttachmentHeader, setLocalAttachmentHeader] = useState<HTMLDivElement | null>(null);
|
||||
const [executionEpoch, setExecutionEpoch] = useState(0);
|
||||
const childrenPending = useCloudPendingState();
|
||||
usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending);
|
||||
@@ -301,6 +302,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
};
|
||||
const approvalGeneration = generation.current;
|
||||
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat agent-chat-styled" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
||||
{attachmentHeader === undefined && <div ref={setLocalAttachmentHeader} className="agent-chat-local-files-header" />}
|
||||
{mode === 'preview' && <div className="agent-chat-toolbar"><div className="agent-chat-toolbar-row">
|
||||
<div className="agent-chat-toolbar-heading"><span className="agent-chat-toolbar-icon" aria-hidden="true"><MessageCircle /></span><div className="min-w-0"><p className="agent-chat-toolbar-title">{previewRevision === undefined ? '和助手聊一聊' : '试一试'}</p>
|
||||
{previewRevision !== undefined && <p className="mt-1 text-xs text-muted-foreground">当前试用 · 修订 {previewRevision}</p>}
|
||||
@@ -386,6 +388,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}>放弃内容</Button><Button variant="ghost" onClick={() => setLeaving(false)}>继续编辑</Button></div>}
|
||||
<form data-testid="cloud-chat-composer" className="agent-chat-composer" onSubmit={e => { e.preventDefault(); void send(); }}>
|
||||
<CloudComposerAttachments key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
|
||||
headerTarget={attachmentHeader === undefined ? localAttachmentHeader : attachmentHeader} activeView={activeView}
|
||||
deliveryHistory={history}
|
||||
filesRevision={`${history?.messages.at(-1)?.id ?? ''}:${history?.run?.agent_run_id ?? ''}:${history?.run?.status ?? ''}`}
|
||||
onBusy={setAttachmentBusy} disabled={busy || running || Boolean(intent) || loading || Boolean(thread?.archived)} ensureThread={async () => {
|
||||
if (thread) return thread.thread_id;
|
||||
const expectedGeneration = generation.current;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
|
||||
import { ChevronDown, Download, FileText, Folder, Image, Plus, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Download, FileText, Image, Paperclip, Plus, RefreshCw, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
import type { CloudAttachment, CloudFile, CloudUpload } from '../../../shared/cloud-agents';
|
||||
import { deliveredFilePaths } from './cloud-delivered-files';
|
||||
import type { CloudAttachment, CloudFile, CloudHistory, CloudUpload } from '../../../shared/cloud-agents';
|
||||
|
||||
type PendingUpload = {
|
||||
upload: CloudUpload;
|
||||
@@ -14,8 +18,24 @@ type PendingUpload = {
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
type AttachmentList = { threadId?: string; items: CloudAttachment[]; error: string };
|
||||
type FileList = { threadId?: string; path: string; items: CloudFile[]; error: string };
|
||||
type AttachmentList = { threadId?: string; loaded?: boolean; items: CloudAttachment[]; error: string };
|
||||
type FileList = { threadId?: string; deliveryKey: string; loaded?: boolean; items: CloudFile[]; error: string };
|
||||
|
||||
// Verify only the folders of explicitly linked reply files through Main.
|
||||
// Internal folders and unrelated files never enter the attachment library.
|
||||
async function conversationFiles(threadId: string, deliveredPaths: ReadonlySet<string>, live: () => boolean) {
|
||||
const directories = new Set([...deliveredPaths].map(path => path.slice(0, path.lastIndexOf('/')) || '/'));
|
||||
const files = new Map<string, CloudFile>();
|
||||
for (const path of directories) {
|
||||
if (!live()) return [];
|
||||
const result = await cloudAgentsApi.call('files', { thread_id: threadId, path });
|
||||
if (!live()) return [];
|
||||
for (const file of result.files) {
|
||||
if (!file.is_dir && deliveredPaths.has(file.path)) files.set(file.path, file);
|
||||
}
|
||||
}
|
||||
return [...files.values()];
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : '文件操作失败,请重试。';
|
||||
@@ -35,37 +55,47 @@ function mergeAttachments(...lists: CloudAttachment[][]) {
|
||||
return [...new Map(lists.flat().map(file => [file.file_id, file])).values()];
|
||||
}
|
||||
|
||||
export function CloudComposerAttachments({ threadId, ensureThread, selected, onSelected, onBusy, disabled, children, footer }: {
|
||||
export function CloudComposerAttachments({ threadId, ensureThread, selected, onSelected, onBusy, disabled, headerTarget, deliveryHistory, activeView = true, filesRevision = '', children, footer }: {
|
||||
threadId?: string;
|
||||
ensureThread: () => Promise<string>;
|
||||
selected: string[];
|
||||
onSelected: (ids: string[]) => void;
|
||||
onBusy: (busy: boolean) => void;
|
||||
disabled: boolean;
|
||||
headerTarget?: HTMLElement | null;
|
||||
deliveryHistory?: CloudHistory | null;
|
||||
activeView?: boolean;
|
||||
filesRevision?: string;
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const [attachmentList, setAttachmentList] = useState<AttachmentList>({ items: [], error: '' });
|
||||
const [fileList, setFileList] = useState<FileList>({ path: '/', items: [], error: '' });
|
||||
const [fileList, setFileList] = useState<FileList>({ deliveryKey: '', items: [], error: '' });
|
||||
const [localThreadId, setLocalThreadId] = useState<string>();
|
||||
const [pending, setPending] = useState<PendingUpload | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [pickerFailed, setPickerFailed] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [path, setPath] = useState('/');
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const mounted = useRef(false);
|
||||
const busyRef = useRef(false);
|
||||
const confirmed = useRef(new Map<string, CloudAttachment[]>());
|
||||
const callbacks = useRef({ onBusy, onSelected, selected });
|
||||
const libraryId = useId();
|
||||
const currentThreadId = threadId ?? localThreadId;
|
||||
const deliveryKey = useMemo(() => [...deliveredFilePaths(deliveryHistory, currentThreadId ?? '')].sort().join('\0'), [deliveryHistory, currentThreadId]);
|
||||
const deliveredPaths = useMemo(() => new Set(deliveryKey ? deliveryKey.split('\0') : []), [deliveryKey]);
|
||||
const matchingFiles = fileList.threadId === currentThreadId && fileList.deliveryKey === deliveryKey;
|
||||
const attachments = attachmentList.threadId === currentThreadId ? attachmentList.items : [];
|
||||
const knownFiles = attachments;
|
||||
const blocked = disabled || busy;
|
||||
const attachmentsLoading = Boolean(currentThreadId) && attachmentList.threadId !== currentThreadId;
|
||||
const filesLoading = Boolean(currentThreadId) && (fileList.threadId !== currentThreadId || fileList.path !== path);
|
||||
const filesLoading = Boolean(currentThreadId) && !matchingFiles;
|
||||
const attachmentsKnown = attachmentList.threadId === currentThreadId && attachmentList.loaded;
|
||||
const generatedFiles = attachmentsKnown && matchingFiles ? fileList.items.filter(file => deliveredPaths.has(file.path) && !knownFiles.some(attachment => file.path === attachment.path || file.path === attachment.original_path)) : [];
|
||||
const count = knownFiles.length + generatedFiles.length;
|
||||
const countReady = attachmentsKnown && matchingFiles && fileList.loaded;
|
||||
const libraryError = attachmentList.threadId === currentThreadId && attachmentList.error || matchingFiles && fileList.error;
|
||||
usePendingCloudInput(busy || Boolean(pending));
|
||||
|
||||
useEffect(() => { callbacks.current = { onBusy, onSelected, selected }; }, [onBusy, onSelected, selected]);
|
||||
@@ -84,23 +114,28 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS
|
||||
if (!currentThreadId) return;
|
||||
let live = true;
|
||||
cloudAgentsApi.call('attachments', { thread_id: currentThreadId }).then(result => {
|
||||
if (live) setAttachmentList({ threadId: currentThreadId, items: mergeAttachments(result.attachments, confirmed.current.get(currentThreadId) ?? []), error: '' });
|
||||
if (live) setAttachmentList({ threadId: currentThreadId, loaded: true, items: mergeAttachments(result.attachments, confirmed.current.get(currentThreadId) ?? []), error: '' });
|
||||
}).catch(error => {
|
||||
if (live) setAttachmentList({ threadId: currentThreadId, items: confirmed.current.get(currentThreadId) ?? [], error: message(error) });
|
||||
if (live) setAttachmentList(previous => ({ threadId: currentThreadId, loaded: previous.threadId === currentThreadId && previous.loaded, items: mergeAttachments(previous.threadId === currentThreadId ? previous.items : [], confirmed.current.get(currentThreadId) ?? []), error: message(error) }));
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [currentThreadId, refresh]);
|
||||
}, [currentThreadId, refresh, filesRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !currentThreadId) return;
|
||||
if (!currentThreadId) return;
|
||||
let live = true;
|
||||
cloudAgentsApi.call('files', { thread_id: currentThreadId, path }).then(result => {
|
||||
if (live) setFileList({ threadId: currentThreadId, path, items: result.files, error: '' });
|
||||
conversationFiles(currentThreadId, deliveredPaths, () => live).then(items => {
|
||||
if (live) setFileList({ threadId: currentThreadId, deliveryKey, loaded: true, items, error: '' });
|
||||
}).catch(error => {
|
||||
if (live) setFileList({ threadId: currentThreadId, path, items: [], error: message(error) });
|
||||
if (live) setFileList(previous => {
|
||||
const sameCollection = previous.threadId === currentThreadId && previous.deliveryKey === deliveryKey;
|
||||
return { threadId: currentThreadId, deliveryKey, loaded: sameCollection && previous.loaded, items: sameCollection ? previous.items : [], error: message(error) };
|
||||
});
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [open, currentThreadId, path, refresh]);
|
||||
}, [currentThreadId, refresh, filesRevision, deliveredPaths, deliveryKey]);
|
||||
|
||||
useEffect(() => { if (!activeView) setOpen(false); }, [activeView]);
|
||||
|
||||
async function act(task: () => Promise<void>) {
|
||||
if (disabled || busyRef.current || !mounted.current) return;
|
||||
@@ -143,7 +178,7 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS
|
||||
if (!result.attachments.length) throw new Error('文件暂未加入,请重试。');
|
||||
const all = mergeAttachments(confirmed.current.get(upload.threadId) ?? [], result.attachments);
|
||||
confirmed.current.set(upload.threadId, all);
|
||||
setAttachmentList(previous => ({ threadId: upload.threadId, items: mergeAttachments(previous.threadId === upload.threadId ? previous.items : [], all), error: previous.threadId === upload.threadId ? previous.error : '' }));
|
||||
setAttachmentList(previous => ({ threadId: upload.threadId, loaded: previous.threadId === upload.threadId && previous.loaded, items: mergeAttachments(previous.threadId === upload.threadId ? previous.items : [], all), error: previous.threadId === upload.threadId ? previous.error : '' }));
|
||||
callbacks.current.onSelected([...new Set([...callbacks.current.selected, ...result.attachments.map(file => file.file_id)])]);
|
||||
setPending(null); setRefresh(value => value + 1);
|
||||
}
|
||||
@@ -168,7 +203,12 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS
|
||||
callbacks.current.onSelected(value ? [...new Set([...current, fileId])] : current.filter(id => id !== fileId));
|
||||
}
|
||||
|
||||
return <div className="agent-attachment-composer">
|
||||
const entry = (count > 0 || libraryError) && <SheetTrigger asChild><Button type="button" variant="ghost" size="sm" className="agent-attachment-entry" aria-label={count && countReady ? `附件,共 ${count} 个文件` : '附件'}>
|
||||
<Paperclip aria-hidden="true" size={18} /><span>附件</span>{count > 0 && countReady && <span className="agent-attachment-count" aria-hidden="true">{count}</span>}
|
||||
</Button></SheetTrigger>;
|
||||
|
||||
return <Sheet open={open && activeView} onOpenChange={next => { setOpen(next); if (next) setRefresh(value => value + 1); }}><div className="agent-attachment-composer">
|
||||
{headerTarget ? createPortal(entry, headerTarget) : headerTarget === undefined && entry}
|
||||
{selected.length > 0 && <div className="agent-attachment-selected" aria-label="本条消息的附件">
|
||||
{selected.map(id => {
|
||||
const file = knownFiles.find(file => file.file_id === id);
|
||||
@@ -193,32 +233,49 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => { setPending(null); setError(''); }}>取消添加</Button>
|
||||
</>}
|
||||
</div>}
|
||||
{error && <div className="agent-attachment-error"><p role="alert">{error}</p>{pickerFailed && <>
|
||||
{error && !open && <div className="agent-attachment-error"><p role="alert">{error}</p>{pickerFailed && <>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={blocked} onClick={() => pick()}>重试选择文件</Button>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={blocked} onClick={() => { setPickerFailed(false); setError(''); }}>取消添加</Button>
|
||||
</>}</div>}
|
||||
<div className="agent-attachment-toolbar">
|
||||
<div className="agent-attachment-actions">
|
||||
<Button type="button" variant="ghost" size="sm" className="agent-attachment-add" disabled={blocked || Boolean(pending)} onClick={() => pick()}><Plus size={18} />添加文件或图片</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="agent-attachment-browse" aria-expanded={open} aria-controls={libraryId} onClick={() => setOpen(value => !value)}><ChevronDown size={16} />对话文件</Button>
|
||||
<DropdownMenu.Root><DropdownMenu.Trigger asChild><Button type="button" variant="ghost" size="icon" className="agent-attachment-add" aria-label="添加文件或图片" title="添加文件或图片" disabled={blocked || Boolean(pending)}><Plus aria-hidden="true" size={20} /></Button></DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal><DropdownMenu.Content align="start" side="top" sideOffset={8} className="agent-attachment-menu">
|
||||
<DropdownMenu.Item onSelect={() => pick()}><Paperclip size={17} />添加文件或图片</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => pick(true)}><FileText size={17} />添加并选择读取方式</DropdownMenu.Item>
|
||||
</DropdownMenu.Content></DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
{open && <div id={libraryId} className="agent-attachment-library">
|
||||
<div className="agent-attachment-library-header"><span>已上传的文件</span><Button type="button" variant="ghost" size="sm" disabled={busy} onClick={() => setRefresh(value => value + 1)}>刷新文件</Button></div>
|
||||
<SheetContent side="right" className="agent-attachment-sheet" onCloseAutoFocus={event => {
|
||||
// A conversation switch may have removed its now-empty trigger.
|
||||
if (!count && !libraryError) { event.preventDefault(); headerTarget?.closest('section')?.querySelector<HTMLTextAreaElement>('[aria-label="消息"]')?.focus(); }
|
||||
}}>
|
||||
<div className="agent-attachment-sheet-heading"><span className="agent-attachment-sheet-symbol" aria-hidden="true"><Paperclip size={23} /></span><div><SheetTitle>本次对话的附件</SheetTitle><SheetDescription>{count && countReady ? `${count} 个文件,随时找回来。` : '这次聊天用到的文件都在这里。'}</SheetDescription></div>
|
||||
<SheetClose asChild><Button type="button" variant="ghost" size="icon" aria-label="关闭附件面板"><X size={20} /></Button></SheetClose></div>
|
||||
<div className="agent-attachment-sheet-body">
|
||||
<section className="agent-attachment-group" data-kind="uploaded" aria-label="你上传的">
|
||||
<div className="agent-attachment-library-header"><h3>你上传的</h3><Button type="button" variant="ghost" size="icon" aria-label="刷新附件" title="刷新附件" disabled={busy} onClick={() => setRefresh(value => value + 1)}><RefreshCw size={16} /></Button></div>
|
||||
{attachmentsLoading ? <p role="status">正在读取文件…</p> : attachmentList.threadId === currentThreadId && attachmentList.error ? <p role="alert">{attachmentList.error}</p> : knownFiles.length === 0 && <p className="agent-attachment-empty">还没有上传文件。</p>}
|
||||
{knownFiles.map(file => {
|
||||
const Icon = isImage(file.file_name) ? Image : FileText;
|
||||
return <label key={file.file_id} className="agent-attachment-library-file"><input type="checkbox" aria-label={'选择附件 ' + file.file_name} checked={selected.includes(file.file_id)} disabled={blocked} onChange={event => choose(file.file_id, event.target.checked)} /><Icon size={18} /><span className="agent-attachment-name" title={file.file_name}>{file.file_name}</span><span className="agent-attachment-size">{fileSize(file.file_size)}</span></label>;
|
||||
return <label key={file.file_id} className="agent-attachment-library-file" data-selected={selected.includes(file.file_id)}><input type="checkbox" aria-label={'选择附件 ' + file.file_name} checked={selected.includes(file.file_id)} disabled={blocked} onChange={event => choose(file.file_id, event.target.checked)} /><span className="agent-attachment-file-icon"><Icon size={20} /></span><span className="agent-attachment-card-info"><span className="agent-attachment-name" title={file.file_name}>{file.file_name}</span><span className="agent-attachment-size">{fileSize(file.file_size)} · {selected.includes(file.file_id) ? '已加入本条消息' : '选中后随消息发送'}</span></span>
|
||||
{currentThreadId && (file.original_path || file.path) && <Button type="button" variant="ghost" size="icon" disabled={blocked} title="保存到电脑" aria-label={'保存 ' + file.file_name} onClick={event => { event.preventDefault(); event.stopPropagation(); void act(async () => { await cloudAgentsApi.download(currentThreadId, file.original_path || file.path); }); }}><Download size={18} /></Button>}
|
||||
</label>;
|
||||
})}
|
||||
<Button type="button" variant="ghost" size="sm" className="agent-attachment-reading-add" disabled={blocked || Boolean(pending)} onClick={() => pick(true)}>添加并选择读取方式</Button>
|
||||
{currentThreadId && <div className="agent-attachment-artifacts">
|
||||
<div className="agent-attachment-library-header"><span className="agent-attachment-path" title={path}>对话产物 · {path}</span>{path !== '/' && <Button type="button" variant="ghost" size="sm" disabled={busy} onClick={() => setPath(path.split('/').slice(0, -1).join('/') || '/')}>上一级</Button>}</div>
|
||||
{filesLoading ? <p role="status">正在读取产物…</p> : fileList.error ? <p role="alert">{fileList.error}</p> : fileList.items.length === 0 && <p className="agent-attachment-empty">还没有生成文件。</p>}
|
||||
{!filesLoading && fileList.items.map(file => <div key={file.path} className="agent-attachment-library-file">
|
||||
{file.is_dir ? <Button type="button" variant="ghost" className="agent-attachment-directory" disabled={busy} onClick={() => setPath(file.directory_path)}><Folder size={18} /><span className="agent-attachment-name" title={file.name}>{file.name}</span></Button> : <><FileText size={18} /><span className="agent-attachment-name" title={file.name}>{file.name}</span><span className="agent-attachment-size">{fileSize(file.size)}</span><Button type="button" variant="ghost" size="icon" disabled={blocked} aria-label={'保存 ' + file.name} onClick={() => void act(async () => { await cloudAgentsApi.download(currentThreadId, file.path); })}><Download size={17} /></Button></>}
|
||||
</section>
|
||||
{currentThreadId && <section className="agent-attachment-group" data-kind="generated" aria-label="助手生成的">
|
||||
<div className="agent-attachment-library-header"><h3>助手生成的</h3></div>
|
||||
{filesLoading || attachmentsLoading ? <p role="status">正在读取文件…</p> : !attachmentsKnown ? <p className="agent-attachment-empty">附件还没读完整,请刷新再试。</p> : fileList.error ? <p role="alert">{fileList.error}</p> : generatedFiles.length === 0 && <p className="agent-attachment-empty">还没有生成文件。</p>}
|
||||
{!filesLoading && generatedFiles.map(file => <div key={file.path} className="agent-attachment-library-file">
|
||||
<span className="agent-attachment-file-icon">{isImage(file.name) ? <Image size={20} /> : <FileText size={20} />}</span><span className="agent-attachment-card-info"><span className="agent-attachment-name" title={file.name}>{file.name}</span><span className="agent-attachment-size">{fileSize(file.size)}</span></span><Button type="button" variant="ghost" size="icon" disabled={blocked} title="保存到电脑" aria-label={'保存 ' + file.name} onClick={() => void act(async () => { await cloudAgentsApi.download(currentThreadId, file.path); })}><Download size={18} /></Button>
|
||||
</div>)}
|
||||
</div>}
|
||||
</div>}
|
||||
</div>;
|
||||
</section>}
|
||||
{error && <p role="alert" className="agent-attachment-error">{error}</p>}
|
||||
</div>
|
||||
<div className="agent-attachment-sheet-footer"><SheetClose asChild><Button type="button" variant="outline">返回聊天{selected.length > 0 ? ` · 已选 ${selected.length} 个附件` : ''}</Button></SheetClose>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</div></Sheet>;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
const initialTab = entryView ?? (recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration');
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [visited, setVisited] = useState<Set<Tab>>(() => new Set([initialTab]));
|
||||
const [attachmentHeader, setAttachmentHeader] = useState<HTMLDivElement | null>(null);
|
||||
const pending = useCloudPendingState();
|
||||
const formalPending = useCloudPendingState();
|
||||
const alive = useRef(true);
|
||||
@@ -128,7 +129,8 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="h-4 w-4" /></Button>
|
||||
{tab === 'chat' ? <>
|
||||
<CloudAvatar src={saved.icon_url} name={saved.name} identity={saved.slug} className="agent-chat-header-avatar" />
|
||||
<h2 className="agent-chat-header-name">{saved.name}</h2>
|
||||
<h2 className="agent-chat-header-name" title={saved.name}>{saved.name}</h2>
|
||||
<div ref={setAttachmentHeader} className="agent-chat-header-files" />
|
||||
</> : <>
|
||||
<Button variant="ghost" className="agent-chat-identity-avatar relative h-12 w-12 shrink-0 p-0" aria-label="修改头像" title="修改头像" disabled={busy} onClick={() => setEditingAvatar(true)}>
|
||||
<CloudAvatar src={draft.icon_url} name={draft.name} identity={saved.slug} />
|
||||
@@ -202,7 +204,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
</div>
|
||||
<div hidden={tab !== 'chat'} className="agent-workspace-view agent-conversation-workspace">
|
||||
{visited.has('chat') && <CloudPendingContext.Provider value={formalPending.report}>
|
||||
{saved.published_version ? <CloudChat key="published" slug={saved.slug} assistantName={saved.name} assistantAvatarUrl={saved.icon_url} activeView={tab === 'chat'} />
|
||||
{saved.published_version ? <CloudChat key="published" slug={saved.slug} assistantName={saved.name} assistantAvatarUrl={saved.icon_url} activeView={tab === 'chat'} attachmentHeader={attachmentHeader} />
|
||||
: <div className="m-auto space-y-4 text-center text-sm"><p>保存配置后,开始使用这个智能体。</p><Button disabled={busy || remote !== null || !publication.access} onClick={startUsing}>{publication.pending ? '重试开始使用' : '开始使用'}</Button></div>}
|
||||
</CloudPendingContext.Provider>}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,53 @@
|
||||
.agent-chat-workspace-header > button { border-radius: 13px; }
|
||||
.agent-chat-workspace-header { flex-wrap: nowrap; min-height: 76px; padding: 12px 24px; }
|
||||
.agent-chat-header-avatar { width: 44px; height: 44px; flex-shrink: 0; border: 2px solid #fff; border-radius: 15px; box-shadow: 0 2px 0 #ebe3d6; }
|
||||
.agent-chat-header-name { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #303747; font-size: 18px; font-weight: 650; line-height: 1.5; }
|
||||
.agent-chat-header-name { display: -webkit-box; min-width: 0; max-height: 3em; margin: 0; overflow: hidden; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; color: #303747; font-size: 18px; font-weight: 650; line-height: 1.5; }
|
||||
.agent-chat-header-files { margin-left: auto; flex-shrink: 0; }
|
||||
.agent-chat-local-files-header { display: flex; justify-content: flex-end; padding: 12px 24px 0; }
|
||||
.agent-chat-local-files-header:empty { display: none; }
|
||||
.agent-attachment-entry { display: flex; height: 42px; min-width: 88px; gap: 7px; border: 1px solid #dfd5ed; border-radius: 14px; padding: 0 12px; background: #f5f0fc; color: #655080; cursor: pointer; }
|
||||
.agent-attachment-entry:hover, .agent-attachment-entry[data-state="open"] { border-color: #c6b3df; background: #ede4f8; }
|
||||
.agent-attachment-count { display: grid; min-width: 22px; height: 22px; place-items: center; border-radius: 8px; padding: 0 5px; background: #e7daf4; font-size: 12px; font-weight: 650; }
|
||||
.agent-attachment-sheet { display: flex; width: min(420px, 100vw); max-width: 420px; flex-direction: column; gap: 0; border-color: #e3dcec; border-radius: 24px 0 0 24px; padding: 24px; background: #fffdf9; color: #303747; box-shadow: -12px 0 48px #50406012; backdrop-filter: none; }
|
||||
.agent-attachment-sheet-heading { display: flex; align-items: center; gap: 12px; padding-bottom: 24px; }
|
||||
.agent-attachment-sheet-heading > div { min-width: 0; flex: 1; }
|
||||
.agent-attachment-sheet-heading h2 { margin: 0; font-size: 19px; font-weight: 650; }
|
||||
.agent-attachment-sheet-heading p { margin-top: 6px; color: #677286; font-size: 12px; line-height: 1.7; }
|
||||
.agent-attachment-sheet-symbol { display: grid; width: 44px; height: 48px; flex-shrink: 0; place-items: center; border: 1px solid #e0d3ef; border-radius: 16px; background: #f0e8fa; color: #8062a3; }
|
||||
.agent-attachment-sheet-heading button { flex-shrink: 0; border-radius: 12px; color: #657084; }
|
||||
.agent-attachment-sheet-body { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 2px; }
|
||||
.agent-attachment-group { margin-bottom: 18px; border: 1px solid #dfd8ed; border-radius: 20px; padding: 12px; background: #f7f3fc; }
|
||||
.agent-attachment-group[data-kind="generated"] { border-color: #dae6db; background: #f2f7ef; }
|
||||
.agent-attachment-sheet .agent-attachment-library-header { min-height: 40px; margin-bottom: 8px; color: #68547f; }
|
||||
.agent-attachment-sheet .agent-attachment-library-header h3 { font-size: 14px; font-weight: 650; }
|
||||
.agent-attachment-group[data-kind="generated"] .agent-attachment-library-header { color: #587263; }
|
||||
.agent-attachment-sheet .agent-attachment-library-file { min-height: 70px; gap: 10px; margin-top: 8px; border: 1px solid #e7e1ef; border-radius: 14px; padding: 10px; background: #fff; }
|
||||
.agent-attachment-sheet label.agent-attachment-library-file { cursor: pointer; }
|
||||
.agent-attachment-sheet .agent-attachment-library-file[data-selected="true"] { border-color: #bc9edc; background: #faf7ff; }
|
||||
.agent-attachment-sheet .agent-attachment-library-file input { width: 16px; height: 16px; accent-color: #8062a3; }
|
||||
.agent-attachment-file-icon { display: grid; width: 34px; height: 40px; flex-shrink: 0; place-items: center; border-radius: 10px; background: #f0eaf8; color: #8062a3; }
|
||||
.agent-attachment-group[data-kind="generated"] .agent-attachment-file-icon { background: #e9f1e5; color: #63806b; }
|
||||
.agent-attachment-sheet .agent-attachment-card-info > span { display: block; }
|
||||
.agent-attachment-sheet .agent-attachment-card-info .agent-attachment-name { white-space: normal; overflow-wrap: anywhere; line-height: 1.6; }
|
||||
.agent-attachment-sheet .agent-attachment-library-file button { flex-shrink: 0; border-radius: 11px; color: #587263; }
|
||||
.agent-attachment-sheet .agent-attachment-empty { margin: 10px 0 14px; font-size: 12px; line-height: 1.8; }
|
||||
.agent-attachment-sheet [role="alert"] { color: #a34424; font-size: 13px; line-height: 1.8; overflow-wrap: anywhere; }
|
||||
.agent-attachment-sheet [role="status"] { color: #677286; font-size: 13px; }
|
||||
.agent-attachment-sheet-footer { padding-top: 16px; }
|
||||
.agent-attachment-sheet-footer > button { width: 100%; min-height: 44px; border-color: #ddd3e9; border-radius: 13px; background: #fff; font-size: 13px; }
|
||||
.agent-attachment-menu { z-index: 120; min-width: 210px; max-width: calc(100vw - 24px); border: 1px solid #e0d7ec; border-radius: 15px; padding: 6px; background: #fffdf9; box-shadow: 0 8px 24px #50406014; }
|
||||
.agent-attachment-menu [role="menuitem"] { display: flex; align-items: center; min-height: 42px; gap: 9px; border-radius: 10px; padding: 8px 10px; color: #5f4e78; font-size: 13px; cursor: pointer; outline: none; }
|
||||
.agent-attachment-menu [role="menuitem"]:focus { background: #f1eafa; }
|
||||
.agent-attachment-sheet :is(button, input):focus-visible, .agent-attachment-entry:focus-visible { outline: 2px solid #9275b4; outline-offset: 3px; }
|
||||
.agent-attachment-sheet button { cursor: pointer; }
|
||||
.agent-attachment-sheet button:disabled { cursor: not-allowed; }
|
||||
@media (max-width: 480px) {
|
||||
.agent-attachment-sheet { padding: 20px 16px; border-radius: 0; }
|
||||
.agent-attachment-sheet-heading { gap: 9px; }
|
||||
.agent-attachment-sheet-heading h2 { font-size: 17px; }
|
||||
.agent-attachment-entry { min-width: 82px; padding-inline: 9px; }
|
||||
.agent-chat-local-files-header { padding-inline: 14px; }
|
||||
}
|
||||
.agent-conversation-workspace { padding: 0; background: #fffdf9; container-type: inline-size; }
|
||||
.agent-conversation-sources { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; padding: 10px 24px; border-bottom: 1px solid #e9e5de; }
|
||||
.agent-conversation-source-options { display: flex; gap: 4px; padding: 4px; border: 1px solid #e5e2ee; border-radius: 15px; background: #f2eff7; }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown';
|
||||
import type { CloudHistory } from '../../../shared/cloud-agents';
|
||||
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
url?: string;
|
||||
identifier?: string;
|
||||
children?: MarkdownNode[];
|
||||
};
|
||||
|
||||
const scheme = /^[a-z][a-z\d+.-]*:/i;
|
||||
const nestedPathEscape = /%(?:2e|2f|5c|00|3f|23)/i;
|
||||
|
||||
function invalidPath(path: string): boolean {
|
||||
return /[\\\ufffd?#]/.test(path) || [...path].some(character => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code < 32 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function filePath(url: string, threadId: string): string | null {
|
||||
if (!url || invalidPath(url)) return null;
|
||||
let path = url;
|
||||
if (/^sandbox:\//i.test(path)) path = path.slice('sandbox:'.length);
|
||||
else if (scheme.test(path)) return null;
|
||||
|
||||
try { path = decodeURIComponent(path); }
|
||||
catch { return null; }
|
||||
|
||||
if (!path || invalidPath(path) || scheme.test(path)
|
||||
|| path.startsWith('//') || nestedPathEscape.test(path)) return null;
|
||||
if (!path.startsWith('/')) path = '/' + path;
|
||||
|
||||
const artifact = path.match(/^\/api\/(?:chat\/thread|makelore\/threads)\/([^/]+)\/artifacts\/(.+)$/);
|
||||
if (artifact) {
|
||||
if (artifact[1] !== threadId) return null;
|
||||
path = '/' + artifact[2];
|
||||
} else if (/^\/api\/(?:chat\/thread|makelore\/threads)(?:\/|$)/.test(path)) return null;
|
||||
|
||||
const segments = path.slice(1).split('/');
|
||||
if (segments.some(segment => !segment || segment === '.' || segment === '..')) return null;
|
||||
return path;
|
||||
}
|
||||
|
||||
function referenceId(identifier: string): string {
|
||||
return identifier.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
function walk(node: MarkdownNode, visit: (node: MarkdownNode) => void): void {
|
||||
if (node.type === 'code' || node.type === 'inlineCode' || node.type === 'html') return;
|
||||
visit(node);
|
||||
node.children?.forEach(child => walk(child, visit));
|
||||
}
|
||||
|
||||
/** Legacy delivery evidence comes from saved assistant replies, never workspace contents. */
|
||||
export function deliveredFilePaths(history: CloudHistory | null | undefined, threadId: string): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
if (!threadId || history?.thread_id !== threadId) return paths;
|
||||
|
||||
for (const message of history.messages) {
|
||||
if (message.role !== 'assistant') continue;
|
||||
const tree = fromMarkdown(message.content);
|
||||
const definitions = new Map<string, string>();
|
||||
walk(tree, node => {
|
||||
if (node.type === 'definition' && node.identifier && node.url) {
|
||||
const id = referenceId(node.identifier);
|
||||
if (!definitions.has(id)) definitions.set(id, node.url);
|
||||
}
|
||||
});
|
||||
walk(tree, node => {
|
||||
const url = node.type === 'link' || node.type === 'image' ? node.url
|
||||
: (node.type === 'linkReference' || node.type === 'imageReference') && node.identifier
|
||||
? definitions.get(referenceId(node.identifier)) : undefined;
|
||||
if (url) {
|
||||
const path = filePath(url, threadId);
|
||||
if (path) paths.add(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
Reference in new issue
Block a user