fix(agents): show only explicitly delivered chat files
This commit is contained in:
1 parent
922b09bda0
commit
e78459b384
12 files changed
+429
-62
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user