merge: 合并 Agent 面板 UI 优化 PR #624
This commit is contained in:
@@ -46,6 +46,14 @@ export function agentCanvasActions(toolName: string, detail: unknown, references
|
||||
}
|
||||
} else if (toolName === "canvas_get_state" && !failed && Array.isArray(result.nodes)) {
|
||||
for (const value of result.nodes) { const node = agentRecord(value); add(node.id, node.title, node.type, "read"); }
|
||||
} else if (toolName === "canvas_inspect_image" && !failed) {
|
||||
// 看图回执:reuseObservation 是复用账本里的观察(没有再附图)、repeat 是读循环护栏
|
||||
// (只回执文字)。两者都没有把画面送出去,所以不能报成"查看了…画面"。
|
||||
if (result.reuseObservation === true) add(result.nodeId, result.title, "image", "reused");
|
||||
else if (result.repeat !== true) {
|
||||
add(result.nodeId, result.title, "image", "viewed");
|
||||
if (Array.isArray(result.batchImages)) for (const value of result.batchImages) { const image = agentRecord(value); add(image.nodeId, image.title, "image", "viewed"); }
|
||||
}
|
||||
} else if (toolName === "canvas_apply_ops" && !failed && Array.isArray(args.ops)) {
|
||||
for (const value of args.ops) {
|
||||
const op = agentRecord(value);
|
||||
@@ -64,7 +72,8 @@ export function agentCanvasActionLabel(action: AgentCanvasAction) {
|
||||
const targetKind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record<string, string>)[action.targetNodeType || ""] || "";
|
||||
return `建立引用:${kind}节点《${action.title}》 → ${targetKind}节点《${action.targetTitle}》`;
|
||||
}
|
||||
const verb = ({ created: "创建了", updated: "更新了", referenced: "引用了", read: "读取了", generating: "已提交生成:", generated: "已生成", failed: "生成未完成:" } as Record<string, string>)[action.action] || "定位";
|
||||
// read 的动词必须是"存在式":canvas_get_state 只读到清单,说成"读取了图片节点"会被读成看过画面。
|
||||
const verb = ({ created: "创建了", updated: "更新了", referenced: "引用了", read: "画布上有", viewed: "查看了", reused: "复用观察:", generating: "已提交生成:", generated: "已生成", failed: "生成未完成:" } as Record<string, string>)[action.action] || "定位";
|
||||
const fieldText = action.fields?.length ? `(修改:${action.fields.join("、")})` : "";
|
||||
const titleText = action.resultTitle ? `,名称改为《${action.resultTitle}》` : "";
|
||||
return `${verb}${kind}节点《${action.title}》${fieldText}${titleText}`;
|
||||
|
||||
115
web/src/lib/canvas/agent-operation-feed.ts
Normal file
115
web/src/lib/canvas/agent-operation-feed.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { agentToolCategory, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary } from "./agent-tool-presentation";
|
||||
import { agentToolRetry } from "./agent-tool-retry";
|
||||
|
||||
/**
|
||||
* Agent 的"操作信息"(工具调用记录)在消息流里是一条条 `role === "tool"` 的消息。一轮运行动辄
|
||||
* 十几步,逐条铺开会把正文挤出可视区,所以界面上把它们折成一行:默认只报最新一步,点击展开
|
||||
* 完整记录(组件见 `canvas-cloud-agent-chat-ui` 的 `AgentOperationFeed`)。
|
||||
*
|
||||
* 这里只做纯切分与文案,不引入 React 或组件类型:调用方把 `CloudAgentChatMessage[]` 传进来,
|
||||
* 结构上满足 `AgentFeedRecord` 即可。
|
||||
*/
|
||||
|
||||
export type AgentFeedRecord = {
|
||||
id: string;
|
||||
role: string;
|
||||
title?: string;
|
||||
text: string;
|
||||
detail?: unknown;
|
||||
planItems?: readonly unknown[];
|
||||
question?: unknown;
|
||||
};
|
||||
|
||||
export type AgentFeedSegment<T> = { kind: "operations"; key: string; items: T[] } | { kind: "message"; key: string; item: T };
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯载体消息:计划清单(`plan_updated`)与提问(`user_question`)各带一条没有正文的消息,
|
||||
* 内容分别由输入区上方的"本轮待办"条与提问条渲染。它们既不是操作记录,也没有可展示的正文:
|
||||
* 实测直接渲染会得到"操作画布 / 操作已完成 / 处理中"这样的空卡片。
|
||||
*/
|
||||
export function isAgentCarrierRecord(item: AgentFeedRecord): boolean {
|
||||
if (item.role === "user" || item.text || item.title) return false;
|
||||
return Boolean(item.planItems?.length) || Boolean(item.question);
|
||||
}
|
||||
|
||||
/** 等用户确认的工具调用(审批卡)必须留在消息流里,不能被折进"操作记录"里藏起来。 */
|
||||
export function isAgentOperationRecord(item: AgentFeedRecord): boolean {
|
||||
return item.role === "tool" && !isAgentCarrierRecord(item) && record(item.detail).status !== "pending";
|
||||
}
|
||||
|
||||
/** 折叠行上报的那一步:最新一条操作记录的友好摘要(等待执行时用"准备…"口径)。 */
|
||||
export function agentOperationLabel(item: AgentFeedRecord): string {
|
||||
const name = agentToolName(item.title || "工具执行", item.detail);
|
||||
const retry = agentToolRetry(item.detail);
|
||||
// 自动纠正分组在卡片里只显示纠正记录,折叠行跟上同一口径,否则会报一个"已完成"的假象。
|
||||
if (retry) return `${agentToolRetryLabel(retry)} · ${retry.attempt}/${retry.maxAttempts} 次尝试`;
|
||||
return friendlyAgentToolSummary(name, item.text, item.detail, agentToolStatus(name, item.text, item.detail) === "pending");
|
||||
}
|
||||
|
||||
/** 失败/被拒的操作默认展开:错误不该被折进一行里看不见。 */
|
||||
export function agentOperationFailed(item: AgentFeedRecord): boolean {
|
||||
const status = agentToolStatus(agentToolName(item.title || "工具执行", item.detail), item.text, item.detail);
|
||||
return status === "failed" || status === "rejected";
|
||||
}
|
||||
|
||||
/** 一条操作记录的类别(折叠行徽标用它):按工具契约判定,不看文案。 */
|
||||
export function agentOperationCategory(item: AgentFeedRecord) {
|
||||
return agentToolCategory(agentToolName(item.title || "工具执行", item.detail), item.detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠段上报的一句聚合文案。单条语义仍归 `agentOperationLabel`(卡片与折叠行共用,不许改),
|
||||
* 这里只回答"这一段到底看没看画面":
|
||||
*
|
||||
* 看图片段报「查看了 N 张画面 · 最新《…》」,N 按**真的送出画面的图片**去重计数(复用账本里的
|
||||
* 观察与读循环护栏都没有附图,不计数);其余段沿用最新一步自己的摘要。
|
||||
*/
|
||||
export function agentOperationSegmentLabel(items: readonly AgentFeedRecord[]): string {
|
||||
const latest = items[items.length - 1];
|
||||
if (!latest) return "";
|
||||
if (agentOperationCategory(latest) !== "vision") return agentOperationLabel(latest);
|
||||
const viewed = new Set<string>();
|
||||
let latestTitle = "";
|
||||
for (const item of items) {
|
||||
if (agentOperationCategory(item) !== "vision") continue;
|
||||
const result = record(record(item.detail).result);
|
||||
if (result.reuseObservation === true || result.repeat === true) continue;
|
||||
if (typeof result.nodeId === "string" && result.nodeId) {
|
||||
viewed.add(result.nodeId);
|
||||
if (typeof result.title === "string" && result.title) latestTitle = result.title;
|
||||
}
|
||||
for (const value of Array.isArray(result.batchImages) ? result.batchImages : []) {
|
||||
const image = record(value);
|
||||
if (typeof image.nodeId !== "string" || !image.nodeId) continue;
|
||||
viewed.add(image.nodeId);
|
||||
if (typeof image.title === "string" && image.title) latestTitle = image.title;
|
||||
}
|
||||
}
|
||||
if (!viewed.size) return agentOperationLabel(latest);
|
||||
return `查看了 ${viewed.size} 张画面${latestTitle ? ` · 最新《${latestTitle}》` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把消息流切成「操作段」与「单条消息」:连续的非审批工具记录合成一段,其余消息各自成段。
|
||||
*
|
||||
* 段 key 取段内第一条消息的 id:运行中后续步骤是**追加**进同一段的,key 保持稳定,
|
||||
* 展开状态与滚动位置不会因为新步骤到达而被重置。
|
||||
*/
|
||||
export function buildAgentFeedSegments<T extends AgentFeedRecord>(messages: readonly T[]): AgentFeedSegment<T>[] {
|
||||
const segments: AgentFeedSegment<T>[] = [];
|
||||
for (const item of messages) {
|
||||
if (isAgentCarrierRecord(item)) continue;
|
||||
if (isAgentOperationRecord(item)) {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last?.kind === "operations") last.items.push(item);
|
||||
else segments.push({ kind: "operations", key: item.id, items: [item] });
|
||||
continue;
|
||||
}
|
||||
segments.push({ kind: "message", key: item.id, item });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@@ -1,15 +1,86 @@
|
||||
import { agentToolRetry } from "./agent-tool-retry";
|
||||
import { agentToolRetry, type AgentToolRetry } from "./agent-tool-retry";
|
||||
|
||||
export const AGENT_TOOL_METADATA: Record<string, { summary: string | ((context: { pending: boolean; detail?: unknown }) => string); failureMessage: string }> = {
|
||||
/**
|
||||
* 前端工具清单,口径与后端 `CloudAgentSupportedToolNames()`
|
||||
* (`backend/internal/app/cloud_agent_tools.go`,从 `compileCloudAgentTools` 派生)逐一对齐。
|
||||
*
|
||||
* 作用只有一个:让"哪些工具该有类别与文案"这件事可断言。上游合并后工具表若变了,
|
||||
* 这份清单与下面的类别集合要一起复核 —— 未登记的工具一律走"其他操作"的中性兜底,
|
||||
* 绝不能落进"修改画布"(那会把一次读取读成写操作)。
|
||||
*/
|
||||
export const AGENT_TOOL_NAMES = [
|
||||
"agent_profile_read",
|
||||
"ask_user",
|
||||
"canvas_apply_ops",
|
||||
"canvas_arrange_nodes",
|
||||
"canvas_create_storyboard",
|
||||
"canvas_edit_batch_table",
|
||||
"canvas_edit_storyboard",
|
||||
"canvas_get_state",
|
||||
"canvas_inspect_image",
|
||||
"canvas_list_node_types",
|
||||
"canvas_read_batch_table",
|
||||
"canvas_read_storyboard",
|
||||
"finish_run",
|
||||
"generate_media",
|
||||
"image_annotation_render",
|
||||
"image_layer_split",
|
||||
"image_text_detect",
|
||||
"model_list",
|
||||
"plan_update",
|
||||
"recall_lessons",
|
||||
"remember_lesson",
|
||||
"skill_read_file",
|
||||
"skill_search",
|
||||
"task_get",
|
||||
] as const;
|
||||
|
||||
export type AgentToolSummaryContext = { pending: boolean; detail?: unknown; text: string };
|
||||
|
||||
export type AgentToolMetadataEntry = {
|
||||
summary: string | ((context: AgentToolSummaryContext) => string);
|
||||
/** 失败文案也支持函数形态:识图要按回执/错误细分(额度、读循环、批量上限、能力缺失)。 */
|
||||
failureMessage: string | ((context: AgentToolSummaryContext) => string);
|
||||
};
|
||||
|
||||
export const AGENT_TOOL_METADATA: Record<string, AgentToolMetadataEntry> = {
|
||||
canvas_list_node_types: { summary: "已读取可用节点类型", failureMessage: "获取可用节点类型失败" },
|
||||
canvas_get_state: { summary: "已读取当前画布", failureMessage: "获取画布内容失败" },
|
||||
// 清单 ≠ 画面:这个工具只读到节点存在/类型/标题/规模。说成"已读取当前画布"会被读成看过图。
|
||||
canvas_get_state: { summary: "已读取画布清单(未查看画面)", failureMessage: "获取画布清单失败" },
|
||||
task_get: { summary: "已查询任务状态", failureMessage: "查询任务状态失败" },
|
||||
canvas_apply_ops: { summary: ({ pending }) => (pending ? "准备更新画布内容" : "画布内容已保存至服务端"), failureMessage: "更新画布内容失败" },
|
||||
model_list: { summary: "已获取可用模型", failureMessage: "获取可用模型失败" },
|
||||
generate_media: { summary: ({ pending, detail }) => (pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交"), failureMessage: "媒体生成未完成" },
|
||||
// 看图:只有这个工具会把图片字节真的交给模型,所以只有它可以说"画面/看图"。
|
||||
// 回执字段来源:`backend/internal/app/cloud_agent_runtime.go`(payload["result"] = 回执)。
|
||||
canvas_inspect_image: {
|
||||
summary: ({ pending, detail }) => {
|
||||
const result = record(field(detail, "result"));
|
||||
const title = typeof result.title === "string" && result.title ? `《${result.title}》` : "该图片";
|
||||
if (result.reuseObservation === true) return "复用已记录的观察,未重复看图";
|
||||
const batch = Array.isArray(result.batchImages) ? result.batchImages : [];
|
||||
if (result.repeat === true && batch.length === 0) return "本轮重复查看,只回执文字、未附图";
|
||||
if (batch.length > 1) return `已附上 ${batch.length} 张画面`;
|
||||
return pending ? `准备查看画面${title}` : `已附上${title}的画面`;
|
||||
},
|
||||
failureMessage: ({ text, detail }) => agentVisionFailureSummary(text, detail),
|
||||
},
|
||||
// 收尾闸门(工作项 A):被拦下时卡片要说清"这次没结束",而不是千篇一律的"操作已完成"。
|
||||
finish_run: {
|
||||
summary: ({ pending, detail }) => (pending ? "准备收尾并给出最终答复" : field(field(detail, "result"), "completionBlocked") === true ? "收尾被拦下,继续处理未完成项" : "已收尾并给出最终答复"),
|
||||
failureMessage: "收尾未完成",
|
||||
},
|
||||
};
|
||||
|
||||
export type AgentToolCategory = "read" | "create" | "operate";
|
||||
/** 画布上"读到清单"的只读工具:无像素,chip 必须是存在式。 */
|
||||
const AGENT_CANVAS_READ_TOOLS = new Set(["canvas_get_state", "canvas_list_node_types", "canvas_read_storyboard", "canvas_read_batch_table"]);
|
||||
/** 非画布信息的只读工具(模型、任务、技能、偏好)。`skills_load` 是历史事件名,保留兼容。 */
|
||||
const AGENT_INFO_READ_TOOLS = new Set(["model_list", "task_get", "skill_search", "skill_read_file", "skills_load", "agent_profile_read"]);
|
||||
const AGENT_VISION_TOOLS = new Set(["canvas_inspect_image"]);
|
||||
const AGENT_CREATE_TOOLS = new Set(["generate_media", "canvas_create_storyboard", "image_annotation_render"]);
|
||||
const AGENT_OPERATE_TOOLS = new Set(["canvas_apply_ops", "canvas_arrange_nodes", "canvas_edit_storyboard", "canvas_edit_batch_table", "image_layer_split", "image_text_detect"]);
|
||||
|
||||
export type AgentToolCategory = "read" | "vision" | "create" | "operate" | "other";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
@@ -30,23 +101,30 @@ function toolArguments(detail?: unknown) {
|
||||
* 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.
|
||||
*
|
||||
* 未登记的工具落 `other`("其他操作"):兜底不能让任何工具看起来像在写画布。
|
||||
*/
|
||||
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 (AGENT_VISION_TOOLS.has(toolName)) return "vision";
|
||||
if (AGENT_CANVAS_READ_TOOLS.has(toolName) || AGENT_INFO_READ_TOOLS.has(toolName)) return "read";
|
||||
if (AGENT_CREATE_TOOLS.has(toolName)) 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";
|
||||
}
|
||||
return "operate";
|
||||
if (AGENT_OPERATE_TOOLS.has(toolName)) return "operate";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function agentToolCategoryLabel(toolName: string, category: AgentToolCategory): string {
|
||||
if (category === "read") return toolName === "canvas_get_state" ? "读取节点" : "读取信息";
|
||||
if (category === "vision") return "查看画面";
|
||||
if (category === "read") return AGENT_CANVAS_READ_TOOLS.has(toolName) ? "读取清单" : "读取信息";
|
||||
if (category === "create") return "创建节点";
|
||||
return "操作画布";
|
||||
if (category === "operate") return "修改画布";
|
||||
return "其他操作";
|
||||
}
|
||||
|
||||
type ToolStatus = "completed" | "failed" | "noop" | "rejected" | "pending" | "retrying";
|
||||
@@ -88,9 +166,53 @@ export function friendlyAgentToolSummary(toolName: string, text: string, detail?
|
||||
return `${pending ? "准备读取参考资料" : "已读取参考资料"} · ${target}`;
|
||||
}
|
||||
const metadata = AGENT_TOOL_METADATA[toolName];
|
||||
const summary = typeof metadata?.summary === "function" ? metadata.summary({ pending, detail }) : metadata?.summary;
|
||||
const failure = metadata?.failureMessage;
|
||||
return failed ? failure || "操作未完成" : summary || (pending ? "准备执行操作" : "操作已完成");
|
||||
const context: AgentToolSummaryContext = { pending, detail, text };
|
||||
const summary = typeof metadata?.summary === "function" ? metadata.summary(context) : metadata?.summary;
|
||||
const failure = typeof metadata?.failureMessage === "function" ? metadata.failureMessage(context) : metadata?.failureMessage;
|
||||
if (failed) return failure || "操作未完成";
|
||||
if (summary) return summary;
|
||||
// 未登记工具的中性兜底:点名工具,别让任何工具都读成"操作画布/已完成"(不变量 ③)。
|
||||
const name = toolName || "工具";
|
||||
return pending ? `准备执行 ${name}` : `已完成:${name}`;
|
||||
}
|
||||
|
||||
const VISION_QUOTA_PATTERN = /已用\s*(\d+)\s*次、本轮额度\s*(\d+)\s*次/u;
|
||||
const VISION_BATCH_PATTERN = /本批已附\s*(\d+)\s*张、单次上限\s*(\d+)\s*张/u;
|
||||
|
||||
/**
|
||||
* 识图失败的细分文案。
|
||||
*
|
||||
* 这四条只给文案、不给结构化字段:额度那条在 `prepareCloudAgentImageInspection` 里带
|
||||
* "已用 X 次、本轮额度 Y 次",批量上限那条带"本批已附 X 张、单次上限 Y 张",读循环走
|
||||
* `cloudAgentReadLoopError`,能力缺失是 `BadAuthRequest("当前模型未声明图片输入能力")`。
|
||||
* 所以这里按文案特征分类,拿到数字就把数字带上(不给数字时模型只能靠试探)。
|
||||
*/
|
||||
export function agentVisionFailureSummary(text: string, detail?: unknown): string {
|
||||
const payload = record(detail);
|
||||
const nested = record(payload.result);
|
||||
const raw = [text, payload.error, nested.error, payload.message, nested.note].filter((value): value is string => typeof value === "string").join(" ");
|
||||
const quota = VISION_QUOTA_PATTERN.exec(raw);
|
||||
if (quota) return `本轮识图额度已用完(已用 ${quota[1]} / 额度 ${quota[2]})`;
|
||||
const batch = VISION_BATCH_PATTERN.exec(raw);
|
||||
if (batch) return `本批图片数已达模型上限(已附 ${batch[1]} / 上限 ${batch[2]}),下一步继续`;
|
||||
if (/重复读取|护栏/.test(raw)) return "同一张图同版本重复读取,已拦下";
|
||||
if (/未声明图片输入能力|支持图片输入的渠道模型/.test(raw)) return "当前渠道模型未声明图片输入能力";
|
||||
// 兜底也认后端的哨兵文案("cloud agent image inspection budget exhausted"):包一层时才有数字,
|
||||
// 单独出现时只给到"额度已用完",不能让用户看到一句英文原样。
|
||||
if (/(识图|看图|image inspection budget)/i.test(raw) && /(额度|预算|budget)/i.test(raw)) return "本轮识图额度已用完";
|
||||
return "查看画面未完成";
|
||||
}
|
||||
|
||||
/** 工具事件的调用名:detail.toolName/name/tool 优先,退化到消息标题。 */
|
||||
export function agentToolName(title: string, detail?: unknown): string {
|
||||
return String(field(detail, "toolName") || field(detail, "name") || field(detail, "tool") || title);
|
||||
}
|
||||
|
||||
/** 自动纠正分组的固定文案(卡片与折叠行共用,避免两处各写一套)。 */
|
||||
export function agentToolRetryLabel(retry: AgentToolRetry): string {
|
||||
if (retry.status === "recovered") return "自动纠正后已恢复";
|
||||
if (retry.status === "exhausted") return "自动纠正未完成";
|
||||
return "自动纠正记录";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user