From eb7344f92aa820c4361115b475ab74d97cab5bbc Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Tue, 15 Sep 2026 13:38:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E5=8A=A9=E6=89=8B=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=20-=20=E4=BC=98=E5=8C=96=E6=8E=A8=E7=90=86=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E3=80=81=E5=B7=A5=E5=85=B7=E5=88=86=E7=BB=84=E4=B8=8E?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-cloud-agent-chat-ui.tsx | 75 +++-- .../canvas/canvas-cloud-agent-panel.tsx | 20 +- .../components/canvas/canvas-cloud-agent.css | 308 +++++++++++++++++- .../lib/canvas/agent-approval-presentation.ts | 4 +- web/src/lib/canvas/agent-tool-presentation.ts | 36 ++ web/src/lib/creation/creative-agent-tools.ts | 3 +- web/src/services/api/agent.ts | 6 +- web/test/agent-chat-tool-card.test.tsx | 18 + web/test/agent-tool-presentation.test.ts | 10 +- web/test/creative-agent-controller.test.ts | 15 + 10 files changed, 457 insertions(+), 38 deletions(-) diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 612a9cd3e..4a0e40819 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -1,10 +1,10 @@ import { agentCanvasActions, agentCanvasActionLabel } from "@/lib/canvas/agent-canvas-actions"; import { Button } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; -import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; -import { ArrowUp, AtSign, CheckCircle2, CircleAlert, CircleDot, ImagePlus, LoaderCircle, RotateCcw, Sparkles, Square, X, XCircle } from "lucide-react"; +import { ArrowUp, AtSign, CheckCircle2, ChevronDown, CircleAlert, CircleDot, Eye, ImagePlus, LoaderCircle, Pencil, Plus, RotateCcw, Sparkles, Square, X, XCircle } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -13,7 +13,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { Skill } from "@/services/api/skills"; import { buildSkillMentionReferences } from "@/services/skill-runtime"; -import { agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; +import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; export type CloudAgentChatAttachment = { id: string; name: string; url: string }; type CloudAgentOperationImpact = { @@ -93,10 +93,28 @@ export function AgentChatMessage({ const isSystem = item.role === "system"; const color = item.role === "error" ? "#ef4444" : theme.node.text; if (item.reasoning) { - return
- {item.streaming ? "模型推理中" : "模型推理摘要"} -
{item.text}
-
; + return ( +
+
+ + + + {item.streaming ? "模型正在思考" : "模型思考"} + {item.streaming ? "整理目标与下一步" : "推理摘要"} + + + {item.streaming ? + +
+
+
+
+ ); } if (isSystem) { return ( @@ -278,26 +296,39 @@ function agentImpactFromDetail(detail: unknown) { export function AgentToolCard({ title, text, detail, theme, references = [], onFocusNode }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; references?: CanvasResourceReference[]; onFocusNode?: (nodeId: string) => void }) { const state = toolCardState(title, text, detail); const toolName = agentToolName(title, detail); + const category = agentToolCategory(toolName, detail); + const categoryLabel = agentToolCategoryLabel(toolName, category); const summary = friendlyAgentToolSummary(toolName, text, detail); const actions = agentCanvasActions(toolName, detail, references); - const visibleActions = actions.slice(0, 8); - const readNodeCount = toolName === "canvas_get_state" && !state.isError ? readCanvasNodeCount(detail) : 0; - const hiddenReadNodeCount = Math.max(0, readNodeCount - visibleActions.length); + const isNodeRead = toolName === "canvas_get_state" && category === "read"; + const [readExpanded, setReadExpanded] = useState(false); + const visibleActions = actions.slice(0, isNodeRead && !readExpanded ? 1 : 8); + const collapsedReadNodeCount = isNodeRead ? Math.max(0, actions.length - visibleActions.length) : 0; + const isPlain = !actions.length && !state.isError; const conciseError = text.length > 180 ? `${text.slice(0, 180)}…` : text; + const categoryIcon = category === "read" ? : category === "create" ? : ; return ( -
+
- {actions.length ?
- {visibleActions.map((action) => )} - {hiddenReadNodeCount > 0 ? 已读取 {readNodeCount} 个节点,已折叠其余 {hiddenReadNodeCount} 个 : null} -
: summary} - {state.isError && actions.length ? {summary} : null} - {state.isError && text && text !== summary ? {conciseError} : null} +
+ {categoryIcon}{categoryLabel} + {summary} + {state.label !== "已完成" ? {state.label} : null} +
+ {actions.length ?
+ {visibleActions.map((action) => )} + {isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? ( + + ) : null} +
: null} + {state.isError && text && text !== summary ? {conciseError} : null} {state.isError && text.length > 180 ?
查看完整错误详情

{text}

: null} - {state.isError && objectField(objectField(detail, "result"), "taskId") ? 任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))} : null} + {state.isError && objectField(objectField(detail, "result"), "taskId") ? 任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))} : null}
- {state.label !== "已完成" ? {state.label} : 已完成} + {state.label === "已完成" ? 已完成 : null}
); } @@ -755,9 +786,3 @@ function agentToolName(title: string, detail?: unknown) { function objectField(value: unknown, key: string) { return value && typeof value === "object" ? (value as Record)[key] : undefined; } - -function readCanvasNodeCount(detail: unknown) { - const result = objectField(detail, "result"); - const nodes = objectField(result, "nodes"); - return Array.isArray(nodes) ? nodes.length : 0; -} diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 9f4566e30..d5353c042 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -100,8 +100,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re const installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]); const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]); const status = run?.status || "idle"; - const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : "待命"; - const statusColor = status === "failed" ? "#e66b6b" : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted; + const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : status === "cancelled" ? "已停止" : status === "rejected" ? "已拒绝" : "待命"; + const statusColor = status === "failed" ? "#e66b6b" : status === "rejected" || status === "cancelled" ? theme.node.muted : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted; useEffect(() => { if (!open || view !== "chat") setSkillsOpen(false); @@ -447,7 +447,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re await decideAgentApproval(runId, approvalId, decision, approval.reason, AbortSignal.timeout(15_000)); if (currentScope.current === scope) { setApproval((current) => current?.approvalId === approvalId ? null : current); - setRun((current) => current?.id === runId && current.status === "waiting_approval" && (!current.approval || current.approval.approvalId === approvalId) ? { ...current, status: "running", approval: undefined } : current); + setRun((current) => current?.id === runId && current.status === "waiting_approval" && (!current.approval || current.approval.approvalId === approvalId) ? { ...current, status: decision === "reject" ? "rejected" : "running", approval: undefined } : current); } } catch (cause) { if (currentScope.current !== scope) return; @@ -1021,7 +1021,7 @@ function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange } function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) { - const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : "生成"; + const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : "生成"; const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => { if (!title) return null; const content = <>{title}{typeLabel ? {typeLabel} : null}; @@ -1081,7 +1081,17 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch appendUniqueMessage(current, { + id: event.eventId, + role: "system", + text: text || "已拒绝本次操作,未写入画布。你可以告诉 Agent 修改方向后重新申请。", + })); + } + return; + } if (event.type === "progress_summary") { setMessages((current) => appendUniqueMessage(current, { id: event.eventId, role: "system", text: text || "Agent 正在整理执行计划" })); return; diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css index ce6d78d21..3ee065fcc 100644 --- a/web/src/components/canvas/canvas-cloud-agent.css +++ b/web/src/components/canvas/canvas-cloud-agent.css @@ -91,9 +91,140 @@ } .agent-tool-row { + /* 行为用图标和文案区分,颜色只留给真实的成功/失败状态。 */ + --agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent); + min-height: 42px; + border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--foreground) 2.5%, var(--background)); + padding: 9px 10px; + transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease; +} + +.agent-tool-row:hover { + border-color: color-mix(in srgb, var(--foreground) 20%, transparent); + background: color-mix(in srgb, var(--foreground) 4%, var(--background)); +} + +/* 没有节点明细的“读取信息 / 操作画布”只保留时间线语义,不再撑成一张空卡片。 */ +.agent-tool-row--plain { min-height: 28px; + border-color: transparent; + border-radius: 9px; + background: transparent; + padding: 3px 2px; +} + +.agent-tool-row--plain:hover { + border-color: transparent; + background: transparent; +} + +.agent-tool-row--read { + --agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent); +} + +.agent-tool-row--create { + --agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent); +} + +.agent-tool-row--operate { + --agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent); +} + +.agent-tool-header { + display: flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: 7px; + line-height: 18px; +} + +.agent-tool-category { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 5%, transparent); + padding: 2px 7px 2px 6px; + color: color-mix(in srgb, var(--foreground) 76%, transparent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.01em; + line-height: 15px; +} + +.agent-tool-summary { + min-width: 0; + flex: 1 1 180px; + overflow: hidden; + color: color-mix(in srgb, currentColor 82%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-tool-action-list { + display: flex; + min-width: 0; + flex-direction: column; + align-items: stretch; + gap: 4px; + margin-top: 8px; +} + +.agent-tool-action-link { + min-width: 0; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); border-radius: 8px; - padding: 2px 6px; + background: color-mix(in srgb, var(--foreground) 3%, transparent); + padding: 4px 7px; + color: color-mix(in srgb, currentColor 88%, transparent); + font-size: 11px; + line-height: 17px; + overflow-wrap: anywhere; + text-align: left; + transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease; +} + +.agent-tool-action-link:not(:disabled) { + cursor: pointer; +} + +.agent-tool-action-link:not(:disabled):hover { + border-color: color-mix(in srgb, var(--foreground) 26%, transparent); + background: color-mix(in srgb, var(--foreground) 7%, transparent); + color: var(--foreground); +} + +.agent-tool-action-link:focus-visible { + outline: 2px solid color-mix(in srgb, var(--foreground) 56%, transparent); + outline-offset: 1px; +} + +.agent-tool-more { + align-self: flex-start; + border: 0; + background: transparent; + padding: 2px 2px 0; + color: color-mix(in srgb, currentColor 64%, transparent); + font-size: 11px; + line-height: 17px; + text-align: left; +} + +.agent-tool-more:hover { + color: var(--foreground); + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; +} + +@media (prefers-reduced-motion: reduce) { + .agent-tool-row, + .agent-tool-action-link { transition: none; } } .agent-composer-prompt-scroll { @@ -169,6 +300,181 @@ white-space: nowrap; } +/* + * 推理摘要不是普通的折叠文本:它是 Agent 工作流中的“过程层”,需要 + * 比工具日志更安静、比正文更有层次。使用当前画布主题的 accent,避免 + * 固定紫色的 AI 默认样式,同时把实时状态、摘要内容和展开动作分开。 + */ +.agent-reasoning { + min-width: 0; + color: color-mix(in srgb, var(--foreground) 64%, transparent); +} + +.agent-reasoning-card { + min-width: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--foreground) 2.5%, var(--background)); + box-shadow: 0 5px 18px color-mix(in srgb, var(--foreground) 3%, transparent); + transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; +} + +.agent-reasoning-card[open] { + border-color: color-mix(in srgb, var(--agent-reasoning-accent) 28%, transparent); + background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background)); + box-shadow: 0 8px 24px color-mix(in srgb, var(--agent-reasoning-accent) 7%, transparent); +} + +.agent-reasoning-summary { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; + cursor: pointer; + list-style: none; + padding: 9px 10px; + color: color-mix(in srgb, var(--foreground) 72%, transparent); + user-select: none; +} + +.agent-reasoning-summary::-webkit-details-marker { + display: none; +} + +.agent-reasoning-summary:hover { + background: color-mix(in srgb, var(--agent-reasoning-accent) 4%, transparent); +} + +.agent-reasoning-icon { + display: grid; + width: 25px; + height: 25px; + flex: 0 0 25px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--agent-reasoning-accent) 24%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--agent-reasoning-accent) 11%, transparent); + color: var(--agent-reasoning-accent); +} + +.agent-reasoning-copy { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: baseline; + gap: 7px; +} + +.agent-reasoning-title { + min-width: 0; + overflow: hidden; + color: color-mix(in srgb, var(--foreground) 78%, transparent); + font-size: 12px; + font-weight: 650; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-reasoning-subtitle { + min-width: 0; + overflow: hidden; + color: color-mix(in srgb, var(--foreground) 43%, transparent); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-reasoning-status { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 4%, transparent); + padding: 2px 7px; + color: color-mix(in srgb, var(--foreground) 45%, transparent); + font-size: 10px; + font-weight: 600; + line-height: 15px; +} + +.agent-reasoning-status.is-live { + border-color: color-mix(in srgb, var(--agent-reasoning-accent) 22%, transparent); + background: color-mix(in srgb, var(--agent-reasoning-accent) 9%, transparent); + color: var(--agent-reasoning-accent); +} + +.agent-reasoning-status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 13%, transparent); + animation: agent-reasoning-pulse 1.5s ease-in-out infinite; +} + +.agent-reasoning-chevron { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 18px; + place-items: center; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + font-size: 17px; + line-height: 12px; + transform: translateY(-1px); + transition: color 160ms ease, transform 160ms ease; +} + +.agent-reasoning-card[open] .agent-reasoning-chevron { + color: var(--agent-reasoning-accent); + transform: rotate(180deg) translateY(1px); +} + +.agent-reasoning-content { + position: relative; + display: flex; + max-height: min(232px, 30vh); + gap: 10px; + overflow: auto; + border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); + padding: 10px 12px 12px 14px; + scrollbar-gutter: stable; +} + +.agent-reasoning-rail { + width: 2px; + flex: 0 0 2px; + align-self: stretch; + border-radius: 999px; + background: linear-gradient(180deg, color-mix(in srgb, var(--agent-reasoning-accent) 72%, transparent), color-mix(in srgb, var(--agent-reasoning-accent) 12%, transparent)); +} + +.agent-reasoning-text { + min-width: 0; + color: color-mix(in srgb, var(--foreground) 58%, transparent); + font-size: 12px; + line-height: 1.75; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@keyframes agent-reasoning-pulse { + 0%, 100% { opacity: .55; transform: scale(.82); } + 50% { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .agent-reasoning-card, + .agent-reasoning-chevron { transition: none; } + + .agent-reasoning-status-dot { animation: none; } +} + .agent-working-indicator { display: inline-flex; min-height: 28px; diff --git a/web/src/lib/canvas/agent-approval-presentation.ts b/web/src/lib/canvas/agent-approval-presentation.ts index 5e6047d96..5a699016c 100644 --- a/web/src/lib/canvas/agent-approval-presentation.ts +++ b/web/src/lib/canvas/agent-approval-presentation.ts @@ -41,7 +41,7 @@ function fieldLabel(key: string, nodeType: string): string { } function operation(value: unknown): AgentApprovalPreviewOperation | null { - return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" ? value : null; + return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" ? value : null; } function normalizeServerPreview(value: unknown): AgentApprovalPresentation | null { @@ -127,4 +127,4 @@ export function agentApprovalPresentation(detail: unknown): AgentApprovalPresent if (name === "canvas_apply_ops") return canvasFallback(args); if (name === "generate_media") return mediaFallback(args, payload); return { source: "fallback", kind: "agent_approval", title: "确认执行操作", description: "Agent 请求执行一项操作,但审批参数无法识别;请重新读取画布后再申请审批。", items: [] }; -} \ No newline at end of file +} diff --git a/web/src/lib/canvas/agent-tool-presentation.ts b/web/src/lib/canvas/agent-tool-presentation.ts index 41f2d0010..39c889cdd 100644 --- a/web/src/lib/canvas/agent-tool-presentation.ts +++ b/web/src/lib/canvas/agent-tool-presentation.ts @@ -7,6 +7,42 @@ export const AGENT_TOOL_METADATA: Record pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交", failureMessage: "媒体生成未完成" }, }; +export type AgentToolCategory = "read" | "create" | "operate"; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function toolArguments(detail?: unknown) { + const value = record(detail).arguments; + if (typeof value !== "string") return record(value); + try { return record(JSON.parse(value)); } catch { return {}; } +} + +/** + * Keeps the activity feed semantic instead of presenting every tool call as + * the same check-mark row. This is intentionally based on the tool contract, + * not on translated copy, so the visual grouping remains stable as messages + * change or get localized. + */ +export function agentToolCategory(toolName: string, detail?: unknown): AgentToolCategory { + if (["canvas_get_state", "canvas_list_node_types", "model_list", "task_get", "skills_load", "skill_read_file"].includes(toolName)) return "read"; + if (toolName === "generate_media") return "create"; + if (toolName === "canvas_apply_ops") { + const actions = record(detail).actions; + if (Array.isArray(actions) && actions.some((item) => record(item).action === "created" || record(item).action === "generating")) return "create"; + const ops = toolArguments(detail).ops; + if (Array.isArray(ops) && ops.some((item) => record(item).type === "add_node")) return "create"; + } + return "operate"; +} + +export function agentToolCategoryLabel(toolName: string, category: AgentToolCategory): string { + if (category === "read") return toolName === "canvas_get_state" ? "读取节点" : "读取信息"; + if (category === "create") return "创建节点"; + return "操作画布"; +} + type ToolStatus = "completed" | "failed" | "noop" | "rejected" | "pending"; function field(value: unknown, key: string): unknown { diff --git a/web/src/lib/creation/creative-agent-tools.ts b/web/src/lib/creation/creative-agent-tools.ts index 33c10ef63..f0d3b1e9e 100644 --- a/web/src/lib/creation/creative-agent-tools.ts +++ b/web/src/lib/creation/creative-agent-tools.ts @@ -29,10 +29,11 @@ export const CREATIVE_AGENT_TOOLS: ResponseFunctionTool[] = [{ export const CREATIVE_AGENT_SYSTEM_PROMPT = `你是影策的通用创作助手,支持短片、营销、电商与一般创作。普通咨询直接回答,不强制问卷、方案或任务计划。 用户授权在当前任务范围内持续有效,直到用户撤回、修改目标或出现范围外操作。“可以”“好的”“按你说的做”应结合紧邻的明确建议理解为同意该建议;“你决定”“自主处理”允许你决定该任务内的常规创意和操作细节。不要把模糊同意扩大到未提及的删除、额外费用或新任务。用户已授权的读取、素材挑选、文本编辑、布局和技术纠错应直接推进,不为每个子步骤重新征求同意。常规可调整的创意选择自行决定并简短说明,只对真正阻塞执行、无法从现有素材查明的信息提问。 多步骤创作使用 creative_respond。先结合全部需求摘要、会话、用户素材、专业角色指导、选中技能与真实画布,提取已知信息;只有缺失或冲突且确实影响执行的信息才补问,通常1至3题,确需一起决定时最多6题,不凑题。允许自定义安全的需求字段,优先复用已有字段,不通过改名重复询问。用户直接输入的自然语言回答与卡片回答同等有效,可以同时更新多个字段。不得将猜测写成用户确认。用户允许自主决定时可以直接给方案。 +当前首页规划阶段唯一可调用的函数工具是 creative_respond。availableModels、references 和 canvas 是本次请求附带的只读快照,不是可继续调用的工具。不要虚构 canvas_*、model_* 或其他未注册工具调用,也不要声称已读取、创建、提交或生成快照中没有的结果;节点落地和媒体提交只会在程序完成方案校验并取得对应确认后发生。 多步骤任务可携带 plan,按本次实际工作命名步骤,例如“确定结尾情绪”“设计商品构图”“制作首张主图”;不套固定步骤数。每步 ref 在同一工作持续时保持稳定,新工作使用新ref。phase只用于程序关联真实状态:questions=需求选择,proposal=内容方案,canvas=节点落地,media=产物生成(mediaRefs关联方案生成项ref)。可以调整未完成步骤并在reason说明原因;不编造完成状态。普通咨询不创建计划。已完成工作不可抹掉,新增任务不能复用已完成步骤ref。 问题与方案不能在同一次响应同时推进。已经确认的题材/时长/数量/比例/风格不再问。覆盖已确认字段必须在 evidence 引用最新用户消息中明确修改该字段的原文;不能将建议或旧消息作为修改授权。素材题的 option.id 只能使用上下文提供的真实素材ID。 方案应是可阅读的完整 Markdown,包含内容、已有素材、待生成产物、规格和真实依赖。workflow 支持 text/image/video/styleboard/story_input/script;搭建短剧工作流时使用真实的 styleboard 项目画风选择节点、story_input 故事梗概和 script 分镜表,已有节点优先沿用。script.shots 必须逐镜填写 durationSeconds、videoMotionPrompt、dialogue,不用普通文本模拟表格。styleboard 是待选择入口,不把任意文字当作已应用画风;用户已有画风时不重复创建。只有 image/video 填写 generationItems;创建分镜表和画风入口不代表生成媒体。媒体必须有完整提示词,文本必须有正文。每个待生成媒体节点有且仅有一项 generationItems,使用可用模型的完整 model ID。用户已有图片优先用 image 节点的 assetId 指向真实 references.id,此类节点不写 generationItems、不重复生成,可被其他节点用 referenceRefs 引用。 -分镜任务先检查 canvas_get_context.storyboardReadiness 或 canvas_read_storyboard.readiness。缺少画风时先 canvas_list_styles,再按用户已确认的风格或自主决策授权调用 canvas_apply_style;只有确实需要用户取舍时才提问,不重复询问已有风格。应用后重新读取上下文确认 canGenerateStoryboard,不能只在正文声称画风已设置。用户给的是剧本、剧情或要求自动拆镜时,优先 canvas_generate_storyboard 复用项目专业拆镜能力,不自己编一组 shots 替代专业任务。没有分镜节点时,在方案获批或用户已明确要求搭建的范围内调用 canvas_create_storyboard,将原始剧本保存在底部输入框,再调用 canvas_generate_storyboard;待拆镜空表不代表完成。专业任务会展示费用与整表替换确认,用户取消后停止,不重复请求。已有明确镜头清单需要录入,或局部修改时,用 canvas_read_storyboard 获取真实行 ID,再 canvas_edit_storyboard 更新或追加,保留其余行与素材。方案直接交付已拆好的 script 时仍必须提供非空 shots。拆镜后重新读取真实表格,核对总时长和镜头数;5秒、10秒等节点时长选项是单镜头时长,总片长必须写入拆镜要求。不要把项目画风全文重复灌进每行作为镜头描述,不声称有镜头行就已经生成图片视频。 +分镜任务先检查只读 canvas 快照中的现有 styleboard、story_input 和 script 节点,已有节点优先沿用,不为同一职责重复创建。用户要求交付明确镜头清单或搭建可维护的多镜头工作流时,proposal.workflow 使用 script 节点并提供非空 shots,逐镜核对总时长、单镜头时长、运动提示词和对白;不要用多个 text/Markdown 节点模拟分镜表。用户只要求单画面、单镜头试验或普通脚本文档时,可以选择更轻量的节点,不为形式强加分镜。当前规划阶段不能直接运行专业自动拆镜、应用画风或修改真实分镜行;如任务依赖这些后续能力,在方案和 plan 中如实标记为节点落地后的待执行步骤,不虚构工具调用、费用确认、真实行 ID 或已生成结果。不要把项目画风全文重复灌进每行作为镜头描述,不声称有镜头行就已经生成图片视频。 generationItems 是方案的声明式生成配置,用于校验和之后的报价,不会立即调用媒体模型。因此“确认后再生成”仍必须填写它,不能省略。每项 ref 必须对应节点 ref,mode 必须等于节点 kind,model 必须来自 availableModels 中相同 mode 的条目;图片模型不能用于视频。上下文若有 rejectedProposal,先根据其中真实校验错误修正,保留已知需求并返回完整方案。结构化方案和费用的批准以程序记录为准;若仍缺按钮确认,直接显示对应卡片,不再先问一遍是否同意、再让用户点卡片。 引用已有画布图片时,将真实 references.id 放到 workflow.nodes[].referenceNodeIds;referenceRefs 优先用于本次方案内其他图片节点的 ref。不要为引用现有图片再生成一张副本,不要在两个字段重复放同一张图。程序会核实真实资源并整理引用,未知ID不能通过。 单段视频可以在正文描述多个叙事节奏,不把它们拆成未合成的几段视频。用户要求15秒时只选实际支持15秒的模型;不存在则解释缺少能力并询问可接受替代,绝不能偷偷缩短。图像引用数量、视频输入角色、尺寸均须符合上下文能力。边表达真实依赖,不为了好看造线。 diff --git a/web/src/services/api/agent.ts b/web/src/services/api/agent.ts index ba6d13b29..9a5ec035d 100644 --- a/web/src/services/api/agent.ts +++ b/web/src/services/api/agent.ts @@ -20,7 +20,7 @@ export type AgentProfileView = { layers: AgentProfileLayer[]; }; -export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "generate_media"; +export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "generate_media" | "create_storyboard" | "edit_storyboard"; export type AgentApprovalPreviewItem = { operation: AgentApprovalPreviewOperation; @@ -57,7 +57,7 @@ export type AgentApproval = { export type AgentRun = { id: string; canvasId: string; - status: "queued" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled"; + status: "queued" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled" | "rejected"; permissionMode: AgentPermissionMode; revision?: number; cleanupPending?: boolean; @@ -250,7 +250,7 @@ export function subscribeAgentEvents(runId: string, onEvent: (event: AgentEvent) lastStatusKey = statusKey; emit("run_status", statusPayload); } - terminal = !run.cleanupPending && ["completed", "failed", "cancelled"].includes(run.status); + terminal = !run.cleanupPending && ["completed", "failed", "cancelled", "rejected"].includes(run.status); } else if (item.event === "error") throw new Error("Agent 状态读取失败"); }, done); // A repeated initial snapshot is not a healthy connection. diff --git a/web/test/agent-chat-tool-card.test.tsx b/web/test/agent-chat-tool-card.test.tsx index 9c9ed9df4..8e5298ac5 100644 --- a/web/test/agent-chat-tool-card.test.tsx +++ b/web/test/agent-chat-tool-card.test.tsx @@ -10,6 +10,24 @@ test("completed tools keep an accessible status without a duplicate visual badge expect(html).not.toContain("agent-tool-label"); }); +test("empty read and operation events stay flat instead of rendering empty cards", () => { + const readHtml = renderToStaticMarkup(); + const operationHtml = renderToStaticMarkup(); + expect(readHtml).toContain("agent-tool-row--plain"); + expect(operationHtml).toContain("agent-tool-row--plain"); +}); + +test("canvas reads show one node by default and expose the collapsed count", () => { + const html = renderToStaticMarkup( {}} />); + expect(html).toContain("读取了图片节点《第一个节点》"); + expect(html).not.toContain("读取了文本节点《第二个节点》"); + expect(html).toContain("已折叠 2 个节点,展开查看"); +}); + test("failed tools retain a visible failure label", () => { const html = renderToStaticMarkup(); expect(html).toContain("agent-tool-label"); diff --git a/web/test/agent-tool-presentation.test.ts b/web/test/agent-tool-presentation.test.ts index e93de7e0a..478450e28 100644 --- a/web/test/agent-tool-presentation.test.ts +++ b/web/test/agent-tool-presentation.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "bun:test"; -import { agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; +import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; describe("Agent tool presentation", () => { + it("separates read, create, and canvas operation activity", () => { + expect(agentToolCategory("canvas_get_state", { eventType: "tool_completed" })).toBe("read"); + expect(agentToolCategoryLabel("canvas_get_state", "read")).toBe("读取节点"); + expect(agentToolCategory("generate_media", { eventType: "generation_task_created" })).toBe("create"); + expect(agentToolCategory("canvas_apply_ops", { eventType: "canvas_updated", actions: [{ action: "updated" }] })).toBe("operate"); + expect(agentToolCategory("canvas_apply_ops", { eventType: "canvas_updated", actions: [{ action: "created" }] })).toBe("create"); + }); + it("distinguishes media submission from a completed canvas result", () => { expect(friendlyAgentToolSummary("generate_media", "", { eventType: "generation_task_created" })).toBe("媒体节点已创建,生成任务已提交"); expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_completed" })).toBe("生成结果已回写画布节点"); diff --git a/web/test/creative-agent-controller.test.ts b/web/test/creative-agent-controller.test.ts index 6ee69e13f..322b88ea8 100644 --- a/web/test/creative-agent-controller.test.ts +++ b/web/test/creative-agent-controller.test.ts @@ -6,6 +6,21 @@ import { applyCanvasOperations, type CanvasSnapshot } from "../src/lib/canvas/ca import { defaultConfig } from "../src/stores/use-config-store"; import type { GenerationTask } from "../src/services/api/task-center"; import { CanvasNodeType } from "../src/types/canvas"; +import { CREATIVE_AGENT_SYSTEM_PROMPT, CREATIVE_AGENT_TOOLS } from "../src/lib/creation/creative-agent-tools"; + +test("首页创造规划请求使用自动工具选择", async () => { + const source = await Bun.file(new URL("../src/services/creative-agent-controller.ts", import.meta.url)).text(); + expect(source).toContain('tools: CREATIVE_AGENT_TOOLS, toolChoice: "auto"'); + expect(source).not.toContain('tools: CREATIVE_AGENT_TOOLS, toolChoice: "required"'); +}); + +test("首页创造提示词只声明实际注册的规划工具", () => { + expect(CREATIVE_AGENT_TOOLS.map((tool) => tool.function.name)).toEqual(["creative_respond"]); + expect(CREATIVE_AGENT_SYSTEM_PROMPT).toContain("唯一可调用的函数工具是 creative_respond"); + expect(CREATIVE_AGENT_SYSTEM_PROMPT).not.toContain("canvas_generate_storyboard"); + expect(CREATIVE_AGENT_SYSTEM_PROMPT).not.toContain("canvas_create_storyboard"); + expect(CREATIVE_AGENT_SYSTEM_PROMPT).not.toContain("canvas_edit_storyboard"); +}); function harness(state: CreativeAgentState, status: CreationRun["status"] = "paused", submissions: CreationSubmission[] = [], waitTask?: ConstructorParameters[0]["waitTask"]) { let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record, approvedProposalVersion: state.proposal?.version, approvedProposalHash: state.operations ? "approved-proposal-hash" : undefined, createdAt: "", updatedAt: "" };