feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
897981b893
commit
6c1d17dbf6
26 files changed
+999
-236
No files matched your search
@@ -2,7 +2,7 @@ import { Button, Modal, Segmented, Select } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, Cpu, Settings2, Trash2, X } from "lucide-react";
|
||||
import { Copy, Cpu, Settings2, Trash2, X, AtSign } from "lucide-react";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
@@ -23,12 +23,13 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session";
|
||||
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
|
||||
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
|
||||
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentImagePreview, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
import { AgentChatEmptyState, AgentPanelChrome } from "./canvas-agent-panel-chrome";
|
||||
import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel";
|
||||
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendingBackendSession, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
|
||||
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
|
||||
@@ -374,7 +375,7 @@ export function CanvasAssistantPanel({
|
||||
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
|
||||
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
|
||||
const [composerSkills, setComposerSkills] = useState<Skill[]>([]);
|
||||
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
||||
const [attachedReferenceIds, setAttachedReferenceIds] = useState<Set<string>>(() => new Set());
|
||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
||||
const localSessionsRef = useRef(localSessions);
|
||||
const [localActiveSessionId, setLocalActiveSessionIdState] = useState<string | null>(activeSessionId);
|
||||
@@ -442,9 +443,11 @@ export function CanvasAssistantPanel({
|
||||
const messages = activeSession?.messages || [];
|
||||
const hasMessages = messages.length > 0;
|
||||
const agentBusy = isRunning || safeSessions.some((session) => session.pendingBackendSession?.status === "pending");
|
||||
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
|
||||
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
|
||||
const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]);
|
||||
const selectedReferences = useMemo(() => buildAssistantReferences(nodes, attachedReferenceIds), [attachedReferenceIds, nodes]);
|
||||
const composerImageReferences = useMemo(() => assistantReferencesToMentionReferences(selectedReferences), [selectedReferences]);
|
||||
const resolvedComposerImageReferences = useResolvedCanvasResourceReferences(composerImageReferences);
|
||||
const composerReferences = useMemo(() => [...buildSkillMentionReferences(composerSkills), ...resolvedComposerImageReferences], [composerSkills, resolvedComposerImageReferences]);
|
||||
const resolvedPreviewById = useMemo(() => new Map(resolvedComposerImageReferences.map((item) => [item.id, item.previewUrl || ""])), [resolvedComposerImageReferences]);
|
||||
const contextSummary = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
|
||||
const iconButtonStyle = { color: theme.node.muted };
|
||||
|
||||
@@ -455,8 +458,27 @@ export function CanvasAssistantPanel({
|
||||
}, [agentBusy, agentMode, localActiveSessionId, messages, view]);
|
||||
|
||||
useEffect(() => {
|
||||
setRemovedReferenceIds(new Set());
|
||||
}, [selectedNodeKey]);
|
||||
setAttachedReferenceIds((current) => {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const next = new Set(current);
|
||||
let changed = false;
|
||||
selectedNodeIds.forEach((id) => {
|
||||
const node = nodeById.get(id);
|
||||
if (!node || !nodeToReference(node) || next.has(id)) return;
|
||||
next.add(id);
|
||||
changed = true;
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [nodes, selectedNodeIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setAttachedReferenceIds((current) => {
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const next = new Set(Array.from(current).filter((id) => nodeIds.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [nodes]);
|
||||
|
||||
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
|
||||
const next = localSessionsRef.current.map((session) => (session.id === sessionId ? updater(session) : session));
|
||||
@@ -1104,16 +1126,26 @@ export function CanvasAssistantPanel({
|
||||
{view === "chat" ? (
|
||||
<>
|
||||
{selectedReferences.length ? (
|
||||
<div className="thin-scrollbar flex max-w-full gap-1.5 overflow-x-auto px-3 pb-1">
|
||||
<div className="thin-scrollbar flex max-w-full items-center gap-1.5 overflow-x-auto px-3 py-1.5">
|
||||
<span className="shrink-0 text-[var(--fs-micro)] font-medium opacity-55">参考 · {selectedReferences.length}</span>
|
||||
{selectedReferences.map((item, index) => (
|
||||
<AssistantReferenceChip
|
||||
key={item.id}
|
||||
item={item}
|
||||
label={assistantImageReferenceLabel(selectedReferences, index)}
|
||||
previewUrl={resolvedPreviewById.get(item.id) || item.dataUrl}
|
||||
onRemove={() => {
|
||||
setRemovedReferenceIds((prev) => new Set(prev).add(item.id));
|
||||
setAttachedReferenceIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
if (selectedNodeIds.has(item.id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== item.id)));
|
||||
}}
|
||||
onInsertMention={(label) => {
|
||||
const token = `@${label} `;
|
||||
setPrompt((prev) => (promptAlreadyHasMention(prev, label) ? prev : prev.trim() ? `${prev.replace(/\s+$/u, "")} ${token}` : token));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1123,7 +1155,7 @@ export function CanvasAssistantPanel({
|
||||
sending={agentBusy}
|
||||
placeholder={cinematicEntryActive ? "一句话描述题材、角色和核心冲突" : "描述你想让 Agent 如何操作画布"}
|
||||
theme={theme}
|
||||
references={buildSkillMentionReferences(composerSkills)}
|
||||
references={composerReferences}
|
||||
slashSkills={composerSkills}
|
||||
onPromptChange={setPrompt}
|
||||
onSubmit={cinematicEntryActive ? () => submitCinematicProject(prompt) : submit}
|
||||
@@ -1388,39 +1420,121 @@ function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantReferenceChip({ item, label, onRemove }: { item: CanvasAssistantReference; label?: string; onRemove?: () => void }) {
|
||||
function promptAlreadyHasMention(prompt: string, label: string) {
|
||||
const token = `@${label}`;
|
||||
let from = 0;
|
||||
while (from <= prompt.length) {
|
||||
const index = prompt.indexOf(token, from);
|
||||
if (index < 0) return false;
|
||||
const next = prompt[index + token.length];
|
||||
if (!next || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(next)) return true;
|
||||
from = index + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function AssistantReferenceChip({ item, label, previewUrl, onRemove, onInsertMention }: { item: CanvasAssistantReference; label?: string; previewUrl?: string; onRemove?: () => void; onInsertMention?: (label: string) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const mentionReference = useMemo(() => assistantReferencesToMentionReferences([item]), [item]);
|
||||
const resolvedReference = useResolvedCanvasResourceReferences(mentionReference)[0];
|
||||
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
|
||||
const imageUrl = previewUrl || resolvedReference?.previewUrl || item.dataUrl;
|
||||
const hasImage = Boolean(imageUrl || item.storageKey);
|
||||
const chipStyle = { background: theme.spatial.surface, color: theme.node.text };
|
||||
const actionStyle = { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
|
||||
if (hasImage) {
|
||||
return (
|
||||
<>
|
||||
<span className="group/chip relative inline-flex h-12 shrink-0 items-center overflow-visible rounded-md" style={chipStyle}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-12 shrink-0 overflow-hidden rounded-l-md border-0 p-0"
|
||||
style={{ background: theme.toolbar.itemHover }}
|
||||
title="点击放大预览"
|
||||
aria-label={`预览 ${label || item.title}`}
|
||||
disabled={!imageUrl}
|
||||
onClick={() => imageUrl && setPreviewOpen(true)}
|
||||
onDoubleClick={() => imageUrl && setPreviewOpen(true)}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[var(--fs-micro)] opacity-50">…</span>
|
||||
)}
|
||||
</button>
|
||||
{label && onInsertMention ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 border-0 bg-transparent px-2.5 text-[var(--fs-tiny)] opacity-80 hover:opacity-100"
|
||||
title={`插入 @${label}`}
|
||||
onClick={() => onInsertMention(label)}
|
||||
>
|
||||
<AtSign className="size-2.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
) : label ? (
|
||||
<span className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 px-2.5 text-[var(--fs-tiny)] opacity-80">
|
||||
<AtSign className="size-2.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100 group-focus-within/chip:opacity-100"
|
||||
style={actionStyle}
|
||||
onClick={onRemove}
|
||||
aria-label="移除引用"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{previewOpen && imageUrl ? <AgentImagePreview attachment={{ id: item.id, url: imageUrl, name: item.title || label || "图片" }} onClose={() => setPreviewOpen(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
|
||||
{item.dataUrl ? (
|
||||
<span className="relative block size-8 shrink-0">
|
||||
<img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" />
|
||||
{label ? <span className="absolute left-0.5 top-0.5 rounded bg-black/60 px-1 py-0.5 text-[var(--fs-micro)] font-medium leading-none text-white">{label}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid size-8 place-items-center rounded-md text-sm font-medium" style={{ background: theme.spatial.surface }}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
<span className="group/chip relative inline-flex h-12 max-w-[168px] shrink-0 items-center gap-2 overflow-hidden rounded-md px-2.5 text-sm" style={chipStyle}>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md text-sm font-medium" style={{ background: theme.toolbar.itemHover }}>
|
||||
{text}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--fs-tiny)] opacity-80">{item.title}</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}
|
||||
style={actionStyle}
|
||||
onClick={onRemove}
|
||||
aria-label="移除引用"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function assistantReferencesToMentionReferences(references: CanvasAssistantReference[]): CanvasResourceReference[] {
|
||||
return references.flatMap((item, index): CanvasResourceReference[] => {
|
||||
if (item.dataUrl || item.storageKey) {
|
||||
const label = assistantImageReferenceLabel(references, index) || item.title;
|
||||
return [{ id: item.id, nodeId: item.id, kind: "image", label, title: item.title, previewUrl: item.dataUrl, storageKey: item.storageKey, active: true }];
|
||||
}
|
||||
if (item.text) {
|
||||
const label = item.type === CanvasNodeType.Skill ? `技能${index + 1}` : `文本${index + 1}`;
|
||||
return [{ id: item.id, nodeId: item.id, kind: item.type === CanvasNodeType.Skill ? "skill" : "text", label, title: item.title, text: item.text, active: true }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function assistantImageReferenceLabel(references: CanvasAssistantReference[], index: number) {
|
||||
if (!references[index]?.dataUrl) return undefined;
|
||||
const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl).length - 1;
|
||||
if (!references[index]?.dataUrl && !references[index]?.storageKey) return undefined;
|
||||
const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl || item.storageKey).length - 1;
|
||||
return imageIndex >= 0 ? imageReferenceLabel(imageIndex) : undefined;
|
||||
}
|
||||
|
||||
@@ -1889,7 +2003,7 @@ function explainNoop(ops: CanvasAgentOp[], snapshot: CanvasAgentSnapshot) {
|
||||
}
|
||||
|
||||
function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) {
|
||||
if (node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey)) {
|
||||
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
|
||||
}
|
||||
if (node.type === CanvasNodeType.Text && node.metadata?.content) {
|
||||
|
||||
Reference in new issue
Block a user