feat(canvas): 优化 Agent 工具卡片与交互界面
- 精简工具调用卡片样式,提升对话流可读性 - 动态生成工具调用标题并友好化结果消息 - 将思考中指示器插入到最新用户消息之后 - 固定主要操作按钮和计划栏,改善滚动
This commit is contained in:
@@ -3,7 +3,7 @@ import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowUp, AtSign, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { ArrowUp, AtSign, CheckCircle2, ChevronDown, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
|
||||
@@ -100,10 +100,7 @@ export function AgentChatMessage({
|
||||
if (item.role === "tool") {
|
||||
if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />;
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AgentAvatar theme={theme} />
|
||||
<AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />
|
||||
</div>
|
||||
<AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -151,26 +148,26 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AgentAvatar theme={theme} />
|
||||
<div className="min-w-0 flex-1 rounded-md p-3.5" style={{ background: "rgba(217,119,6,.07)", color: theme.node.text }}>
|
||||
<div className="agent-pending-tool min-w-0 flex-1 rounded-md p-2.5" style={{ background: "rgba(217,119,6,.07)", color: theme.node.text }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
|
||||
<span className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
|
||||
<CircleAlert className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[13px] font-semibold leading-5">
|
||||
<span>确认工具调用</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
|
||||
等待确认
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.text }}>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{impact?.operationCount ? (
|
||||
<div className="mt-3 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
<ImpactMetric label="操作" value={impact.operationCount} theme={theme} />
|
||||
<ImpactMetric label="涉及节点" value={impact.affectedNodeCount} theme={theme} />
|
||||
<ImpactMetric label="删除" value={impact.destructiveCount} attention={impact.destructiveCount > 0} theme={theme} />
|
||||
@@ -198,11 +195,11 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
</details>
|
||||
) : null}
|
||||
{onReject || onApprove ? (
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button danger size="small" className="!h-8 flex-1" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
|
||||
拒绝执行
|
||||
</Button>
|
||||
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
|
||||
<Button size="small" className="!h-8 flex-1" icon={<CheckCircle2 className="size-3.5" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
|
||||
批准执行
|
||||
</Button>
|
||||
</div>
|
||||
@@ -242,27 +239,21 @@ function agentImpactFromDetail(detail: unknown) {
|
||||
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const state = toolCardState(title, text, detail);
|
||||
return (
|
||||
<details className="min-w-0 flex-1 rounded-md px-3 py-3 text-left" style={{ background: theme.spatial.surface, color: theme.node.text }}>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ color: state.color, background: state.softBg }}>
|
||||
<details data-agent-tool-card className="agent-tool-row group min-w-0 flex-1 text-left" style={{ color: theme.node.text }}>
|
||||
<summary className="cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<span className="agent-tool-status" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{state.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[13px] leading-5">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: state.color, background: state.softBg }}>
|
||||
<span className="agent-tool-label" style={{ color: state.color }}>
|
||||
{state.label}
|
||||
</span>
|
||||
{detail ? (
|
||||
<span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>
|
||||
详情
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
{detail ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform duration-200 group-open:rotate-180" style={{ color: theme.node.muted }} aria-hidden="true" /> : null}
|
||||
</div>
|
||||
<div className="truncate text-xs leading-5" style={{ color: state.isError ? state.color : theme.node.muted, maxWidth: "100%" }}>{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCanvasTheme } from "@/hooks/use-canvas-theme";
|
||||
import { Button, Modal, Segmented, Select } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, Cpu, FileText, Image as ImageIcon, Music, Sparkles, Video, Settings2, Trash2, X } from "lucide-react";
|
||||
import { AtSign, Copy, Cpu, FileText, Music, Sparkles, Video, Settings2, Trash2, X } from "lucide-react";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
@@ -26,19 +26,19 @@ import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
|
||||
import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe";
|
||||
import { activeGenerationConsumerController } from "@/services/generation-consumer-lifecycle";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
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 { budgetCanvasAgentHistory } from "@/lib/canvas/canvas-agent-context-budget";
|
||||
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";
|
||||
@@ -413,7 +413,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, setLocalSessionsState] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
||||
const localSessionsRef = useRef(localSessions);
|
||||
const [localActiveSessionId, setLocalActiveSessionIdState] = useState<string | null>(activeSessionId);
|
||||
@@ -486,10 +486,12 @@ export function CanvasAssistantPanel({
|
||||
const agentBusy = isRunning || Object.values(creativeBusy).some(Boolean) || safeSessions.some((session) => session.pendingBackendSession?.status === "pending");
|
||||
const activeCreativeId = messages.findLast((message) => Boolean(canvasCreativeDetail(message)))?.id;
|
||||
const latestAssistantMessage = messages.findLast((message) => message.role === "assistant");
|
||||
const currentCreativeState = latestAssistantMessage ? canvasCreativeDetail(latestAssistantMessage)?.state : undefined;
|
||||
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 currentCreativeState = activeCreativeId ? canvasCreativeDetail(messages.find((message) => message.id === activeCreativeId)!)?.state : latestAssistantMessage ? canvasCreativeDetail(latestAssistantMessage)?.state : undefined;
|
||||
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 };
|
||||
|
||||
@@ -500,8 +502,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));
|
||||
@@ -733,7 +754,7 @@ export function CanvasAssistantPanel({
|
||||
const toolMessage: CanvasAssistantMessage = {
|
||||
id: toolMessageId,
|
||||
role: "tool",
|
||||
title: "确认工具调用",
|
||||
title: `确认${capabilityBatchTitle(result.toolCalls)}`,
|
||||
text: summarizeToolCalls(result.toolCalls),
|
||||
detail: { status: "pending", step: loop.step, toolCalls: result.toolCalls, impact: previewOnlineToolCalls(result.toolCalls, snapshotRef.current, effectiveConfig) },
|
||||
};
|
||||
@@ -784,7 +805,7 @@ export function CanvasAssistantPanel({
|
||||
appendMessage(sessionId, {
|
||||
id: nanoid(),
|
||||
role: "tool",
|
||||
title: "工具自动执行完成",
|
||||
title: capabilityBatchTitle(result.toolCalls),
|
||||
text: toolResults.map((item) => toolResultText(item.result)).join("\n"),
|
||||
detail: { status: "completed", step, toolCalls: result.toolCalls, results: toolResults },
|
||||
});
|
||||
@@ -833,7 +854,7 @@ export function CanvasAssistantPanel({
|
||||
appendMessage(sessionId, {
|
||||
id: toolMessageId,
|
||||
role: "tool",
|
||||
title: "确认工具调用",
|
||||
title: `确认${capabilityBatchTitle(next.toolCalls)}`,
|
||||
text: summarizeToolCalls(next.toolCalls),
|
||||
detail: { status: "pending", step: step + 1, toolCalls: next.toolCalls, impact: previewOnlineToolCalls(next.toolCalls, snapshotRef.current, effectiveConfig) },
|
||||
});
|
||||
@@ -1027,7 +1048,7 @@ export function CanvasAssistantPanel({
|
||||
setIsRunning(true);
|
||||
const results = await executeOnlineToolCalls(session.id, toolCalls);
|
||||
addOnlineLog("工具执行结果", results);
|
||||
upsertMessage(session.id, { id: messageId, role: "tool", title: "工具执行完成", text: results.map((item) => toolResultText(item.result)).join("\n"), detail: { ...detail, results, status: "completed" } });
|
||||
upsertMessage(session.id, { id: messageId, role: "tool", title: capabilityBatchTitle(toolCalls), text: results.map((item) => toolResultText(item.result)).join("\n"), detail: { ...detail, results, status: "completed" } });
|
||||
pendingToolContextRef.current.delete(messageId);
|
||||
await continueOnlineToolLoopAfterResults(session.id, assistantId, previousMessages, toolCalls, results, pendingContext?.step || Number(detail.step) || 1);
|
||||
} catch (error) {
|
||||
@@ -1162,8 +1183,10 @@ export function CanvasAssistantPanel({
|
||||
<div ref={chatListRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
{messages.length ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="space-y-2">
|
||||
{messages.map((message, index) => (
|
||||
<Fragment key={message.id}>
|
||||
{agentBusy && index === messages.findLastIndex((item) => item.role === "user") + 1 ? <div className="creative-agent-thinking-wrap"><AgentWorkingMessage theme={theme} /></div> : null}
|
||||
<div className={message.role === "tool" ? "agent-tool-message" : "space-y-2"}>
|
||||
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} isStreaming={agentBusy && message.id === messages.at(-1)?.id && message.role === "assistant" && !canvasCreativeDetail(message)} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} onQuickAction={submitQuickAction} />
|
||||
{canvasCreativeDetail(message) && <CanvasCreativeInteraction
|
||||
message={message} sessionId={activeSession!.id} active={message.id === activeCreativeId && !isRunning && agentMode === "online"} config={effectiveConfig}
|
||||
@@ -1180,8 +1203,9 @@ export function CanvasAssistantPanel({
|
||||
/>}
|
||||
{message.references?.length ? <MessageReferences message={message} /> : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
|
||||
{agentBusy && messages.findLastIndex((item) => item.role === "user") === messages.length - 1 ? <div className="creative-agent-thinking-wrap"><AgentWorkingMessage theme={theme} /></div> : null}
|
||||
</>
|
||||
) : (
|
||||
<AgentChatEmptyState
|
||||
@@ -1206,24 +1230,33 @@ export function CanvasAssistantPanel({
|
||||
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>
|
||||
) : null}
|
||||
{currentCreativeState && <div className="px-3 pb-2" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||||
{currentCreativeState && <div className="creative-agent-plan-docked" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||||
<CreativePlanBar plan={creativePlan(currentCreativeState)} onLocateNode={(id) => onSelectNodeIds(new Set([id]))} />
|
||||
</div>}
|
||||
<div ref={composerRef}>
|
||||
<AgentChatComposer
|
||||
prompt={prompt}
|
||||
sending={agentBusy}
|
||||
placeholder="描述创作需求,或告诉我如何调整画布…"
|
||||
placeholder={currentCreativeState?.modificationRequested ? "在这里告诉我想改哪里,调整后再确认…" : "描述创作需求,或告诉我如何调整画布…"}
|
||||
theme={theme}
|
||||
references={buildSkillMentionReferences(composerSkills)}
|
||||
references={composerReferences}
|
||||
slashSkills={composerSkills}
|
||||
onPromptChange={setPrompt}
|
||||
onSubmit={submit}
|
||||
@@ -1485,34 +1518,122 @@ 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 = useCanvasTheme();
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const mentionReference = useMemo(() => assistantReferencesToMentionReferences([item]), [item]);
|
||||
const resolvedReference = useResolvedCanvasResourceReferences(mentionReference)[0];
|
||||
const title = item.title.trim() || "未命名节点";
|
||||
const kind = item.type === "image" ? "图片" : item.type === "video" ? "视频" : item.type === "audio" ? "音频" : item.type === "skill" ? "技能" : "文本";
|
||||
const Icon = item.type === "image" ? ImageIcon : item.type === "video" ? Video : item.type === "audio" ? Music : item.type === "skill" ? Sparkles : FileText;
|
||||
const Icon = item.type === "video" ? Video : item.type === "audio" ? Music : item.type === "skill" ? Sparkles : FileText;
|
||||
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 (
|
||||
<Tooltip title={`${kind} · ${title}`}>
|
||||
<div tabIndex={0} aria-label={`${kind}引用:${title}`} className="inline-flex h-8 max-w-[180px] shrink-0 items-center gap-1.5 rounded-lg px-2 text-xs focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--control-focus-ring)]" style={{ color: theme.node.text, background: theme.spatial.surface }}>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label ? `${label} · ` : ""}{title}</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 }}>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--fs-tiny)] opacity-80">{item.title}</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-5 shrink-0 place-items-center rounded hover:bg-[var(--control-selected-bg)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--control-focus-ring)]"
|
||||
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={actionStyle}
|
||||
onClick={onRemove}
|
||||
aria-label={`移除引用:${title}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -1769,6 +1890,13 @@ function summarizeToolCalls(calls: ResponseToolCall[]) {
|
||||
return calls.map((call) => toolCallLabel(call.function.name)).join(",") || "工具调用";
|
||||
}
|
||||
|
||||
function capabilityBatchTitle(calls: ResponseToolCall[]) {
|
||||
const names = calls.map((call) => call.function.name);
|
||||
if (names.some((name) => name.startsWith("canvas_") && (name.includes("skill") || name.includes("plugin")))) return "技能与插件";
|
||||
if (names.some((name) => name.startsWith("canvas_"))) return "画布操作";
|
||||
return "工具调用";
|
||||
}
|
||||
|
||||
function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOperationImpact {
|
||||
const ops: CanvasAgentOp[] = [];
|
||||
let deferredCinematicCount = 0;
|
||||
@@ -1842,11 +1970,22 @@ function toolCallLabel(name: string) {
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
return name;
|
||||
return "执行系统能力";
|
||||
}
|
||||
|
||||
function toolResultText(result: OnlineToolResult) {
|
||||
return result.message;
|
||||
return friendlyToolMessage(result.message);
|
||||
}
|
||||
|
||||
function friendlyToolMessage(message: string) {
|
||||
return message
|
||||
.replace(/canvas_[a-z0-9_]+/gi, "画布能力")
|
||||
.replace(/SKILL\.md/gi, "技能说明")
|
||||
.replace(/nodeId/gi, "节点").replace(/storageKey/gi, "素材引用")
|
||||
.replace(/executionEpoch|expectedStateHash|expectedRevision/gi, "当前画布状态")
|
||||
.replace(/toolCalls?/gi, "操作")
|
||||
.replace(/API|MCP|JSON|HTTP/gi, "系统服务")
|
||||
.replace(/\s{2,}/g, " ");
|
||||
}
|
||||
|
||||
function requireStringArray(value: unknown, field: string): string[] {
|
||||
@@ -1997,7 +2136,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) {
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
.creative-agent-card { container-type: inline-size; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--background); color: var(--foreground); display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; overflow-wrap: anywhere; }
|
||||
.creative-agent-card { container-type: inline-size; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--background); color: var(--foreground); display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; overflow-wrap: anywhere; }
|
||||
.creative-agent-card h3, .creative-agent-card p { margin: 0; }
|
||||
.creative-agent-card-content { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
||||
.creative-agent-receipt { padding: var(--space-2) var(--space-3); color: var(--muted-foreground); font-size: var(--fs-caption); line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.creative-agent-receipt { padding: var(--space-1) var(--space-2); color: var(--muted-foreground); font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.creative-agent-receipt summary { cursor: pointer; }
|
||||
.creative-agent-receipt p { margin-block: var(--space-2); }
|
||||
.creative-agent-next { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2) var(--space-1); color: var(--muted-foreground); line-height: 1.7; }
|
||||
.creative-agent-next { position: sticky; bottom: 0; z-index: 4; display: flex; flex-direction: column; gap: var(--space-1); margin: 0 -4px; padding: var(--space-1) var(--space-2) calc(var(--space-1) + env(safe-area-inset-bottom)); color: var(--muted-foreground); line-height: 1.5; font-size: var(--fs-caption); background: color-mix(in srgb, var(--background) 96%, transparent); border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); backdrop-filter: blur(10px); }
|
||||
.creative-agent-primary-actions { position: sticky; bottom: 0; z-index: 3; margin-inline: calc(var(--space-3) * -1); padding: var(--space-2) var(--space-3) 0; background: color-mix(in srgb, var(--background) 96%, transparent); }
|
||||
.creative-agent-next p { margin: 0; }
|
||||
.creative-agent-next .creative-agent-actions:empty { display: none; }
|
||||
.creative-agent-thinking-wrap { display: flex; align-items: center; min-height: 24px; padding: 0 2px; }
|
||||
.creative-agent-thinking-wrap > [role="status"] { font-size: var(--fs-caption); gap: var(--space-1); }
|
||||
.agent-tool-row { border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); padding: 2px 4px; }
|
||||
.agent-tool-message { margin: 0; }
|
||||
.agent-tool-row:last-child { border-bottom: 0; }
|
||||
.agent-tool-row summary { padding: 3px 0; }
|
||||
.agent-tool-status { display: grid; width: 18px; height: 18px; place-items: center; flex-shrink: 0; }
|
||||
.agent-tool-status svg { width: 15px; height: 15px; }
|
||||
.agent-tool-label { flex-shrink: 0; font-size: var(--fs-caption); }
|
||||
.agent-tool-row > .creative-agent-detail { margin: 4px 0 5px 26px; padding: 6px 8px; border-radius: var(--control-radius); background: color-mix(in srgb, var(--surface-card) 70%, transparent); font-size: var(--fs-caption); }
|
||||
.agent-pending-tool .creative-agent-detail { margin-top: 6px; padding: 6px 8px; font-size: var(--fs-caption); }
|
||||
.agent-pending-tool details { margin-top: 6px !important; padding-top: 0 !important; }
|
||||
.agent-pending-tool .creative-agent-archive-row { gap: 6px; }
|
||||
.creative-agent-receipt summary:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
|
||||
.creative-agent-archive-row { display: flex; align-items: center; gap: var(--space-1); min-width: 0; }
|
||||
.creative-agent-archive-row > button { flex-shrink: 0; }
|
||||
@@ -20,8 +34,8 @@
|
||||
.creative-agent-step-dots { display: flex; gap: var(--space-1); flex-wrap: wrap; max-width: 45%; }
|
||||
.creative-agent-step-dots i { width: var(--space-4); height: var(--space-1); border-radius: var(--control-radius); background: var(--border); }
|
||||
.creative-agent-step-dots i[data-current="true"] { background: var(--foreground); }
|
||||
.creative-agent-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); padding-top: var(--space-1); }
|
||||
.creative-agent-actions .ant-btn { max-width: 100%; height: auto; min-height: var(--space-8); white-space: normal; }
|
||||
.creative-agent-actions { display: flex; flex-wrap: nowrap; gap: var(--space-2); padding-top: var(--space-1); }
|
||||
.creative-agent-actions .ant-btn { max-width: 100%; height: 30px; min-height: 30px; padding-inline: 12px; white-space: nowrap; font-size: 13px; }
|
||||
.creative-agent-options { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.creative-agent-option { display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--control-radius); cursor: pointer; }
|
||||
.creative-agent-option[data-selected="true"] { background: var(--control-selected-bg); border-color: var(--control-selected-border); color: var(--control-selected-fg); }
|
||||
@@ -47,6 +61,9 @@
|
||||
.creative-agent-plan li p { color: var(--muted-foreground); margin: var(--space-1) 0 0; line-height: 1.6; }
|
||||
.creative-agent-plan li[data-status="completed"]::marker { color: var(--muted-foreground); }
|
||||
.creative-agent-plan-trigger { display: flex; width: 100%; gap: var(--space-2); white-space: normal; height: auto; min-height: var(--space-8); text-align: start; padding: var(--space-2) var(--space-3); background: var(--surface-card); border-color: var(--border); }
|
||||
.creative-agent-plan-docked { position: relative; z-index: 3; width: 100%; margin: 0; padding: 0 12px; display: flex; justify-content: flex-end; }
|
||||
.creative-agent-plan-docked .creative-agent-plan-trigger { width: auto; min-width: 0; max-width: 230px; min-height: 30px; padding: 5px 9px; border-radius: 999px; font-size: var(--fs-caption); box-shadow: 0 5px 16px var(--shadow-color, rgba(0,0,0,.12)); }
|
||||
.creative-agent-plan-docked .creative-agent-plan-current { flex: 0 1 auto; }
|
||||
.creative-agent-plan-count { flex-shrink: 0; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
|
||||
.creative-agent-plan-current { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.creative-agent-plan-chevron { color: var(--muted-foreground); flex-shrink: 0; }
|
||||
@@ -61,6 +78,7 @@
|
||||
.creative-agent-quote-total strong { text-align: end; font-size: var(--fs-heading-lg); font-variant-numeric: tabular-nums; }
|
||||
.creative-agent-card :focus-visible, .creative-agent-plan :focus-visible, .creative-agent-plan-trigger:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
|
||||
@container (max-width: 280px) {
|
||||
.creative-agent-actions { flex-wrap: wrap; }
|
||||
.creative-agent-deliverables { grid-template-columns: minmax(0, 1fr); }
|
||||
.creative-agent-quote-total { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function CreativeProposalCard({ proposal, onApprove, onModify, onRedirect
|
||||
{!archived && <Button className="creative-agent-detail-toggle" aria-expanded={expanded} aria-controls={detailId} onClick={() => setExpanded(!expanded)}>{expanded ? "收起完整方案" : "查看完整方案"}</Button>}
|
||||
{expanded && <div id={detailId} className="creative-agent-detail"><AIMessageMarkdown>{proposal.markdown}</AIMessageMarkdown></div>}
|
||||
{!archived && <><div className="creative-agent-scope-note">确认后创建本阶段节点并准备参数;图片、视频等生成费用另行确认。</div>
|
||||
<div className="creative-agent-actions"><Button type="primary" loading={busy} disabled={disabled || busy} onClick={onApprove}>确认方案</Button><Button disabled={disabled || busy} onClick={onModify}>修改</Button><Dropdown menu={{ items: [{ key: "redirect", label: "换个方向" }], onClick: onRedirect }} trigger={["click"]} disabled={disabled || busy}><Button disabled={disabled || busy}>更多</Button></Dropdown></div></>}
|
||||
<div className="creative-agent-actions creative-agent-primary-actions"><Button type="primary" loading={busy} disabled={disabled || busy} onClick={onApprove}>确认方案</Button><Button disabled={disabled || busy} onClick={onModify}>修改</Button><Dropdown menu={{ items: [{ key: "redirect", label: "换个方向" }], onClick: onRedirect }} trigger={["click"]} disabled={disabled || busy}><Button disabled={disabled || busy}>更多</Button></Dropdown></div></>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function CreativePlanBar({ plan, onLocateNode }: { plan?: CreativePlan |
|
||||
return <Popover open={open} onOpenChange={(next) => { setOpen(next); if (!next) trigger.current?.focus(); }} afterOpenChange={(next) => { if (next) panel.current?.focus(); }} placement="top" trigger="click" content={<div id={panelId} ref={panel} tabIndex={-1} className="creative-agent-plan" data-canvas-no-zoom data-canvas-wheel-scroll onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); close(); } }}>
|
||||
<div className="creative-agent-card-row"><strong>制作计划</strong><Button size="small" onClick={close}>收起</Button></div>
|
||||
<ol>{plan.steps.map((step) => <li key={step.id} data-status={step.status}><div className="creative-agent-card-row"><strong>{step.title}</strong><Tag>{stepLabels[step.status]}</Tag></div>{step.detail && <p>{step.detail}</p>}{onLocateNode && step.nodeIds?.map((id, index) => <Button key={id} type="link" onClick={() => onLocateNode(id)}>定位节点 {index + 1}</Button>)}</li>)}</ol>
|
||||
</div>}><Button ref={trigger} className="creative-agent-plan-trigger" aria-expanded={open} aria-controls={panelId} onKeyDown={(event) => { if (event.key === "Escape") close(); }}><span className="creative-agent-plan-count">{completed}/{plan.steps.length}</span><span className="creative-agent-plan-current">{running > 1 ? `运行中 ${running} 项` : current ? current.title : completed === plan.steps.length ? "计划已完成" : "查看制作计划"}</span><span className="creative-agent-plan-chevron" aria-hidden="true">{open ? "⌄" : "⌃"}</span></Button></Popover>;
|
||||
</div>}><Button ref={trigger} className="creative-agent-plan-trigger" aria-label="查看制作计划" aria-expanded={open} aria-controls={panelId} onKeyDown={(event) => { if (event.key === "Escape") close(); }}><span className="creative-agent-plan-count">{completed}/{plan.steps.length}</span><span className="creative-agent-plan-current">{running > 1 ? `运行中 ${running} 项` : current ? current.title : completed === plan.steps.length ? "计划已完成" : "查看制作计划"}</span><span className="creative-agent-plan-chevron" aria-hidden="true">{open ? "⌄" : "⌃"}</span></Button></Popover>;
|
||||
}
|
||||
|
||||
export function CreativeQuoteCard({ quote, onApprove, onRefresh, busy, disabled }: { quote: CreativeQuote; onApprove: () => void; onRefresh?: () => void; busy?: boolean; disabled?: boolean }) {
|
||||
@@ -108,6 +108,6 @@ export function CreativeQuoteCard({ quote, onApprove, onRefresh, busy, disabled
|
||||
{quote.expiresAt && <p>有效期至 {quote.expiresAt}</p>}{quote.approvedQuantity !== undefined && <p>已批准 {quote.approvedQuantity} 项</p>}
|
||||
{onRefresh && <Button className="creative-agent-detail-toggle" type="text" disabled={disabled || busy} onClick={onRefresh}>更新报价</Button>}</div></details>
|
||||
{quote.externalBilling && <p>由外部渠道计费,平台无法封顶第三方账单。</p>}
|
||||
<div className="creative-agent-actions"><Button type="primary" loading={busy} disabled={disabled || busy} onClick={onApprove}>确认生成 · {quote.amountLabel}</Button></div>
|
||||
<div className="creative-agent-actions creative-agent-primary-actions"><Button type="primary" loading={busy} disabled={disabled || busy} onClick={onApprove}>确认生成 · {quote.amountLabel}</Button></div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user