fix(agents): show only explicitly delivered chat files

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 23:43:33 +08:00
1 parent 922b09bda0
commit e78459b384
12 files changed
+429 -62

No files matched your search

+1
View File
@@ -389,6 +389,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
<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;
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from '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';
@@ -6,7 +6,8 @@ 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;
@@ -18,24 +19,19 @@ type PendingUpload = {
};
type AttachmentList = { threadId?: string; loaded?: boolean; items: CloudAttachment[]; error: string };
type FileList = { threadId?: string; loaded?: boolean; items: CloudFile[]; error: string };
type FileList = { threadId?: string; deliveryKey: string; loaded?: boolean; items: CloudFile[]; error: string };
// The library is a flat collection, including files saved in output subfolders.
// Always read through Main and abort stale conversations before another folder read.
async function conversationFiles(threadId: string, live: () => boolean) {
const pending = ['/'];
const visited = new Set<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>();
while (pending.length && live()) {
const path = pending.shift()!;
if (visited.has(path)) continue;
if (visited.size >= 128) throw new Error('文件较多,暂时无法完整读取,请重试。');
visited.add(path);
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) pending.push(file.directory_path);
else files.set(file.path, file);
if (!file.is_dir && deliveredPaths.has(file.path)) files.set(file.path, file);
}
}
return [...files.values()];
@@ -59,7 +55,7 @@ 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, headerTarget, activeView = true, filesRevision = '', 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[];
@@ -67,13 +63,14 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS
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>({ 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);
@@ -86,16 +83,19 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS
const confirmed = useRef(new Map<string, CloudAttachment[]>());
const callbacks = useRef({ onBusy, onSelected, selected });
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;
const filesLoading = Boolean(currentThreadId) && !matchingFiles;
const attachmentsKnown = attachmentList.threadId === currentThreadId && attachmentList.loaded;
const generatedFiles = attachmentsKnown && fileList.threadId === currentThreadId ? fileList.items.filter(file => !knownFiles.some(attachment => file.path === attachment.path || file.path === attachment.original_path)) : [];
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 && fileList.threadId === currentThreadId && fileList.loaded;
const libraryError = attachmentList.threadId === currentThreadId && attachmentList.error || fileList.threadId === currentThreadId && fileList.error;
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]);
@@ -124,13 +124,16 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS
useEffect(() => {
if (!currentThreadId) return;
let live = true;
conversationFiles(currentThreadId, () => live).then(items => {
if (live) setFileList({ threadId: currentThreadId, loaded: true, items, error: '' });
conversationFiles(currentThreadId, deliveredPaths, () => live).then(items => {
if (live) setFileList({ threadId: currentThreadId, deliveryKey, loaded: true, items, error: '' });
}).catch(error => {
if (live) setFileList(previous => ({ threadId: currentThreadId, loaded: previous.threadId === currentThreadId && previous.loaded, items: previous.threadId === currentThreadId ? previous.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; };
}, [currentThreadId, refresh, filesRevision]);
}, [currentThreadId, refresh, filesRevision, deliveredPaths, deliveryKey]);
useEffect(() => { if (!activeView) setOpen(false); }, [activeView]);
@@ -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;
}