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 4acf97f43..3ae5fc422 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -12,6 +12,7 @@ import type { Components } from "streamdown"; import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary, type AgentToolCategory } from "@/lib/canvas/agent-tool-presentation"; import { agentOperationCategory, agentOperationFailed, agentOperationSegmentLabel } from "@/lib/canvas/agent-operation-feed"; import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry"; +import { agentErrorContent } from "@/lib/canvas/agent-error-presentation"; import { AgentImagePreview } from "./canvas-cloud-agent-composer"; // 输入区已拆到 canvas-cloud-agent-composer.tsx;附件类型与引用转换在 canvas-cloud-agent-attachments.ts。 @@ -78,6 +79,8 @@ export type CloudAgentChatMessage = { planTerminal?: boolean; question?: CloudAgentUserQuestion; formAnswer?: CloudAgentFormAnswer; + /** 上下文压缩状态;数值只展示 Pi SDK 实际提供的估计值。 */ + compaction?: { status: "pending" | "completed" | "failed" | "aborted"; compactionId?: string; tokensBefore?: number; estimatedTokensAfter?: number; reason?: string; errorMessage?: string }; meta?: string; detail?: unknown; attachments?: CloudAgentChatAttachment[]; @@ -379,10 +382,39 @@ export function AgentChatMessage({ const markdownComponents = useMemo(() => createAgentMessageMarkdownComponents(references, onFocusNode), [onFocusNode, references]); const agentMarkdownText = rewriteAgentNodeLinks(displayedText, references); const errorTone = item.errorSeverity === "warning" ? "warning" : "error"; - const color = item.role === "error" ? (errorTone === "warning" ? "#b45309" : "#ef4444") : theme.node.text; + const color = theme.node.text; if (item.reasoning) { return ; } + if (item.compaction) { + const compactionIcon = + item.compaction.status === "pending" ? ( + + ) : item.compaction.status === "completed" ? ( + + ) : item.compaction.status === "aborted" ? ( + + ) : ( + + ); + return ( +
+ +
+ {item.compaction.status === "pending" ? ( + <> + 压缩中 + 上下文已超过服务端压缩阈值,已暂停本轮执行并压缩历史(保留用户提示摘要、操作记录、未完成任务、当前工作、设计决策、限制和偏好),压完会从压缩后的上下文继续。 + + ) : item.compaction.status === "completed" ? ( + {item.text} + ) : ( + {item.text || item.compaction.errorMessage || (item.compaction.status === "aborted" ? "上下文压缩已取消" : "上下文压缩失败")} + )} +
+
+ ); + } if (isSystem) { return (
@@ -403,23 +435,33 @@ export function AgentChatMessage({ ); } if (item.role === "error") { + const title = item.title || (errorTone === "warning" ? "Agent 正在重试" : "Agent 暂时无法继续"); + const { description, diagnosticId } = agentErrorContent(item.text, title); return ( -
- : } /> -
-
- {item.title || (errorTone === "warning" ? "Agent 正在重试" : "Agent 暂时无法继续")} +
+ +
+
+
{title}
+ {description ?

{description}

: null}
-
- {item.text} -
- {item.meta ? ( -
- {item.meta} -
+ {item.meta ?

{item.meta}

: null} + {diagnosticId ? ( +
+ + +
+

如反复出现,请将诊断号反馈给管理员。

+ {diagnosticId} +
+
) : null} {onRetry ? ( - ) : null} diff --git a/web/src/components/canvas/canvas-cloud-agent-composer.tsx b/web/src/components/canvas/canvas-cloud-agent-composer.tsx index 251d242ac..18a733243 100644 --- a/web/src/components/canvas/canvas-cloud-agent-composer.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-composer.tsx @@ -187,6 +187,7 @@ export function AgentChatComposer({ onRemoveAttachment, left, submitAccessory, + footer, onStop, stopping, references = [], @@ -209,6 +210,8 @@ export function AgentChatComposer({ left?: ReactNode; /** 发送按钮左侧的附属控件,例如上下文用量环。 */ submitAccessory?: ReactNode; + /** 输入区底部的附加内容(例如连接器条)。 */ + footer?: ReactNode; /** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */ references?: CanvasResourceReference[]; /** 供「/」弹出的技能候选(可选) */ @@ -339,13 +342,17 @@ export function AgentChatComposer({ return (
event.stopPropagation()}> + {/* 有底部附加内容时,外壳托住输入卡片与底部条,形成一张整卡。 */} +
+ {/* 整卡模式下发送光环包住整张卡,避免画在输入区与连接器之间形成一条线。 */} + {footer && sending && !reducedMotion ? : null}
- {sending && !reducedMotion ? : null} + {!footer && sending && !reducedMotion ? : null} {attachments.length ? (
{attachments.map((item, index) => ( @@ -522,6 +529,8 @@ export function AgentChatComposer({
+ {footer} +
{previewAttachment ? setPreviewAttachment(null)} /> : null}
); diff --git a/web/src/components/canvas/canvas-cloud-agent-connectors.css b/web/src/components/canvas/canvas-cloud-agent-connectors.css new file mode 100644 index 000000000..ee80b5c5a --- /dev/null +++ b/web/src/components/canvas/canvas-cloud-agent-connectors.css @@ -0,0 +1,96 @@ +/* Agent 连接器样式 - 极简紧凑版:高度压缩,图标更小,左右紧密排列 */ + +.agent-connectors { + display: flex; + align-items: center; + gap: var(--space-1-half); + padding: var(--space-1-half) var(--space-3) var(--space-2-half); + color: var(--agent-caption); +} + +.agent-connectors-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 10.5px; + font-weight: 500; + flex-shrink: 0; + opacity: 0.7; +} + +.agent-connectors-label > svg { + width: 13px; + height: 13px; +} + +.agent-connectors-list { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.agent-connector-item { + width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + display: grid; + place-items: center; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.16s cubic-bezier(0.4, 0, 0.2, 1); +} + +.agent-connector-item > svg { + width: 20px; + height: 20px; +} + +.agent-connector-item:hover { + transform: translateY(-1px) scale(1.08); +} + +.agent-connector-item:active { + transform: scale(0.96); +} + +.agent-connectors-close { + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--agent-caption); + display: grid; + place-items: center; + cursor: pointer; + flex-shrink: 0; + transition: all 0.14s ease; + opacity: 0.6; +} + +.agent-connectors-close > svg { + width: 12px; + height: 12px; +} + +.agent-connectors-close:hover { + opacity: 1; + background: color-mix(in srgb, var(--agent-ink) 6%, transparent); +} + +.agent-connectors-close:active { + transform: scale(0.92); +} + +@media (prefers-reduced-motion: reduce) { + .agent-connector-item, + .agent-connector-item:hover, + .agent-connector-item:active { + transform: none; + } +} diff --git a/web/src/components/canvas/canvas-cloud-agent-connectors.tsx b/web/src/components/canvas/canvas-cloud-agent-connectors.tsx new file mode 100644 index 000000000..21cba6a86 --- /dev/null +++ b/web/src/components/canvas/canvas-cloud-agent-connectors.tsx @@ -0,0 +1,130 @@ +// Agent 连接器:输入框底部的第三方协作工具入口条。 +// +// 流光溢彩渐变背景 + 紧凑排列的品牌图标,无独立按钮背景。 +// 品牌图标为内联 SVG 占位,避免外链 favicon 依赖。 + +import { App } from "antd"; +import { Cable, X } from "lucide-react"; +import type { ReactNode } from "react"; +import { Tooltip } from "@/components/ui/base/tooltip"; +import "./canvas-cloud-agent-connectors.css"; + +type AgentConnector = { + id: string; + name: string; + icon: ReactNode; +}; + +function NotionIcon() { + return ( + + + + + ); +} + +function FeishuIcon() { + return ( + + + + + + + + ); +} + +function BasecampIcon() { + return ( + + + + + + ); +} + +function ObsidianIcon() { + return ( + + + + + + ); +} + +function GitHubIcon() { + return ( + + + + + ); +} + +function TencentDocsIcon() { + return ( + + + + + + + + + ); +} + +function SlackIcon() { + return ( + + + + + + + ); +} + +const AGENT_CONNECTORS: AgentConnector[] = [ + { id: "notion", name: "Notion", icon: }, + { id: "feishu", name: "飞书", icon: }, + { id: "basecamp", name: "Basecamp", icon: }, + { id: "obsidian", name: "Obsidian", icon: }, + { id: "github", name: "GitHub", icon: }, + { id: "tencent-docs", name: "腾讯文档", icon: }, + { id: "slack", name: "Slack", icon: }, +]; + +/** 输入框底部的连接器条,流光溢彩背景,紧凑图标阵列。 */ +export function AgentConnectorsBar({ onClose }: { onClose: () => void }) { + const { message } = App.useApp(); + return ( +
+
+ + 连接器 +
+
+ {AGENT_CONNECTORS.map((connector) => ( + + + + ))} +
+ +
+ ); +} diff --git a/web/src/components/canvas/canvas-cloud-agent-events.ts b/web/src/components/canvas/canvas-cloud-agent-events.ts index 08a6e1e83..a57bd9a44 100644 --- a/web/src/components/canvas/canvas-cloud-agent-events.ts +++ b/web/src/components/canvas/canvas-cloud-agent-events.ts @@ -19,6 +19,25 @@ export function positiveNumber(value: string) { return Number.isFinite(number) && number > 0 ? number : undefined; } +function compactionMessageID(event: AgentEvent, payload: Record) { + const compactionID = typeof payload.compactionId === "string" ? payload.compactionId.trim() : ""; + const eventKey = event.eventId || (Number.isFinite(event.seq) ? `seq-${event.seq}` : "event"); + return compactionID ? `compaction-${event.runId}-${compactionID}` : `compaction-${event.runId}-${eventKey}`; +} + +function compactTokenLabel(value: unknown) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return ""; + if (value >= 1_000_000) return `${Math.round(value / 100_000) / 10}M`; + if (value >= 1_000) return `${Math.round(value / 100) / 10}K`; + return Math.round(value).toLocaleString("zh-CN"); +} + +function compactionCompletedText(payload: Record) { + const before = compactTokenLabel(payload.tokensBefore); + const after = compactTokenLabel(payload.estimatedTokensAfter); + return before && after ? `上下文压缩完成(${before} → ${after} Token)` : "上下文压缩完成"; +} + export function applyAgentEvent( event: AgentEvent, setMessages: Dispatch>, @@ -51,8 +70,8 @@ export function applyAgentEvent( if (terminal) { setMessages((current) => current.map((message) => (message.id === `plan-${event.runId}` && message.planItems?.length ? { ...message, planTerminal: true, streaming: false } : message))); } - if (terminal && nextStatus === "failed" && payload.failureMessage) { - setMessages((current) => appendAgentError(current, `terminal-${event.runId}`, String(payload.failureMessage), "Agent 执行失败", { severity: "error" })); + if (nextStatus === "failed") { + setMessages((current) => upsertRunFailure(current, event, String(payload.failureMessage || ""))); } if (snapshotApproval && !snapshotApproval.decision && snapshotApproval.approvalId) { setApproval((current) => ({ approvalId: snapshotApproval.approvalId, detail: snapshotApproval, reason: current?.approvalId === snapshotApproval.approvalId ? current.reason : snapshotApproval.reason || "" })); @@ -125,6 +144,65 @@ export function applyAgentEvent( setPrompt?.((current) => (current.trim() ? current : text)); return; } + if (event.type === "context_compaction_requested") { + const id = compactionMessageID(event, payload); + const compactionId = typeof payload.compactionId === "string" ? payload.compactionId : undefined; + setMessages((current) => + appendUniqueMessage(current, { + id, + role: "system", + text: "正在整理上下文", + compaction: { status: "pending", compactionId, reason: String(payload.reason || "") }, + }), + ); + return; + } + if (event.type === "context_compacted") { + const explicitId = typeof payload.compactionId === "string" && payload.compactionId.trim() ? `compaction-${event.runId}-${payload.compactionId}` : undefined; + const fallbackId = compactionMessageID(event, payload); + const tokensBefore = typeof payload.tokensBefore === "number" ? payload.tokensBefore : undefined; + const estimatedTokensAfter = typeof payload.estimatedTokensAfter === "number" ? payload.estimatedTokensAfter : undefined; + const compaction = { status: "completed" as const, compactionId: typeof payload.compactionId === "string" ? payload.compactionId : undefined, tokensBefore, estimatedTokensAfter, reason: String(payload.reason || "") }; + setMessages((current) => { + const existing = explicitId + ? current.findIndex((item) => item.id === explicitId) + : ([...current] + .map((item, index) => ({ item, index })) + .reverse() + .find(({ item }) => item.compaction?.status === "pending")?.index ?? -1); + const id = existing >= 0 ? current[existing]?.id || fallbackId : fallbackId; + if (existing >= 0) { + const next = [...current]; + next[existing] = { ...next[existing], text: compactionCompletedText(payload), compaction }; + return next; + } + return appendUniqueMessage(current, { id, role: "system", text: compactionCompletedText(payload), compaction }); + }); + return; + } + if (event.type === "context_compaction_failed") { + const explicitId = typeof payload.compactionId === "string" && payload.compactionId.trim() ? `compaction-${event.runId}-${payload.compactionId}` : undefined; + const fallbackId = compactionMessageID(event, payload); + const aborted = payload.cancelled === true || payload.aborted === true; + const errorText = String(payload.errorMessage || payload.text || "上下文压缩失败"); + setMessages((current) => { + const existing = explicitId + ? current.findIndex((item) => item.id === explicitId) + : ([...current] + .map((item, index) => ({ item, index })) + .reverse() + .find(({ item }) => item.compaction?.status === "pending")?.index ?? -1); + const id = existing >= 0 ? current[existing]?.id || fallbackId : fallbackId; + const compaction = { status: aborted ? ("aborted" as const) : ("failed" as const), compactionId: typeof payload.compactionId === "string" ? payload.compactionId : undefined, reason: String(payload.reason || ""), errorMessage: errorText }; + if (existing >= 0) { + const next = [...current]; + next[existing] = { ...next[existing], text: errorText, errorSeverity: "warning", compaction }; + return next; + } + return appendUniqueMessage(current, { id, role: "system", text: errorText, errorSeverity: "warning", compaction }); + }); + return; + } if (event.type === "user_question") { const options = Array.isArray(payload.options) ? (payload.options as Array<{ label?: unknown; detail?: unknown }>).map((option) => ({ label: String(option?.label || "").trim(), detail: option?.detail === undefined ? undefined : String(option.detail) })).filter((option) => option.label) @@ -192,7 +270,7 @@ export function applyAgentEvent( if (payload.operation === "generate_media_submit" || payload.operation === "generate_media_complete") return; const { canvasPatch: _patch, ...detail } = payload; const id = payload.callId ? `canvas-${event.runId}-${payload.callId}` : event.eventId; - setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: "画布操作已完成", detail: { ...detail, eventType: event.type } })); + setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: String(payload.text || "画布操作已完成"), detail: { ...detail, eventType: event.type } })); return; } if (event.type.startsWith("tool_") && agentToolRetry(payload)) { @@ -243,7 +321,23 @@ export function applyAgentEvent( } return; } - if (event.type === "run_failed" || event.type === "error") setMessages((current) => appendAgentError(current, event.eventId, text || "Agent 执行失败")); + if (event.type === "run_failed" || event.type === "error") setMessages((current) => upsertRunFailure(current, event, text)); +} + +function upsertRunFailure(current: CloudAgentChatMessage[], event: AgentEvent, text: string) { + const id = `terminal-${event.runId}`; + const message: CloudAgentChatMessage = { id, role: "error", ...agentErrorPresentation(text), errorSeverity: "error" }; + // 失败事件和终态快照描述的是同一次失败;重放时同时收敛已缓存的事件行。 + const next = current.filter((item) => item.role !== "error" || item.id !== event.eventId || item.id === id); + const index = next.findIndex((item) => item.id === id); + if (index < 0) return [...next, message]; + const previous = next[index]; + const hasDetails = message.text !== message.title; + const hadDetails = previous.text !== previous.title; + // 快照的公开原因优先;乱序到达的通用通知不能覆盖更完整的说明。 + if (!hasDetails || (event.type !== "run_status" && hadDetails)) return next; + next[index] = message; + return next; } export function toolDetailRecord(value: unknown): Record { diff --git a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx index 3739c1821..fbc10d5eb 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx @@ -165,7 +165,7 @@ export function AgentContextRing({ view }: { view: AgentContextUsageView }) { const protocolBytes = formatContextBytes(view.protocolBytes); const usedRatio = view.ratio === undefined ? 0 : Math.max(0, Math.min(1, view.ratio)); const phaseLabel = CONTEXT_PHASE_LABEL[view.phase]; - const sourceLabel = view.tokenSource === "provider" ? "模型实测校准" : view.estimate ? "本地估算" : "未测量"; + const sourceLabel = view.tokenSource === "pi" ? "Pi 会话估算" : "未测量"; const usageHeading = view.ratio !== undefined ? `上下文已用 ${percent}` : view.phase === "idle" ? "上下文用量" : view.phase === "unknown" ? "上下文窗口未知" : `上下文${view.label}`; return ( @@ -235,11 +235,11 @@ export function AgentContextRing({ view }: { view: AgentContextUsageView }) {
{sourceLabel} - {view.estimate ? " · 不是计费 Token" : " · 预计下次请求"} + {view.tokenSource === "pi" ? " · 不是计费 Token" : ""} {view.compactAtTokens ? 压缩线 {formatContextCount(view.compactAtTokens)} : null}
- {view.lastCompaction ?

本轮已完成一次上下文压缩,下一次读数会刷新。

: null} + {view.compactionError ?

{view.compactionError}

: view.lastCompaction ?

本轮已完成一次上下文压缩,下一次读数会刷新。

: null}
} > diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index b9bb7ba1c..8043b999e 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -29,6 +29,7 @@ import { type AgentRun, } from "@/services/api/agent"; import { agentApprovalMatchesSettings, agentApprovalTargetGenerating } from "@/lib/canvas/agent-media-approval"; +import { appendAgentPromptPrefill } from "@/lib/canvas/agent-prompt-prefill"; import type { CanvasNodeData } from "@/types/canvas"; import type { AgentMediaSettings } from "@/services/api/agent"; import { addSkill, listAddedSkills, listSkillLibraryCategories, listSkills, listSkillPresets, type Skill, type SkillCategory, type SkillLibraryCategory, type SkillPreset } from "@/services/api/skills"; @@ -56,6 +57,7 @@ import { useAgentPanelLayout } from "./use-agent-panel-layout"; import "./canvas-cloud-agent.css"; import { appendAgentError, appendUniqueMessage, applyAgentEvent, positiveNumber, type ApprovalState } from "./canvas-cloud-agent-events"; import { AgentContextRing, AgentConversation, AgentHeader, AgentHistory, AgentLauncher, ComposerControls } from "./canvas-cloud-agent-panel-parts"; +import { AgentConnectorsBar } from "./canvas-cloud-agent-connectors"; type CloudAgentPanelProps = { canvasId: string; @@ -64,7 +66,8 @@ type CloudAgentPanelProps = { selectedNodeIds: string[]; references: CanvasResourceReference[]; open: boolean; - prefillPrompt?: string; + /** 外部预填请求(如右键“发送到 Agent”);id 变化即追加一次 text。 */ + prefillRequest?: { id: number; text: string } | null; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void; @@ -74,7 +77,7 @@ type CloudAgentPanelProps = { }; type AgentPanelView = "chat" | "history" | "settings"; -export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, selectedNodeIds, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode, canvasNodes, runningNodeId }: CloudAgentPanelProps) { +export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, selectedNodeIds, references, open, prefillRequest, onOpen, onCollapse, onFocusNode, canvasNodes, runningNodeId }: CloudAgentPanelProps) { const userId = useUserStore((state) => state.user?.id); const theme = canvasThemes[useActiveTheme()]; const config = useEffectiveConfig(); @@ -87,7 +90,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se const [connectionEpoch, setConnectionEpoch] = useState(0); const [messages, setMessages] = useState([]); const [prompt, setPrompt] = useState(""); - const lastPrefillPromptRef = useRef(""); + const lastPrefillIdRef = useRef(0); const [reasoningMode, setReasoningMode] = useState("off"); const [profileView, setProfileView] = useState(null); const [profileLoading, setProfileLoading] = useState(false); @@ -128,6 +131,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se const [scenePresets, setScenePresets] = useState([]); const [presetApplyingId, setPresetApplyingId] = useState(""); const presetApplyingRef = useRef(null); + // 连接器条默认展示,关闭后本次会话内不再显示。 + const [connectorsVisible, setConnectorsVisible] = useState(true); const panelLayout = useAgentPanelLayout(); const lastSeqRef = useRef(0); const canvasSyncRef = useRef | null>(null); @@ -304,12 +309,12 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se 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(() => { - const value = prefillPrompt?.trim(); - if (!value || value === lastPrefillPromptRef.current) return; - lastPrefillPromptRef.current = value; - setPrompt(value); + if (!prefillRequest || prefillRequest.id === lastPrefillIdRef.current) return; + lastPrefillIdRef.current = prefillRequest.id; + if (!prefillRequest.text.trim()) return; + setPrompt((current) => appendAgentPromptPrefill(current, prefillRequest.text)); setView("chat"); - }, [prefillPrompt]); + }, [prefillRequest]); useEffect(() => { if (!open || view !== "chat") setSkillsOpen(false); @@ -1032,6 +1037,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se slashSkills={installedSkills} includeAssetLibrary={false} submitAccessory={} + footer={connectorsVisible ? setConnectorsVisible(false)} /> : null} left={ summary { + display: flex; + width: fit-content; + align-items: center; + gap: var(--space-1); + border-radius: var(--r-xs); + cursor: pointer; + list-style: none; +} + +.agent-error-diagnostics > summary::-webkit-details-marker { + display: none; +} + +.agent-error-diagnostics > summary:hover { + color: var(--agent-ink); +} + +.agent-error-diagnostics > summary:focus-visible { + outline: 2px solid var(--agent-focus-ring); + outline-offset: 3px; +} + +.agent-error-diagnostics[open] > summary svg { + transform: rotate(180deg); +} + +.agent-error-diagnostics-body { + margin-top: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--r-md); + background: var(--agent-surface-soft); + overflow-wrap: anywhere; +} + +.agent-error-diagnostics-body p { + margin: 0 0 var(--space-1); +} + +.agent-error-diagnostics-body code { + user-select: all; +} + +.agent-error-retry.ant-btn { + height: auto; + margin-top: var(--space-2); + padding: var(--space-1) var(--space-2); + font-size: var(--fs-label); } .agent-message-user { @@ -660,6 +730,17 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent-accent, var(--foreground)) 35%, transparent); } +/* 底部托盘:外壳与输入卡片同为一张卡,输入卡片嵌在上方,底部条直接落在托盘里;无分隔线、无间距。 */ +.agent-composer-shell[data-has-footer] { + border-radius: var(--agent-radius-surface, var(--radius-xl)); + background: color-mix(in srgb, var(--agent-ink) 3%, var(--agent-material-solid, var(--background))); +} + +/* 整卡模式下输入区焦点环会落在输入区与连接器之间形成一条线,不画焦点环。 */ +.agent-composer-shell[data-has-footer] > .agent-composer-surface:focus-within { + box-shadow: none; +} + .agent-plan-bar { border: 0; background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent)); diff --git a/web/src/components/canvas/canvas-mention-menu.tsx b/web/src/components/canvas/canvas-mention-menu.tsx index 0c9a946c8..e44caac7b 100644 --- a/web/src/components/canvas/canvas-mention-menu.tsx +++ b/web/src/components/canvas/canvas-mention-menu.tsx @@ -174,10 +174,17 @@ export function MentionMenu({ } export function MentionReferenceList({ references, activeReferenceId, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; onSelect: (reference: CanvasResourceReference) => void }) { + const activeRef = useRef(null); + + useLayoutEffect(() => { + activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [activeReferenceId]); + if (!references.length) return
没有匹配的引用
; return references.map((reference) => (
- { + { const currentNodes = nodesRef.current; const target = currentNodes.find((node) => node.id === nodeId); if (!target) { message.info("该节点已删除或尚未同步到画布"); return; } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index dc28c8cb5..a3bcc3d65 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -9989,9 +9989,9 @@ html:not(.dark) .canvas-generation-settings-trigger.ant-btn[aria-expanded="true" font-size: 0.92em; font-weight: 500; line-height: 1; - vertical-align: baseline; + vertical-align: middle; user-select: none; - transform: translateY(var(--canvas-mention-chip-offset-y)); + transform: translateY(-0.08em); } .canvas-resource-inline-skill-icon { diff --git a/web/test/agent-context-usage.test.ts b/web/test/agent-context-usage.test.ts index 9a2132131..ae30a1f7d 100644 --- a/web/test/agent-context-usage.test.ts +++ b/web/test/agent-context-usage.test.ts @@ -4,38 +4,50 @@ import { contextInputTokens, contextPressureRatio, emptyAgentContextUsage, prese const event = (runId: string, type: string, payload: Record, seq = 1) => ({ runId, type, payload, seq }); describe("Agent context usage events", () => { - it("keeps provider's previous measurement separate from projected next request", () => { + it("uses only Pi SDK readings and never reads projected/provider fields", () => { const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { modelLimitConfigured: true, - usableInputTokens: 80_000, contextWindowTokens: 128_000, - pressureTokens: 40_000, - projectedNextInputTokens: 48_000, - projectedPressureRatio: 0.6, + estimatedInputTokens: 48_000, + pressureRatio: 0.6, tokenSource: "provider", })); - expect(contextInputTokens(state.reading)).toBe(48_000); - expect(contextPressureRatio(state.reading)).toBe(0.6); + expect(state.reading).toBeNull(); + expect(contextInputTokens({ tokenSource: "provider", estimatedInputTokens: 48_000, projectedNextInputTokens: 99_000 })).toBeUndefined(); + expect(contextPressureRatio({ tokenSource: "provider", contextWindowTokens: 128_000, projectedPressureRatio: 0.6 })).toBeUndefined(); + + const piReading = { + modelLimitConfigured: true, + contextWindowTokens: 128_000, + estimatedInputTokens: 48_000, + pressureRatio: 0.6, + tokenSource: "pi", + projectedNextInputTokens: 99_000, + projectedPressureRatio: 0.99, + }; + expect(contextInputTokens(piReading)).toBe(48_000); + expect(contextPressureRatio(piReading)).toBe(0.6); }); it("does not invent percentages when the model window is unknown", () => { const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { estimatedInputTokens: 12_000, modelLimitConfigured: false, + tokenSource: "pi", })); expect(contextInputTokens(state.reading)).toBe(12_000); expect(contextPressureRatio(state.reading)).toBeUndefined(); }); it("marks pre-compaction readings stale until a fresh pressure event arrives", () => { - let state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { pressureRatio: 0.8, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 2)); + let state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { tokenSource: "pi", pressureRatio: 0.8, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 2)); state = reduceAgentContextUsage(state, event("run-1", "context_compaction_requested", { basis: "tokens" }, 3)); expect(state.compactionPending).toEqual({ basis: "tokens" }); state = reduceAgentContextUsage(state, event("run-1", "context_compacted", { mode: "checkpoint", resume: true }, 4)); expect(state.readingStale).toBe(true); expect(state.compactionPending).toBeNull(); expect(state.lastCompaction).toEqual({ mode: "checkpoint", resume: true }); - state = reduceAgentContextUsage(state, event("run-1", "context_pressure", { pressureRatio: 0.25, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 5)); + state = reduceAgentContextUsage(state, event("run-1", "context_pressure", { tokenSource: "pi", pressureRatio: 0.25, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 5)); expect(state.readingStale).toBe(false); expect(contextPressureRatio(state.reading)).toBe(0.25); }); @@ -43,12 +55,12 @@ describe("Agent context usage events", () => { it("fills the ring against the model context window and keeps the compaction line as a marker", () => { const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { modelLimitConfigured: true, - tokenSource: "provider", + tokenSource: "pi", usableInputTokens: 100_000, compactAtTokens: 85_000, contextWindowTokens: 128_000, - projectedNextInputTokens: 42_500, - projectedPressureRatio: 0.425, + estimatedInputTokens: 42_500, + pressureRatio: 0.425, breakdown: { buckets: [ { key: "system", label: "系统提示(含画布摘要)", tokens: 8_000, scaledTokens: 9_000, bytes: 12_000 }, { key: "tools", tokens: 4_000, scaledTokens: 4_500, bytes: 18_000 }, @@ -75,6 +87,7 @@ describe("Agent context usage events", () => { compactAtTokens: 85, estimatedInputTokens: 90, pressureRatio: 0.9, + tokenSource: "pi", })); expect(presentAgentContextUsage(state).phase).toBe("compress"); expect(presentAgentContextUsage(state).ring).toBe(0.9); @@ -89,6 +102,7 @@ describe("Agent context usage events", () => { const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { estimatedInputTokens: 12_000, modelLimitConfigured: false, + tokenSource: "pi", })); const view = presentAgentContextUsage(state); expect(view.phase).toBe("unknown"); diff --git a/web/test/agent-error-events.test.ts b/web/test/agent-error-events.test.ts new file mode 100644 index 000000000..03eefc1b1 --- /dev/null +++ b/web/test/agent-error-events.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import type { CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui"; +import { applyAgentEvent } from "@/components/canvas/canvas-cloud-agent-events"; +import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed"; +import type { AgentEvent } from "@/services/api/agent"; + +const failureMessage = "Agent 执行中断,请重新发送消息;如反复出现,请把诊断号 ag-test-run 反馈给管理员"; +const event = (type: string, payload: AgentEvent["payload"] = {}, runId = "ag-test-run"): AgentEvent => ({ + eventId: `${runId}:${type}`, + runId, + seq: 1, + type, + payload, + createdAt: "2026-10-03T00:00:00Z", +}); +const failed = event("run_failed", { error: "internal runner error" }); +const status = event("run_status", { status: "failed", failureMessage }); + +function reduceEvents(events: AgentEvent[], initial: CloudAgentChatMessage[] = []) { + let messages = initial; + for (const item of events) { + applyAgentEvent( + item, + (update) => { + messages = typeof update === "function" ? update(messages) : update; + }, + () => {}, + () => {}, + ); + } + return messages; +} + +test("failure event and terminal snapshot update a single error with the public reason", () => { + const messages = reduceEvents([failed, status]); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ id: "terminal-ag-test-run", role: "error", text: failureMessage, errorSeverity: "error" }); + expect(JSON.stringify(messages)).not.toContain("internal runner error"); +}); + +test("reversed delivery, interleaved messages and replay retain the detailed failure", () => { + const assistant = event("assistant_message", { messageId: "answer", text: "已有执行结果" }); + const messages = reduceEvents([status, assistant, failed, status, failed]); + expect(messages.map((item) => item.role)).toEqual(["error", "assistant"]); + expect(messages[0].text).toBe(failureMessage); +}); + +test("separate failed runs are not deduplicated by identical text", () => { + const messages = reduceEvents([event("run_failed", { text: "配额不足" }, "run-1"), event("run_failed", { text: "配额不足" }, "run-2")]); + expect(messages).toHaveLength(2); + expect(messages[0].id).not.toBe(messages[1].id); +}); + +test("a failed snapshot without a reason still appears but never replaces a specific reason", () => { + const emptyStatus = event("run_status", { status: "failed" }); + expect(reduceEvents([emptyStatus])).toHaveLength(1); + const specific = event("run_failed", { text: "模型额度不足" }); + expect(reduceEvents([emptyStatus, specific, emptyStatus])[0].text).toBe("模型额度不足"); +}); + +test("replaying a cached failure replaces its event row instead of preserving duplicates", () => { + const initial: CloudAgentChatMessage[] = [ + { id: failed.eventId, role: "error", title: "Agent 执行失败", text: "Agent 执行失败" }, + { id: "terminal-ag-test-run", role: "error", title: "Agent 执行失败", text: failureMessage }, + { id: "sync-error", role: "error", title: "画布同步冲突", text: "请重新打开画布" }, + ]; + const messages = reduceEvents([failed, status], initial); + expect(messages).toHaveLength(2); + expect(messages[0].text).toBe(failureMessage); + expect(messages[1]).toBe(initial[2]); + expect(initial).toHaveLength(3); +}); + +test("error events share the run failure identity without hiding tool failures", () => { + const tool = event("tool_failed", { toolName: "canvas_get_state", text: "读取失败" }); + const messages = reduceEvents([event("error", { message: "暂时无法执行" }), tool, failed, status]); + expect(messages.filter((item) => item.role === "error")).toHaveLength(1); + expect(messages.filter((item) => item.role === "tool")).toHaveLength(1); +}); + +test("completed, cancelled and rejected runs do not create a failure card", () => { + expect(reduceEvents(["completed", "cancelled", "rejected"].map((state) => event("run_status", { status: state })))).toEqual([]); +}); + +test("cached conversations fold only the event rows with a matching terminal run", () => { + const initial: CloudAgentChatMessage[] = [ + { id: "ag-first:9", role: "error", text: "Agent 执行失败" }, + { id: "tool", role: "tool", text: "已有执行记录" }, + { id: "terminal-ag-first", role: "error", text: "模型调用失败" }, + { id: "ag-second:2", role: "error", text: "模型调用失败" }, + { id: "terminal-ag-third", role: "error", text: "模型调用失败" }, + { id: "stream-error-ag-first", role: "error", text: "事件流已断开" }, + ]; + const segments = buildAgentFeedSegments(initial); + expect(segments.map((item) => item.key)).toEqual(["tool", "terminal-ag-first", "ag-second:2", "terminal-ag-third", "stream-error-ag-first"]); + expect(initial).toHaveLength(6); +}); diff --git a/web/test/agent-error-message.test.tsx b/web/test/agent-error-message.test.tsx new file mode 100644 index 000000000..c65d5426b --- /dev/null +++ b/web/test/agent-error-message.test.tsx @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AgentChatMessage, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui"; +import { canvasThemes } from "@/lib/canvas-theme"; + +const error: CloudAgentChatMessage = { id: "error", role: "error", title: "Agent 执行失败", text: "Agent 执行失败" }; + +test("generic failure renders its title only once in both canvas themes", () => { + for (const theme of [canvasThemes.light, canvasThemes.dark]) { + const html = renderToStaticMarkup(); + expect(html.match(/Agent 执行失败/g)).toHaveLength(1); + expect(html).not.toContain("agent-error-detail"); + expect(html).toContain('role="status"'); + } +}); + +test("diagnostic identifiers are preserved in a closed disclosure, not the main description", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Agent 执行中断,请重新发送消息

"); + expect(html).toContain("诊断信息"); + expect(html).toContain("ag-test-run"); +}); + +test("specific business failures and recovery guidance remain visible and are escaped", () => { + const html = renderToStaticMarkup(bad()", meta: "请联系管理员检查配额" }} theme={canvasThemes.light} />); + expect(html).toContain("余额不足 <script>bad()</script>"); + expect(html).toContain("请联系管理员检查配额"); + expect(html).not.toContain(" { + const html = renderToStaticMarkup( {}} retrying />); + expect(html).toContain("agent-status-message--warning"); + expect(html).toContain("disabled="); + expect(html).toContain("重试中"); +}); diff --git a/web/test/agent-prompt-prefill.test.ts b/web/test/agent-prompt-prefill.test.ts new file mode 100644 index 000000000..d0b112ced --- /dev/null +++ b/web/test/agent-prompt-prefill.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { appendAgentPromptPrefill } from "@/lib/canvas/agent-prompt-prefill"; + +describe("Agent prompt node prefill", () => { + it("preserves a long composer draft and appends the node reference with space separator", () => { + const draft = "请继续按下面的拍摄方案调整画面。".repeat(20).trimEnd(); + const reference = "@[node:image-reference-123] "; + + expect(appendAgentPromptPrefill(draft, reference)).toBe(`${draft} @[node:image-reference-123]`); + }); + + it("uses the prefill directly when the composer is empty", () => { + expect(appendAgentPromptPrefill("", "@[node:text-reference-123] ")).toBe("@[node:text-reference-123] "); + }); + + it("appends with single space separator, no forced line breaks", () => { + expect(appendAgentPromptPrefill("已有要求", "@[node:text-reference-123] ")).toBe("已有要求 @[node:text-reference-123]"); + expect(appendAgentPromptPrefill("已有要求 ", "@[node:text-reference-123] ")).toBe("已有要求 @[node:text-reference-123]"); + }); +});