Merge Agent attachment panel and explicit delivered-file filtering

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 00:09:38 +08:00
commit be53e44dcf
14 files changed
+1010 -98

No files matched your search

+7 -2
View File
@@ -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>;
}
+4 -2
View File
@@ -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>
+47 -1
View File
@@ -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;
}