@@ -187,8 +302,8 @@ function SettingRow({ theme, icon, title, summary, onClick }: { theme: CanvasThe
function TabButton({ active, label, onClick, theme }: { active: boolean; label: string; onClick: () => void; theme: CanvasTheme }) { return
; }
function SettingLabel({ label, hint }: { label: string; hint?: string }) { return
{label}{hint ? {hint} : null}
; }
function BudgetInput({ label, hint, value, onChange, theme }: { label: string; hint: string; value: string; onChange: (value: string) => void; theme: CanvasTheme }) { return
; }
-function sectionTitle(section: SettingsSection) { return section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
-function sectionSubtitle(section: SettingsSection) { return section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
+function sectionTitle(section: SettingsSection) { return section === "profile" ? "长期偏好" : section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
+function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
export function agentPermissionLabel(mode: AgentPermissionMode) { return permissionOptions.find((option) => option.value === mode)?.label || "请求审批"; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f` }; }
diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css
index 4814c9492..ce6d78d21 100644
--- a/web/src/components/canvas/canvas-cloud-agent.css
+++ b/web/src/components/canvas/canvas-cloud-agent.css
@@ -131,6 +131,18 @@
outline: none;
}
+.agent-composer-toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 8px;
+}
+
+.agent-composer-submit {
+ min-width: 0;
+ justify-self: end;
+}
+
.agent-composer-send-hint {
color: color-mix(in srgb, currentColor 52%, transparent);
font-size: var(--fs-tiny);
@@ -138,7 +150,8 @@
white-space: nowrap;
}
-@container (max-width: 420px) {
+/* 面板宽度包含内边距;默认 448px 面板的输入区已不足以同时容纳控制区和快捷键提示。 */
+@container (max-width: 520px) {
.agent-composer-send-hint {
display: none;
}
@@ -243,24 +256,137 @@
}
.canvas-agent-approval-items {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 10px;
+ display: grid;
+ gap: 8px;
+ max-height: min(360px, 42vh);
+ margin-top: 12px;
+ overflow-y: auto;
+ padding-right: 2px;
}
-.canvas-agent-approval-items span {
- max-width: 100%;
- overflow: hidden;
- border-radius: 6px;
- background: color-mix(in srgb, var(--foreground) 7%, transparent);
- padding: 4px 7px;
- font-size: 11px;
+.canvas-agent-approval-item {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--foreground) 3%, transparent);
+ padding: 9px 10px;
+}
+
+.canvas-agent-approval-item-main,
+.canvas-agent-approval-node-summary,
+.canvas-agent-approval-connection {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+}
+
+.canvas-agent-approval-operation {
+ flex: 0 0 auto;
+ border-radius: 5px;
+ padding: 2px 6px;
+ font-size: 10px;
+ font-weight: 700;
line-height: 16px;
+}
+
+.canvas-agent-approval-operation-add_node { background: #dcfce7; color: #166534; }
+.canvas-agent-approval-operation-update_node { background: #dbeafe; color: #1d4ed8; }
+.canvas-agent-approval-operation-connect_nodes { background: #fef3c7; color: #92400e; }
+.canvas-agent-approval-operation-generate_media { background: #f3e8ff; color: #7e22ce; }
+
+.canvas-agent-approval-node,
+.canvas-agent-approval-node-button {
+ display: inline-flex;
+ min-width: 0;
+ align-items: center;
+ gap: 6px;
+ border-radius: 6px;
+ color: var(--foreground);
+ font-size: 12px;
+ line-height: 18px;
+ text-align: left;
+}
+
+.canvas-agent-approval-node-button {
+ cursor: pointer;
+ padding: 2px 5px;
+}
+
+.canvas-agent-approval-node-button:hover {
+ background: color-mix(in srgb, var(--agent-approval-blue) 10%, transparent);
+ color: var(--agent-approval-blue);
+}
+
+.canvas-agent-approval-node-title,
+.canvas-agent-approval-result-title {
+ min-width: 0;
+ overflow: hidden;
+ font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
+.canvas-agent-approval-node-type {
+ flex: 0 0 auto;
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--foreground) 8%, transparent);
+ padding: 1px 5px;
+ color: color-mix(in srgb, var(--foreground) 58%, transparent);
+ font-size: 10px;
+ line-height: 15px;
+}
+
+.canvas-agent-approval-arrow,
+.canvas-agent-approval-change-arrow {
+ flex: 0 0 auto;
+ color: color-mix(in srgb, var(--foreground) 48%, transparent);
+ font-size: 12px;
+}
+
+.canvas-agent-approval-result-title {
+ color: var(--agent-approval-blue);
+ font-size: 12px;
+}
+
+.canvas-agent-approval-field-list,
+.canvas-agent-approval-detail-list,
+.canvas-agent-approval-summary,
+.canvas-agent-approval-empty {
+ color: color-mix(in srgb, var(--foreground) 62%, transparent);
+ font-size: 11px;
+ line-height: 17px;
+}
+
+.canvas-agent-approval-field-list,
+.canvas-agent-approval-detail-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.canvas-agent-approval-field-list span,
+.canvas-agent-approval-detail-list span {
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--foreground) 7%, transparent);
+ padding: 2px 5px;
+}
+
+.canvas-agent-approval-summary {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.canvas-agent-approval-empty {
+ margin-top: 12px;
+ border-radius: 8px;
+ background: color-mix(in srgb, #f59e0b 10%, transparent);
+ padding: 8px 10px;
+}
+
.canvas-agent-approval-reason-toggle {
display: inline-flex;
margin-top: 10px;
@@ -356,82 +482,486 @@
gap: 8px;
}
-.canvas-agent-skill-popover .ant-popover-inner {
- padding: 0;
+.canvas-agent-skill-library-modal .ant-modal-container {
overflow: hidden;
border: 1px solid var(--border);
- border-radius: 18px;
+ border-radius: var(--r-2xl);
+ padding: 0;
+ background: transparent;
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18), var(--elevation-hairline) !important;
}
-.canvas-agent-skill-picker {
+.canvas-agent-skill-library-modal .ant-modal-body {
+ padding: 0;
+}
+
+.canvas-agent-skill-library-modal .ant-modal-close {
+ top: 16px;
+ right: 16px;
+ width: 34px;
+ height: 34px;
+ border-radius: var(--r-md);
+ color: var(--foreground);
+ background: color-mix(in srgb, var(--foreground) 7%, transparent);
+}
+
+.canvas-agent-skill-library-modal .ant-modal-close:hover {
+ background: color-mix(in srgb, var(--foreground) 12%, transparent);
+}
+
+.canvas-agent-skill-library-shell {
+ position: relative;
+ display: grid;
+ height: min(780px, calc(100dvh - 36px));
+ grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ overflow: hidden;
+ border-radius: inherit;
+ background:
+ radial-gradient(circle at 0 0, color-mix(in srgb, var(--workspace-accent) 7%, transparent), transparent 34%),
+ var(--cn-spatial-elevated, var(--background));
+}
+
+.canvas-agent-skill-library-header {
display: flex;
- width: min(520px, calc(100vw - 24px));
- max-height: calc(100vh - 24px);
- flex-direction: column;
- padding: 12px;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 18px 64px 14px 20px;
}
-.canvas-agent-skill-picker-tabs {
- height: 48px;
- margin-top: 10px;
+.canvas-agent-skill-library-header p {
+ margin-bottom: 0;
}
-.canvas-agent-skill-picker-tabs button {
- transition: background-color 140ms ease, color 140ms ease;
+.canvas-agent-skill-library-count {
+ flex: 0 0 auto;
+ border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
+ border-radius: var(--r-lg);
+ background: color-mix(in srgb, var(--foreground) 4%, transparent);
+ padding: 7px 10px;
+ font-size: var(--fs-label);
+ font-variant-numeric: tabular-nums;
}
-.canvas-agent-skill-picker-search {
- height: 44px;
- margin-top: 10px;
+.canvas-agent-skill-library-toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
+ align-items: center;
+ gap: 14px;
+ padding: 0 20px 12px;
}
-.canvas-agent-skill-picker-list {
+.canvas-agent-skill-library-tabs {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 4px;
+}
+
+.canvas-agent-skill-library-tab {
+ display: inline-flex;
+ min-height: 38px;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 7px;
+ border-radius: var(--r-md);
+ padding: 0 12px;
+ color: color-mix(in srgb, var(--foreground) 56%, transparent);
+ font-size: var(--fs-caption);
+ font-weight: 560;
+ transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
+}
+
+.canvas-agent-skill-library-tab span {
+ min-width: 20px;
+ border-radius: 999px;
+ background: color-mix(in srgb, currentColor 9%, transparent);
+ padding: 1px 6px;
+ font-size: var(--fs-tiny);
+ line-height: 18px;
+ text-align: center;
+}
+
+.canvas-agent-skill-library-tab:hover {
+ background: color-mix(in srgb, var(--foreground) 4%, transparent);
+ color: color-mix(in srgb, var(--foreground) 76%, transparent);
+}
+
+.canvas-agent-skill-library-tab.is-active {
+ background: color-mix(in srgb, var(--foreground) 5%, transparent);
+ color: color-mix(in srgb, var(--foreground) 78%, transparent);
+}
+
+.canvas-agent-skill-library-search {
+ height: 40px;
+ border-radius: var(--r-lg);
+}
+
+.canvas-agent-skill-library-categories {
+ display: flex;
+ min-width: 0;
+ gap: 8px;
+ overflow-x: auto;
+ border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
+ border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
+ padding: 10px 20px;
+ overscroll-behavior-x: contain;
+ scrollbar-width: none;
+}
+
+.canvas-agent-skill-library-categories::-webkit-scrollbar {
+ display: none;
+}
+
+.canvas-agent-skill-category {
+ min-height: 34px;
+ flex: 0 0 auto;
+ border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
+ border-radius: var(--r-md);
+ padding: 0 13px;
+ color: color-mix(in srgb, var(--foreground) 58%, transparent);
+ font-size: var(--fs-caption);
+ transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
+}
+
+.canvas-agent-skill-category:hover {
+ border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
+ color: var(--foreground);
+}
+
+.canvas-agent-skill-category.is-active {
+ border-color: color-mix(in srgb, var(--workspace-accent) 34%, var(--border));
+ background: color-mix(in srgb, var(--workspace-accent) 11%, transparent);
+ color: var(--workspace-accent);
+}
+
+.canvas-agent-skill-library-list {
+ display: grid;
min-height: 0;
- max-height: min(360px, 44vh);
- margin-top: 10px;
- padding-right: 5px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-auto-rows: max-content;
+ align-content: start;
+ gap: 12px;
+ overflow-y: auto;
+ padding: 16px 20px;
+ overscroll-behavior: contain;
}
-.canvas-agent-skill-picker-row {
- padding: 8px 10px;
- transition: background-color 140ms ease, border-color 140ms ease;
+.canvas-agent-skill-library-state {
+ display: grid;
+ min-height: 260px;
+ grid-column: 1 / -1;
+ place-items: center;
+ align-content: center;
+ gap: 10px;
+ padding: 28px;
+ font-size: var(--fs-caption);
+ text-align: center;
}
-.canvas-agent-skill-picker-row:hover {
- background: var(--library-surface-hover);
+.canvas-agent-skill-card {
+ display: grid;
+ min-width: 0;
+ min-height: 136px;
+ grid-template-columns: 164px minmax(0, 1fr) auto;
+ gap: 14px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: var(--r-xl);
+ background: color-mix(in srgb, var(--foreground) 3%, transparent);
+ padding: 11px;
+ transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out);
}
-.canvas-agent-skill-picker-row.is-selected {
+.canvas-agent-skill-card:hover {
+ border-color: color-mix(in srgb, var(--foreground) 22%, transparent) !important;
+ background: color-mix(in srgb, var(--foreground) 5%, transparent);
+ transform: translateY(-1px);
+}
+
+.canvas-agent-skill-card.is-selected {
background: color-mix(in srgb, var(--workspace-accent) 7%, transparent);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 24%, transparent);
}
-.canvas-agent-skill-picker-check {
- width: 22px;
- height: 22px;
+.canvas-agent-skill-card-cover {
+ position: relative;
+ min-width: 0;
+ overflow: hidden;
+ border-radius: var(--r-lg);
+ aspect-ratio: 16 / 10;
}
-.canvas-agent-skill-picker-footer {
- min-height: 36px;
- margin-top: 8px;
- padding-top: 8px;
+.canvas-agent-skill-card-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform var(--motion-dur-base) var(--motion-ease-out);
}
-.canvas-agent-skill-picker-expand {
- border-radius: 5px;
- padding: 2px 4px;
+.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
+ transform: scale(1.025);
}
-.canvas-agent-skill-picker-expand:hover {
- background: color-mix(in srgb, var(--workspace-accent) 10%, transparent);
+.canvas-agent-skill-card-placeholder {
+ display: grid;
+ width: 100%;
+ height: 100%;
+ place-items: center;
+ color: color-mix(in srgb, var(--workspace-accent) 64%, var(--foreground));
+ background:
+ radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--workspace-accent) 28%, transparent), transparent 46%),
+ linear-gradient(145deg, color-mix(in srgb, var(--foreground) 8%, transparent), color-mix(in srgb, var(--foreground) 2%, transparent));
+}
+
+.canvas-agent-skill-card-category {
+ position: absolute;
+ top: 7px;
+ right: 7px;
+ max-width: calc(100% - 14px);
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, white 18%, transparent);
+ border-radius: var(--r-md);
+ background: color-mix(in srgb, black 62%, transparent);
+ padding: 2px 7px;
+ color: white;
+ font-size: var(--fs-tiny);
+ line-height: 19px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ backdrop-filter: blur(8px);
+}
+
+.canvas-agent-skill-card-body {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ padding: 4px 0 2px;
+}
+
+.canvas-agent-skill-card-title-row {
+ display: flex;
+ min-width: 0;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.canvas-agent-skill-card-title-row h3 {
+ min-width: 0;
+ overflow: hidden;
+ margin: 0;
+ color: var(--foreground);
+ font-size: 15px;
+ font-weight: 650;
+ line-height: 22px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.canvas-agent-skill-card-version {
+ flex: 0 0 auto;
+ color: color-mix(in srgb, var(--foreground) 42%, transparent);
+ font-size: var(--fs-tiny);
+}
+
+.canvas-agent-skill-card-body > p {
+ display: -webkit-box;
+ min-width: 0;
+ overflow: hidden;
+ margin: 7px 0 10px;
+ color: color-mix(in srgb, var(--foreground) 54%, transparent);
+ font-size: var(--fs-label);
+ line-height: 19px;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.canvas-agent-skill-card-meta {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 6px;
+ margin-top: auto;
+ font-size: var(--fs-label);
+}
+
+.canvas-agent-skill-card-action {
+ display: flex;
+ align-items: flex-start;
+ padding-top: 3px;
+}
+
+.canvas-agent-skill-card-action .ant-btn {
+ min-width: 76px;
+ border-radius: var(--r-md);
+}
+
+.canvas-agent-skill-card-action .ant-btn.is-selected {
+ border-color: color-mix(in srgb, var(--workspace-accent) 32%, var(--border));
+ background: color-mix(in srgb, var(--workspace-accent) 13%, transparent);
+ color: var(--workspace-accent);
+ box-shadow: none;
+}
+
+.canvas-agent-skill-library-footer {
+ display: flex;
+ min-height: 48px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ border-top: 1px solid;
+ padding: 8px 20px;
+ font-size: var(--fs-label);
+}
+
+.canvas-agent-skill-library-tab:focus-visible,
+.canvas-agent-skill-category:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+@media (max-width: 920px) {
+ .canvas-agent-skill-library-list {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 680px) {
+ .canvas-agent-skill-library-shell {
+ height: calc(100dvh - 24px);
+ }
+
+ .canvas-agent-skill-library-header {
+ align-items: flex-start;
+ gap: 10px;
+ padding: 16px 56px 12px 14px;
+ }
+
+ .canvas-agent-skill-library-header p {
+ display: none;
+ }
+
+ .canvas-agent-skill-library-count {
+ padding: 5px 8px;
+ }
+
+ .canvas-agent-skill-library-toolbar {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 8px;
+ padding: 0 14px 10px;
+ }
+
+ .canvas-agent-skill-library-tabs {
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+
+ .canvas-agent-skill-library-tabs::-webkit-scrollbar {
+ display: none;
+ }
+
+ .canvas-agent-skill-library-categories {
+ padding: 8px 14px;
+ }
+
+ .canvas-agent-skill-library-list {
+ gap: 10px;
+ padding: 12px 14px;
+ }
+
+ .canvas-agent-skill-library-footer {
+ min-height: 44px;
+ padding: 6px 14px;
+ }
+}
+
+@media (max-width: 560px) {
+ .canvas-agent-skill-library-header {
+ flex-wrap: wrap;
+ }
+
+ .canvas-agent-skill-library-count {
+ position: absolute;
+ top: 17px;
+ right: 56px;
+ }
+
+ .canvas-agent-skill-library-tab {
+ min-height: 34px;
+ padding: 0 9px;
+ font-size: var(--fs-label);
+ }
+
+ .canvas-agent-skill-card {
+ min-height: 122px;
+ grid-template-columns: 104px minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 8px 10px;
+ padding: 9px;
+ }
+
+ .canvas-agent-skill-card-cover {
+ grid-row: 1 / 3;
+ align-self: stretch;
+ aspect-ratio: auto;
+ }
+
+ .canvas-agent-skill-card-body {
+ padding: 1px 0 0;
+ }
+
+ .canvas-agent-skill-card-body > p {
+ margin: 4px 0 6px;
+ font-size: var(--fs-tiny);
+ line-height: 17px;
+ }
+
+ .canvas-agent-skill-card-meta {
+ font-size: var(--fs-tiny);
+ }
+
+ .canvas-agent-skill-card-action {
+ justify-content: flex-end;
+ padding: 0;
+ }
+
+ .canvas-agent-skill-card-action .ant-btn {
+ min-width: 72px;
+ }
+
+ .canvas-agent-skill-library-footer > span:last-child {
+ display: none;
+ }
}
@media (max-height: 560px) {
- .canvas-agent-skill-picker {
- max-height: calc(100vh - 16px);
+ .canvas-agent-skill-library-shell {
+ height: calc(100dvh - 16px);
}
- .canvas-agent-skill-picker-list {
- max-height: min(280px, 38vh);
+ .canvas-agent-skill-library-header {
+ padding-top: 12px;
+ padding-bottom: 8px;
+ }
+
+ .canvas-agent-skill-library-categories {
+ padding-top: 7px;
+ padding-bottom: 7px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .canvas-agent-skill-card,
+ .canvas-agent-skill-card-cover img,
+ .canvas-agent-skill-category,
+ .canvas-agent-skill-library-tab {
+ transition: none;
+ }
+
+ .canvas-agent-skill-card:hover,
+ .canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
+ transform: none;
}
}
diff --git a/web/src/lib/canvas/agent-approval-presentation.ts b/web/src/lib/canvas/agent-approval-presentation.ts
index 0da43dc42..5e6047d96 100644
--- a/web/src/lib/canvas/agent-approval-presentation.ts
+++ b/web/src/lib/canvas/agent-approval-presentation.ts
@@ -1,11 +1,17 @@
+import type { AgentApprovalPreview, AgentApprovalPreviewItem, AgentApprovalPreviewOperation } from "@/services/api/agent";
+
type RecordValue = Record
;
+export type AgentApprovalPresentation = AgentApprovalPreview & { source: "server" | "fallback" };
+
function record(value: unknown): RecordValue | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : null;
}
-function text(value: unknown): string {
- return typeof value === "string" ? value.trim() : "";
+function text(value: unknown, max = 240): string {
+ if (typeof value !== "string") return "";
+ const compact = value.replace(/[\u0000-\u001f\u007f]/gu, " ").trim();
+ return compact.length > max ? `${compact.slice(0, max)}…` : compact;
}
function approvalArguments(value: unknown): RecordValue | null {
@@ -17,35 +23,108 @@ function approvalArguments(value: unknown): RecordValue | null {
}
}
-export function agentApprovalPresentation(detail: unknown) {
+function shortReference(value: unknown): string {
+ const id = text(value, 80);
+ if (!id) return "未知目标";
+ return id.length <= 16 ? id : `${id.slice(0, 8)}…${id.slice(-4)}`;
+}
+
+function nodeTypeLabel(value: unknown): string {
+ const type = text(value, 32).toLowerCase();
+ return ({ text: "文本", markdown: "Markdown", image: "图片", video: "视频", audio: "音频", frame: "画框", script: "脚本" } as Record)[type] || (type ? "节点" : "");
+}
+
+function fieldLabel(key: string, nodeType: string): string {
+ if (key === "title") return "节点名称";
+ if (key === "content") return ["image", "video", "audio"].includes(nodeType.toLowerCase()) ? "下一版提示词" : "正文";
+ return key === "composerContent" ? "下一版提示词" : "已配置字段";
+}
+
+function operation(value: unknown): AgentApprovalPreviewOperation | null {
+ return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" ? value : null;
+}
+
+function normalizeServerPreview(value: unknown): AgentApprovalPresentation | null {
+ const source = record(value);
+ if (!source || !Array.isArray(source.items)) return null;
+ const rawItems = source.items;
+ const items: AgentApprovalPreviewItem[] = [];
+ for (const raw of rawItems) {
+ const item = record(raw);
+ const op = operation(item?.operation);
+ const summary = text(item?.summary);
+ if (!item || !op || !summary) continue;
+ items.push({
+ operation: op,
+ nodeId: text(item.nodeId, 120) || undefined,
+ nodeTitle: text(item.nodeTitle, 120) || undefined,
+ resultTitle: text(item.resultTitle, 120) || undefined,
+ nodeType: text(item.nodeType, 32) || undefined,
+ nodeTypeLabel: text(item.nodeTypeLabel, 48) || undefined,
+ targetNodeId: text(item.targetNodeId, 120) || undefined,
+ targetNodeTitle: text(item.targetNodeTitle, 120) || undefined,
+ targetNodeType: text(item.targetNodeType, 32) || undefined,
+ fields: Array.isArray(item.fields) ? item.fields.map((field) => text(field, 64)).filter(Boolean).slice(0, 12) : undefined,
+ details: Array.isArray(item.details) ? item.details.map((detail) => text(detail, 180)).filter(Boolean).slice(0, 12) : undefined,
+ summary,
+ });
+ }
+ const title = text(source.title, 120);
+ const description = text(source.description, 500);
+ if (!title || !description || !items.length) return null;
+ return { kind: text(source.kind, 64) || "agent_approval", title, description, items, source: "server" };
+}
+
+function canvasFallback(args: RecordValue | null): AgentApprovalPresentation {
+ const rawOps = Array.isArray(args?.ops) ? args.ops : [];
+ const items: AgentApprovalPreviewItem[] = [];
+ for (const raw of rawOps) {
+ const op = record(raw);
+ const kind = operation(op?.type);
+ if (!op || !kind || kind === "generate_media") continue;
+ const type = text(op.nodeType, 32).toLowerCase();
+ const label = nodeTypeLabel(type);
+ if (kind === "add_node") {
+ const title = text(op.title, 120) || `未命名${label || "节点"}`;
+ items.push({ operation: kind, nodeTitle: title, nodeType: type || undefined, nodeTypeLabel: label || undefined, fields: ["节点名称", label === "文本" || label === "Markdown" ? "正文" : "初始内容"], summary: `新增${label || "节点"}《${title}》` });
+ continue;
+ }
+ if (kind === "update_node") {
+ const patch = record(op.patch);
+ const id = shortReference(op.id);
+ const fields = patch ? Object.keys(patch).map((key) => fieldLabel(key, type)).filter(Boolean) : [];
+ const resultTitle = text(patch?.title, 120);
+ const target = `目标节点(引用 ${id})`;
+ items.push({ operation: kind, nodeTitle: target, nodeType: type || undefined, nodeTypeLabel: label || undefined, resultTitle: resultTitle || undefined, fields: fields.length ? [...new Set(fields)] : ["已配置字段"], summary: `修改${label || "节点"}《${target}》的${fields.length ? [...new Set(fields)].join("、") : "已配置字段"}${resultTitle ? `,名称改为《${resultTitle}》` : ""}` });
+ continue;
+ }
+ const from = shortReference(op.fromNodeId);
+ const to = shortReference(op.toNodeId);
+ items.push({ operation: kind, nodeTitle: `来源(引用 ${from})`, targetNodeTitle: `目标(引用 ${to})`, summary: `建立来源(引用 ${from})→目标(引用 ${to})的引用连线` });
+ }
+ const counts = items.reduce>((out, item) => { out[item.operation] = (out[item.operation] || 0) + 1; return out; }, {});
+ const parts = [counts.add_node ? `新增 ${counts.add_node} 个节点` : "", counts.update_node ? `修改 ${counts.update_node} 个节点` : "", counts.connect_nodes ? `建立 ${counts.connect_nodes} 条引用连线` : ""].filter(Boolean);
+ return { source: "fallback", kind: "canvas_mutation", title: "确认画布修改", description: parts.length ? `Agent 准备${parts.join(",")}。历史审批缺少画布快照,以下目标使用安全引用标识;无法确认具体目标时不会代替当前画布。` : "审批参数不完整,无法确认具体修改目标,请重新读取画布后再申请审批。", items, };
+}
+
+function mediaFallback(args: RecordValue | null, payload: RecordValue | null): AgentApprovalPresentation {
+ const mode = text(args?.mode, 32).toLowerCase();
+ const type = mode === "video" ? "视频" : mode === "image" ? "图片" : mode === "audio" ? "音频" : "媒体";
+ const references = Array.isArray(args?.referenceNodeIds) ? args.referenceNodeIds.filter((id) => typeof id === "string") : [];
+ const title = text(args?.title, 120) || `未命名${type}`;
+ const details = [text(payload?.modelName) ? `模型:${text(payload?.modelName)}` : text(args?.channelModelKey) || text(args?.logicalModelId) ? `模型:${text(args?.channelModelKey) || text(args?.logicalModelId)}` : "", references.length ? `引用 ${references.length} 个画布资产,并建立连线` : "不引用画布媒体资产", typeof args?.durationSeconds === "number" && args.durationSeconds > 0 ? `时长:${args.durationSeconds} 秒` : "", text(args?.size) ? `画幅:${text(args?.size, 40)}` : "", text(args?.quality) ? `质量:${text(args?.quality, 40)}` : "", typeof args?.videoGenerateAudio === "boolean" ? `音频:${args.videoGenerateAudio ? "开启" : "关闭"}` : ""].filter(Boolean);
+ return { source: "fallback", kind: "media_generation", title: `确认生成${type}`, description: `${type}草稿节点和引用连线已创建,尚未提交生成。确认规格后批准才会提交收费任务;拒绝则保留草稿,结果自动回写画布。`, items: [{ operation: "generate_media", nodeTitle: title, nodeType: mode || undefined, nodeTypeLabel: type, details, summary: `生成${type}《${title}》` }] };
+}
+
+export function agentApprovalPresentation(detail: unknown): AgentApprovalPresentation {
const payload = record(detail);
+ const serverPreview = normalizeServerPreview(payload?.preview);
+ if (serverPreview) return serverPreview;
const call = record(payload?.call);
const fn = record(call?.function);
const name = text(fn?.name) || text(payload?.toolName);
const args = approvalArguments(fn?.arguments ?? payload?.arguments);
-
- if (name === "canvas_apply_ops") {
- const ops = Array.isArray(args?.ops) ? args.ops.map(record).filter((op): op is RecordValue => Boolean(op)) : [];
- const added = ops.filter((op) => op.type === "add_node").length;
- const updated = ops.filter((op) => op.type === "update_node").length;
- const connected = ops.filter((op) => op.type === "connect_nodes").length;
- const action = [added ? `新增 ${added} 个节点` : "", updated ? `修改 ${updated} 个节点` : "", connected ? `建立 ${connected} 条引用连线` : ""].filter(Boolean).join(",");
- const titles = ops.map((op) => text(op.title)).filter(Boolean).slice(0, 3).map((title) => title.slice(0, 64));
- return { title: "确认画布修改", description: action ? `Agent 准备${action},确认后才会写入画布。` : "Agent 准备修改当前画布,确认后才会写入。", items: titles };
- }
- if (name === "generate_media") {
- const type = args?.mode === "video" ? "视频" : args?.mode === "image" ? "图片" : "媒体";
- const references = Array.isArray(args?.referenceNodeIds) ? args.referenceNodeIds.filter((id) => typeof id === "string") : [];
- const items = [
- text(payload?.modelName) ? `模型:${text(payload?.modelName)}` : (text(args?.channelModelKey) || text(args?.logicalModelId)) ? `模型:${text(args?.channelModelKey) || text(args?.logicalModelId)}` : "",
- text(args?.title) ? `节点:${text(args?.title).slice(0, 64)}` : "",
- references.length ? `引用 ${references.length} 个画布资产,并建立连线` : "不引用画布媒体资产",
- typeof args?.durationSeconds === "number" && args.durationSeconds > 0 ? `时长:${args.durationSeconds} 秒` : "",
- text(args?.size) ? `画幅:${text(args?.size)}` : "",
- text(args?.quality) ? `质量:${text(args?.quality)}` : "",
- typeof args?.videoGenerateAudio === "boolean" ? `音频:${args.videoGenerateAudio ? "开启" : "关闭"}` : "",
- ].filter(Boolean);
- return { title: `确认生成${type}`, description: `${type}草稿节点和引用连线已创建,尚未提交生成。确认模型与画幅后批准才会提交收费任务;拒绝则保留草稿,结果自动回写画布。`, items };
- }
- return { title: "确认执行操作", description: "Agent 请求执行一项操作,确认后才会继续。", items: [] as string[] };
-}
+ 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-canvas-actions.ts b/web/src/lib/canvas/agent-canvas-actions.ts
index f5e47710d..0e1482fc0 100644
--- a/web/src/lib/canvas/agent-canvas-actions.ts
+++ b/web/src/lib/canvas/agent-canvas-actions.ts
@@ -1,6 +1,6 @@
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
-export type AgentCanvasAction = { nodeId: string; title: string; nodeType: string; action: string };
+export type AgentCanvasAction = { nodeId: string; title: string; nodeType: string; action: string; fields?: string[]; resultTitle?: string; targetNodeId?: string; targetTitle?: string; targetNodeType?: string };
export function agentRecord(value: unknown): Record {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
@@ -17,15 +17,21 @@ export function agentCanvasActions(toolName: string, detail: unknown, references
const result = agentRecord(payload.result);
const args = agentToolArguments(detail);
const actions: AgentCanvasAction[] = [];
- const add = (id: unknown, title: unknown, kind: unknown, action: string) => {
+ const add = (id: unknown, title: unknown, kind: unknown, action: string, details: Partial = {}) => {
if (typeof id !== "string" || !id || actions.some((item) => item.nodeId === id && item.action === action)) return;
const reference = references.find((item) => item.nodeId === id);
- actions.push({ nodeId: id, title: typeof title === "string" && title ? title : reference?.title || id, nodeType: typeof kind === "string" ? kind : reference?.kind || "", action });
+ actions.push({ nodeId: id, title: typeof title === "string" && title ? title : reference?.title || id, nodeType: typeof kind === "string" ? kind : reference?.kind || "", action, ...details });
};
if (Array.isArray(payload.actions)) {
for (const value of payload.actions) {
const action = agentRecord(value);
- add(action.nodeId, action.title, action.nodeType, String(action.action || "updated"));
+ add(action.nodeId, action.title, action.nodeType, String(action.action || "updated"), {
+ fields: Array.isArray(action.fields) ? action.fields.filter((field): field is string => typeof field === "string") : undefined,
+ resultTitle: typeof action.resultTitle === "string" ? action.resultTitle : undefined,
+ targetNodeId: typeof action.targetNodeId === "string" ? action.targetNodeId : undefined,
+ targetTitle: typeof action.targetTitle === "string" ? action.targetTitle : undefined,
+ targetNodeType: typeof action.targetNodeType === "string" ? action.targetNodeType : undefined,
+ });
}
return actions;
}
@@ -53,7 +59,13 @@ export function agentCanvasActions(toolName: string, detail: unknown, references
}
export function agentCanvasActionLabel(action: AgentCanvasAction) {
- const kind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", script: "分镜" } as Record)[action.nodeType] || "";
+ const kind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record)[action.nodeType] || "";
+ if (action.action === "referenced" && action.targetTitle) {
+ const targetKind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record)[action.targetNodeType || ""] || "";
+ return `建立引用:${kind}节点《${action.title}》 → ${targetKind}节点《${action.targetTitle}》`;
+ }
const verb = ({ created: "创建了", updated: "更新了", referenced: "引用了", read: "读取了", generating: "已提交生成:", generated: "已生成", failed: "生成未完成:" } as Record)[action.action] || "定位";
- return `${verb}${kind}节点《${action.title}》`;
+ const fieldText = action.fields?.length ? `(修改:${action.fields.join("、")})` : "";
+ const titleText = action.resultTitle ? `,名称改为《${action.resultTitle}》` : "";
+ return `${verb}${kind}节点《${action.title}》${fieldText}${titleText}`;
}
diff --git a/web/src/lib/canvas/agent-error-presentation.ts b/web/src/lib/canvas/agent-error-presentation.ts
index 02688d966..548f1a5e2 100644
--- a/web/src/lib/canvas/agent-error-presentation.ts
+++ b/web/src/lib/canvas/agent-error-presentation.ts
@@ -19,3 +19,11 @@ export function agentErrorPresentation(cause: unknown, fallback = "Agent 执行
// 渠道、鉴权、配额等业务错误保留真实语义,不能按 message 猜测为服务未部署。
return { title: fallback, text: text || fallback };
}
+
+export function agentSubmissionErrorTitle(cause: unknown, accepted: boolean) {
+ if (accepted) return "运行已接收,但本地提交记录清理失败";
+ const status = cause instanceof ApiError ? cause.status : undefined;
+ if (status && [400, 401, 403, 404, 422].includes(status)) return "请求已被服务端拒绝";
+ if (status) return "服务端已返回错误;重试将核对原请求,不重复创建";
+ return "未收到服务端确认;重试将核对原请求,不重复创建";
+}
diff --git a/web/src/lib/canvas/canvas-node-prompt.test.ts b/web/src/lib/canvas/canvas-node-prompt.test.ts
new file mode 100644
index 000000000..eeec54936
--- /dev/null
+++ b/web/src/lib/canvas/canvas-node-prompt.test.ts
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import "./node-registry/definitions/index.ts";
+// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
+import { canvasNodeHasCommittedContent, writeCanvasNodePrompt } from "./canvas-node-prompt.ts";
+// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
+import { CanvasNodeType, type CanvasNodeData } from "../../types/canvas.ts";
+
+function node(type: CanvasNodeData["type"], metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
+ return {
+ id: `${type}-node`,
+ type,
+ title: "测试节点",
+ position: { x: 0, y: 0 },
+ width: 320,
+ height: 180,
+ metadata,
+ };
+}
+
+test("媒体节点已有结果时只更新下一版提示词草稿", () => {
+ for (const type of [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio]) {
+ const current = node(type, {
+ content: `https://example.com/${type}`,
+ prompt: "已提交提示词",
+ composerContent: "旧草稿",
+ status: "success",
+ });
+
+ assert.equal(canvasNodeHasCommittedContent(current), true);
+ assert.deepEqual(writeCanvasNodePrompt(current, "下一版提示词").metadata, {
+ content: `https://example.com/${type}`,
+ prompt: "已提交提示词",
+ composerContent: "下一版提示词",
+ status: "success",
+ });
+ }
+});
+
+test("空媒体草稿同时初始化提交提示词和编辑提示词", () => {
+ const current = node(CanvasNodeType.Video, { status: "idle" });
+
+ assert.equal(canvasNodeHasCommittedContent(current), false);
+ assert.deepEqual(writeCanvasNodePrompt(current, "第一版提示词").metadata, {
+ status: "idle",
+ prompt: "第一版提示词",
+ composerContent: "第一版提示词",
+ });
+});
+
+test("图片仅保留 storageKey 时仍视为已有媒体结果", () => {
+ const current = node(CanvasNodeType.Image, {
+ storageKey: "canvas/image/result.png",
+ prompt: "已提交提示词",
+ });
+
+ assert.equal(canvasNodeHasCommittedContent(current), true);
+ assert.equal(writeCanvasNodePrompt(current, "新草稿").metadata?.prompt, "已提交提示词");
+});
+
+test("文本提示词本身不构成已提交内容", () => {
+ const draft = node(CanvasNodeType.Text, { prompt: "文本草稿" });
+ const committed = node(CanvasNodeType.Text, { content: "已生成文本", prompt: "已提交提示词" });
+
+ assert.equal(canvasNodeHasCommittedContent(draft), false);
+ assert.equal(canvasNodeHasCommittedContent(committed), true);
+ assert.equal(writeCanvasNodePrompt(draft, "新文本草稿").metadata?.prompt, "新文本草稿");
+ assert.equal(writeCanvasNodePrompt(committed, "下一版文本草稿").metadata?.prompt, "已提交提示词");
+});
+
+test("调用方可先清理失败任务元数据,再由统一写入器保存草稿", () => {
+ const current = node(CanvasNodeType.Video, {
+ content: "https://example.com/video.mp4",
+ prompt: "已提交提示词",
+ promptTemplateOperation: "storyboard-video",
+ promptTemplateVariables: { shot: "1" },
+ taskId: "failed-task",
+ });
+ const metadata = { ...current.metadata, taskId: undefined };
+
+ const updated = writeCanvasNodePrompt(current, "修订草稿", {
+ metadata,
+ clearPromptTemplate: true,
+ });
+
+ assert.equal(updated.metadata?.prompt, "已提交提示词");
+ assert.equal(updated.metadata?.composerContent, "修订草稿");
+ assert.equal(updated.metadata?.taskId, undefined);
+ assert.equal(updated.metadata?.promptTemplateOperation, undefined);
+ assert.equal(updated.metadata?.promptTemplateVariables, undefined);
+});
diff --git a/web/src/lib/canvas/canvas-node-prompt.ts b/web/src/lib/canvas/canvas-node-prompt.ts
new file mode 100644
index 000000000..454c9597e
--- /dev/null
+++ b/web/src/lib/canvas/canvas-node-prompt.ts
@@ -0,0 +1,37 @@
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
+import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
+
+export type WriteCanvasNodePromptOptions = {
+ metadata?: CanvasNodeMetadata;
+ clearPromptTemplate?: boolean;
+};
+
+/**
+ * A generated or authored result is committed once the node registry exposes it
+ * as a resource. Text-like resources additionally require persisted content so
+ * a draft prompt alone is not mistaken for a submitted result.
+ */
+export function canvasNodeHasCommittedContent(node: CanvasNodeData) {
+ const resourceKind = getNodeResourceKind(node);
+ if (resourceKind === "text") return Boolean(node.metadata?.content?.trim());
+ return resourceKind !== null;
+}
+
+/**
+ * Update the editable prompt draft without replacing the prompt snapshot that
+ * produced an existing result. Callers may provide sanitized task metadata,
+ * but prompt ownership remains centralized here.
+ */
+export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string, options: WriteCanvasNodePromptOptions = {}) {
+ const metadata = options.metadata ?? node.metadata;
+ const clearPromptTemplate = options.clearPromptTemplate ?? Boolean(metadata?.promptTemplateOperation);
+ const promptTemplateMetadata = clearPromptTemplate
+ ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined }
+ : {};
+ return {
+ ...node,
+ metadata: canvasNodeHasCommittedContent(node)
+ ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt }
+ : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt },
+ };
+}
diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts
index 0d6cf21a7..fd6f0a53b 100644
--- a/web/src/lib/canvas/canvas-resource-references.ts
+++ b/web/src/lib/canvas/canvas-resource-references.ts
@@ -1,5 +1,6 @@
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
+import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { Skill } from "@/services/api/skills";
@@ -215,19 +216,6 @@ function canvasReferenceIdentityChanged(previousReferences: CanvasResourceRefere
return previousReferences.some((reference) => nextLabelByNodeId.get(reference.nodeId) !== reference.label);
}
-export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string) {
- const hasExistingContent = (node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim())) || (node.type === CanvasNodeType.Image && Boolean(node.metadata?.content));
- const promptTemplateMetadata = node.metadata?.promptTemplateOperation
- ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined }
- : {};
- return {
- ...node,
- metadata: hasExistingContent
- ? { ...node.metadata, ...promptTemplateMetadata, composerContent: prompt }
- : { ...node.metadata, ...promptTemplateMetadata, prompt, composerContent: prompt },
- };
-}
-
function removeCanvasMentionToken(value: string, token: string) {
return replaceCanvasMentionToken(value, token, "");
}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index a0f3afc26..03affccc9 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -72,6 +72,7 @@ import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-dir
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
import { useFocusMode } from "@/hooks/use-focus-mode";
import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention";
+import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
import {
applyCanvasConnectionPromptSync,
buildCanvasAgentMentionReferences,
@@ -81,7 +82,6 @@ import {
normalizeCanvasNodeMentionTokens,
reorderCanvasResourceConnections,
replaceCanvasReferenceMentions,
- writeCanvasNodePrompt,
type CanvasResourceReference,
} from "@/lib/canvas/canvas-resource-references";
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
@@ -2521,7 +2521,7 @@ function InfiniteCanvasPage() {
) : null}
-