feat(canvas): 云端 Agent 交互 - 展示错误动作并支持连接器与提示词预填

This commit is contained in:
ddcat666
2026-10-04 02:22:42 +08:00
parent e70430e3e2
commit 8a08663d99
19 changed files with 779 additions and 94 deletions

View File

@@ -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 <AgentReasoningFeed items={[item]} theme={theme} />;
}
if (item.compaction) {
const compactionIcon =
item.compaction.status === "pending" ? (
<LoaderCircle className="size-3 animate-spin" />
) : item.compaction.status === "completed" ? (
<Sparkles className="size-3" />
) : item.compaction.status === "aborted" ? (
<XCircle className="size-3" />
) : (
<CircleAlert className="size-3" />
);
return (
<div className="agent-status-message agent-status-message--compaction flex items-start gap-2 text-xs">
<AgentTimelineMarker theme={theme} tone={item.compaction.status === "failed" ? "warning" : "muted"} icon={compactionIcon} />
<div className="min-w-0 flex-1 py-0.5 leading-5" style={{ color: theme.node.muted }}>
{item.compaction.status === "pending" ? (
<>
<span className="font-medium">压缩中</span>
<span className="ml-2 opacity-75">上下文已超过服务端压缩阈值,已暂停本轮执行并压缩历史(保留用户提示摘要、操作记录、未完成任务、当前工作、设计决策、限制和偏好),压完会从压缩后的上下文继续。</span>
</>
) : item.compaction.status === "completed" ? (
<span className="font-medium">{item.text}</span>
) : (
<span className="font-medium">{item.text || item.compaction.errorMessage || (item.compaction.status === "aborted" ? "上下文压缩已取消" : "上下文压缩失败")}</span>
)}
</div>
</div>
);
}
if (isSystem) {
return (
<div className="agent-status-message agent-status-message--system flex items-start gap-2 text-xs">
@@ -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 (
<div className={`agent-status-message agent-status-message--${errorTone} flex items-start gap-2`}>
<AgentTimelineMarker theme={theme} tone={errorTone} icon={errorTone === "warning" ? <CircleAlert className="size-3.5" /> : <CircleAlert className="size-3.5" />} />
<div className="min-w-0 flex-1 py-0.5 text-[13px] leading-5">
<div className="agent-error-title font-medium" style={{ color }}>
{item.title || (errorTone === "warning" ? "Agent 正在重试" : "Agent 暂时无法继续")}
<div className={`agent-status-message agent-status-message--${errorTone} agent-error-message`}>
<span className="agent-error-icon" aria-hidden="true">
<CircleAlert className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div role="status">
<div className="agent-error-title">{title}</div>
{description ? <p className="agent-error-detail">{description}</p> : null}
</div>
<div className="agent-error-detail whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
{item.text}
</div>
{item.meta ? (
<div className="agent-error-meta text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
{item.meta}
</div>
{item.meta ? <p className="agent-error-meta">{item.meta}</p> : null}
{diagnosticId ? (
<details className="agent-error-diagnostics">
<summary>
<ChevronDown className="size-3" aria-hidden="true" />
诊断信息
</summary>
<div className="agent-error-diagnostics-body">
<p>如反复出现,请将诊断号反馈给管理员。</p>
<code>{diagnosticId}</code>
</div>
</details>
) : null}
{onRetry ? (
<Button type="text" size="small" className="mt-1 !h-7 !px-0" icon={retrying ? <LoaderCircle className="size-3.5 animate-spin" /> : <RotateCcw className="size-3.5" />} disabled={retrying} onClick={onRetry}>
<Button type="text" size="small" className="agent-error-retry" icon={retrying ? <LoaderCircle className="size-3.5 animate-spin" /> : <RotateCcw className="size-3.5" />} disabled={retrying} onClick={onRetry}>
{retrying ? "重试中" : "重试本轮"}
</Button>
) : null}

View File

@@ -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 (
<div className="agent-composer-wrap min-w-0 shrink-0" onWheelCapture={(event) => event.stopPropagation()}>
{/* 有底部附加内容时,外壳托住输入卡片与底部条,形成一张整卡。 */}
<div className="agent-composer-shell relative" data-has-footer={footer ? "" : undefined}>
{/* 整卡模式下发送光环包住整张卡,避免画在输入区与连接器之间形成一条线。 */}
{footer && sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
<div
className="agent-composer-surface group/composer relative transition-[background-color,box-shadow] duration-200"
style={{
color: theme.accent.primary,
}}
>
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
{!footer && sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
{attachments.length ? (
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
{attachments.map((item, index) => (
@@ -522,6 +529,8 @@ export function AgentChatComposer({
</div>
</div>
</div>
{footer}
</div>
{previewAttachment ? <AgentImagePreview attachment={previewAttachment} onClose={() => setPreviewAttachment(null)} /> : null}
</div>
);

View File

@@ -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;
}
}

View File

@@ -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 (
<svg width="20" height="20" viewBox="0 0 100 100" fill="none">
<path d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z" fill="#fff"/>
<path fillRule="evenodd" clipRule="evenodd" d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.724 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z" fill="#000"/>
</svg>
);
}
function FeishuIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#3370FF"/>
<path d="M7 7h10v3H7V7z" fill="#fff"/>
<path d="M7 11h4v6H7v-6z" fill="#fff" opacity="0.9"/>
<path d="M12 11h5v2.5h-5V11z" fill="#fff" opacity="0.75"/>
<path d="M12 14.5h5V17h-5v-2.5z" fill="#fff" opacity="0.75"/>
</svg>
);
}
function BasecampIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#1DB954"/>
<path d="M12 6c-3.5 0-6 2.5-6 6s2.5 6 6 6 6-2.5 6-6-2.5-6-6-6zm0 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z" fill="#fff"/>
<circle cx="12" cy="12" r="2" fill="#fff"/>
</svg>
);
}
function ObsidianIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#7C3AED"/>
<path d="M12 4l7 4v8l-7 4-7-4V8l7-4z" stroke="#fff" strokeWidth="1.5" fill="none"/>
<path d="M12 4v8m0 8V12m-7-4l7 4m7-4l-7 4" stroke="#fff" strokeWidth="1.2" opacity="0.6"/>
</svg>
);
}
function GitHubIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#24292e"/>
<path fillRule="evenodd" clipRule="evenodd" d="M12 3C7.03 3 3 7.03 3 12c0 3.98 2.58 7.35 6.15 8.54.45.08.62-.19.62-.43v-1.7c-2.52.55-3.05-1.08-3.05-1.08-.41-1.04-1-1.32-1-1.32-.82-.56.06-.55.06-.55.91.06 1.39.93 1.39.93.81 1.38 2.12.98 2.63.75.08-.58.31-.98.57-1.2-2.01-.23-4.13-1-4.13-4.47 0-.99.35-1.8.93-2.43-.09-.23-.4-1.14.09-2.38 0 0 .76-.24 2.48.92a8.6 8.6 0 012.26-.3c.77 0 1.54.1 2.26.3 1.72-1.16 2.48-.92 2.48-.92.49 1.24.18 2.15.09 2.38.58.63.93 1.44.93 2.43 0 3.48-2.13 4.24-4.15 4.46.33.28.62.83.62 1.67v2.47c0 .24.17.52.63.43A9.003 9.003 0 0021 12c0-4.97-4.03-9-9-9z" fill="#fff"/>
</svg>
);
}
function TencentDocsIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#1E6FFF"/>
<path d="M8 6h5.5l3 3v8.5a1 1 0 01-1 1H8a1 1 0 01-1-1V7a1 1 0 011-1z" fill="#fff"/>
<path d="M13.5 6v2.2c0 .4.3.8.8.8H17" fill="#BFD4FF"/>
<rect x="9.5" y="12" width="5.5" height="1.2" rx=".6" fill="#1E6FFF"/>
<rect x="9.5" y="14.5" width="5.5" height="1.2" rx=".6" fill="#1E6FFF" opacity=".7"/>
<rect x="9.5" y="17" width="3.5" height="1.2" rx=".6" fill="#1E6FFF" opacity=".5"/>
</svg>
);
}
function SlackIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M8.5 3.5a2 2 0 11-4 0 2 2 0 014 0zm0 7a2 2 0 01-2 2h-2a2 2 0 010-4h2a2 2 0 012 2z" fill="#E01E5A"/>
<path d="M15.5 10.5a2 2 0 100-4 2 2 0 000 4zm7 0a2 2 0 01-2 2h-2a2 2 0 110-4h2a2 2 0 012 2z" fill="#36C5F0"/>
<path d="M15.5 20.5a2 2 0 100-4 2 2 0 000 4zm0-7a2 2 0 012-2v-2a2 2 0 10-4 0v2a2 2 0 012 2z" fill="#2EB67D"/>
<path d="M8.5 13.5a2 2 0 10-4 0 2 2 0 004 0zm7 0a2 2 0 01-2 2v2a2 2 0 104 0v-2a2 2 0 01-2-2z" fill="#ECB22E"/>
</svg>
);
}
const AGENT_CONNECTORS: AgentConnector[] = [
{ id: "notion", name: "Notion", icon: <NotionIcon /> },
{ id: "feishu", name: "飞书", icon: <FeishuIcon /> },
{ id: "basecamp", name: "Basecamp", icon: <BasecampIcon /> },
{ id: "obsidian", name: "Obsidian", icon: <ObsidianIcon /> },
{ id: "github", name: "GitHub", icon: <GitHubIcon /> },
{ id: "tencent-docs", name: "腾讯文档", icon: <TencentDocsIcon /> },
{ id: "slack", name: "Slack", icon: <SlackIcon /> },
];
/** 输入框底部的连接器条,流光溢彩背景,紧凑图标阵列。 */
export function AgentConnectorsBar({ onClose }: { onClose: () => void }) {
const { message } = App.useApp();
return (
<div className="agent-connectors" role="group" aria-label="连接器">
<div className="agent-connectors-label">
<Cable />
<span>连接器</span>
</div>
<div className="agent-connectors-list">
{AGENT_CONNECTORS.map((connector) => (
<Tooltip key={connector.id} title={`${connector.name} · 研发中`} placement="top">
<button
type="button"
className="agent-connector-item"
aria-label={`${connector.name}(研发中)`}
onClick={() => message.info({ content: `${connector.name}连接器研发中,敬请期待`, key: `agent-connector-${connector.id}` })}
>
{connector.icon}
</button>
</Tooltip>
))}
</div>
<button type="button" className="agent-connectors-close" aria-label="收起连接器" onClick={onClose}>
<X />
</button>
</div>
);
}

View File

@@ -19,6 +19,25 @@ export function positiveNumber(value: string) {
return Number.isFinite(number) && number > 0 ? number : undefined;
}
function compactionMessageID(event: AgentEvent, payload: Record<string, unknown>) {
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<string, unknown>) {
const before = compactTokenLabel(payload.tokensBefore);
const after = compactTokenLabel(payload.estimatedTokensAfter);
return before && after ? `上下文压缩完成(${before} → ${after} Token)` : "上下文压缩完成";
}
export function applyAgentEvent(
event: AgentEvent,
setMessages: Dispatch<SetStateAction<CloudAgentChatMessage[]>>,
@@ -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<string, unknown> {

View File

@@ -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 }) {
<div className="agent-context-panel-foot">
<span>
{sourceLabel}
{view.estimate ? " · 不是计费 Token" : " · 预计下次请求"}
{view.tokenSource === "pi" ? " · 不是计费 Token" : ""}
</span>
{view.compactAtTokens ? <span>压缩线 {formatContextCount(view.compactAtTokens)}</span> : null}
</div>
{view.lastCompaction ? <p className="agent-context-note">本轮已完成一次上下文压缩,下一次读数会刷新。</p> : null}
{view.compactionError ? <p className="agent-context-note" role="status">{view.compactionError}</p> : view.lastCompaction ? <p className="agent-context-note">本轮已完成一次上下文压缩,下一次读数会刷新。</p> : null}
</div>
}
>

View File

@@ -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<CloudAgentChatMessage[]>([]);
const [prompt, setPrompt] = useState("");
const lastPrefillPromptRef = useRef("");
const lastPrefillIdRef = useRef(0);
const [reasoningMode, setReasoningMode] = useState<AgentReasoningMode>("off");
const [profileView, setProfileView] = useState<AgentProfileView | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
@@ -128,6 +131,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const [scenePresets, setScenePresets] = useState<SkillPreset[]>([]);
const [presetApplyingId, setPresetApplyingId] = useState("");
const presetApplyingRef = useRef<string | null>(null);
// 连接器条默认展示,关闭后本次会话内不再显示。
const [connectorsVisible, setConnectorsVisible] = useState(true);
const panelLayout = useAgentPanelLayout();
const lastSeqRef = useRef(0);
const canvasSyncRef = useRef<ReturnType<typeof createAgentCanvasSync> | 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={<AgentContextRing view={presentAgentContextUsage(contextUsage)} />}
footer={connectorsVisible ? <AgentConnectorsBar onClose={() => setConnectorsVisible(false)} /> : null}
left={
<ComposerControls
config={config}

View File

@@ -419,42 +419,112 @@
}
.agent-status-message--warning {
margin-inline: -4px;
border: 1px solid color-mix(in srgb, #f59e0b 26%, transparent);
border-radius: 10px;
background: color-mix(in srgb, #fef3c7 72%, transparent);
padding: 8px 10px;
--agent-error-accent: var(--palette-status-warning);
}
.agent-status-message--error {
margin-inline: -4px;
border: 1px solid color-mix(in srgb, #ef4444 16%, transparent);
border-radius: 10px;
background: color-mix(in srgb, #ef4444 5%, transparent);
padding: 8px 10px;
--agent-error-accent: var(--palette-status-error);
}
.agent-status-message--warning .agent-timeline-marker,
.agent-status-message--error .agent-timeline-marker {
align-self: flex-start;
padding-top: 1px;
.agent-error-message {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--agent-radius-surface);
background: var(--agent-surface-soft);
color: var(--agent-ink);
}
.agent-error-icon {
display: grid;
flex: 0 0 var(--space-6);
width: var(--space-6);
height: var(--space-6);
place-items: center;
border-radius: var(--r-full);
background: color-mix(in srgb, var(--agent-error-accent) 10%, transparent);
color: var(--agent-error-accent);
}
.agent-error-title {
font-size: 13px;
line-height: 18px;
font-size: var(--fs-body);
font-weight: 550;
line-height: var(--space-6);
}
.agent-error-detail {
margin-top: 2px;
font-size: 12px;
line-height: 17px;
margin: var(--space-1) 0 0;
color: var(--agent-caption);
font-size: var(--fs-caption);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.agent-error-meta {
margin-top: 3px;
opacity: 0.72;
line-height: 16px;
margin: var(--space-1) 0 0;
color: var(--agent-caption);
font-size: var(--fs-label);
line-height: 1.6;
overflow-wrap: anywhere;
}
.agent-error-diagnostics {
margin-top: var(--space-2);
color: var(--agent-caption);
font-size: var(--fs-label);
line-height: 1.6;
}
.agent-error-diagnostics > 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));

View File

@@ -174,10 +174,17 @@ export function MentionMenu({
}
export function MentionReferenceList({ references, activeReferenceId, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; onSelect: (reference: CanvasResourceReference) => void }) {
const activeRef = useRef<HTMLButtonElement | null>(null);
useLayoutEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [activeReferenceId]);
if (!references.length) return <div className="canvas-resource-mention-empty">没有匹配的引用</div>;
return references.map((reference) => (
<button
key={reference.id}
ref={reference.id === activeReferenceId ? activeRef : null}
type="button"
className={`canvas-resource-mention-item ${reference.kind === "skill" ? "is-skill" : ""} ${reference.id === activeReferenceId ? "is-active" : ""}`}
aria-selected={reference.id === activeReferenceId}

View File

@@ -4,7 +4,10 @@ export type AgentContextUsage = {
readingSeq: number;
readingStale: boolean;
compactionPending: Record<string, unknown> | null;
compactionSeq: number;
compactionId: string | null;
lastCompaction: Record<string, unknown> | null;
compactionError: string | null;
};
export type AgentContextUsageEvent = {
@@ -41,14 +44,23 @@ export type AgentContextUsageView = {
compactAtTokens?: number;
contextWindowTokens?: number;
protocolBytes?: number;
tokenSource?: "provider" | "estimate";
estimate: boolean;
tokenSource?: "pi";
breakdown: AgentContextBreakdownItem[];
lastCompaction: Record<string, unknown> | null;
compactionError?: string;
};
export function emptyAgentContextUsage(runId: string): AgentContextUsage {
return { runId, reading: null, readingSeq: 0, readingStale: false, compactionPending: null, lastCompaction: null };
return { runId, reading: null, readingSeq: 0, readingStale: false, compactionPending: null, compactionSeq: 0, compactionId: null, lastCompaction: null, compactionError: null };
}
function eventSequence(event: AgentContextUsageEvent): number | undefined {
return typeof event.seq === "number" && Number.isFinite(event.seq) && event.seq > 0 ? event.seq : undefined;
}
function payloadString(payload: Record<string, unknown>, key: string): string | undefined {
const value = payload[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/** Reduces durable Agent events without mixing readings from different runs. */
@@ -56,13 +68,35 @@ export function reduceAgentContextUsage(current: AgentContextUsage, event: Agent
const scoped = current.runId === event.runId ? current : emptyAgentContextUsage(event.runId);
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
if (event.type === "context_pressure") {
return { ...scoped, reading: payload, readingSeq: event.seq || 0, readingStale: false, compactionPending: null };
// Pi SDK is the only supported context-usage source. Ignore legacy Go/provider
// readings instead of letting them overwrite a valid Pi session reading.
if (payload.tokenSource !== "pi") return scoped;
const seq = eventSequence(event);
// A reading without a durable sequence cannot be ordered against a
// reconnect replay. Never let it move the ring backwards.
if (seq === undefined || seq <= scoped.readingSeq) return scoped;
return { ...scoped, reading: payload, readingSeq: seq, readingStale: false, compactionError: null };
}
if (event.type === "context_compaction_requested") {
return { ...scoped, compactionPending: payload };
const seq = eventSequence(event);
if (seq === undefined || seq <= scoped.compactionSeq) return scoped;
return { ...scoped, compactionPending: payload, compactionSeq: seq, compactionId: payloadString(payload, "compactionId") ?? null, compactionError: null };
}
if (event.type === "context_compacted") {
return { ...scoped, readingStale: Boolean(scoped.reading), compactionPending: null, lastCompaction: payload };
const seq = eventSequence(event);
if (seq === undefined || seq <= scoped.compactionSeq) return scoped;
const compactionId = payloadString(payload, "compactionId");
if (scoped.compactionId && compactionId && scoped.compactionId !== compactionId) return scoped;
return { ...scoped, readingStale: Boolean(scoped.reading), compactionPending: null, compactionSeq: seq, compactionId: compactionId ?? scoped.compactionId, lastCompaction: payload, compactionError: null };
}
if (event.type === "context_compaction_failed") {
const seq = eventSequence(event);
if (seq === undefined || seq <= scoped.compactionSeq) return scoped;
const compactionId = payloadString(payload, "compactionId");
if (scoped.compactionId && compactionId && scoped.compactionId !== compactionId) return scoped;
const aborted = payload.cancelled === true || payload.aborted === true;
const message = typeof payload.errorMessage === "string" ? payload.errorMessage : aborted ? "上下文压缩已取消,原有对话历史已保留" : "上下文压缩失败,原有对话历史已保留";
return { ...scoped, compactionPending: null, compactionSeq: seq, compactionId: compactionId ?? scoped.compactionId, compactionError: message };
}
return scoped;
}
@@ -73,20 +107,14 @@ export function finiteContextNumber(value: unknown): number | undefined {
/** Returns a ratio only when the backend has a trustworthy configured model budget. */
export function contextPressureRatio(reading: Record<string, unknown> | null): number | undefined {
if (!reading || reading.modelLimitConfigured !== true) return undefined;
if (!reading || reading.tokenSource !== "pi" || reading.modelLimitConfigured !== true) return undefined;
const contextWindow = finiteContextNumber(reading.contextWindowTokens);
if (!contextWindow || contextWindow <= 0) return undefined;
const ratio = reading.tokenSource === "provider"
? finiteContextNumber(reading.projectedPressureRatio)
: finiteContextNumber(reading.pressureRatio);
return ratio;
return finiteContextNumber(reading.pressureRatio);
}
export function contextInputTokens(reading: Record<string, unknown> | null): number | undefined {
if (!reading) return undefined;
if (reading.tokenSource === "provider") {
return finiteContextNumber(reading.projectedNextInputTokens) ?? finiteContextNumber(reading.estimatedInputTokens);
}
if (!reading || reading.tokenSource !== "pi") return undefined;
return finiteContextNumber(reading.estimatedInputTokens);
}
@@ -113,7 +141,6 @@ function contextBreakdown(reading: Record<string, unknown> | null): AgentContext
});
}
function contextProtocolBytes(reading: Record<string, unknown> | null): number | undefined {
if (!reading?.breakdown || typeof reading.breakdown !== "object") return undefined;
return finiteContextNumber((reading.breakdown as { envelopeBytes?: unknown }).envelopeBytes);
@@ -131,18 +158,15 @@ function formatContextTokens(tokens: number | undefined): string {
* All visual scales use the model context window; the marker shows the shared 80% compaction line.
*/
export function presentAgentContextUsage(usage: AgentContextUsage): AgentContextUsageView {
const reading = usage.reading;
const reading = usage.reading?.tokenSource === "pi" ? usage.reading : null;
const inputTokens = contextInputTokens(reading);
const contextWindowTokens = finiteContextNumber(reading?.contextWindowTokens);
const usableTokens = contextWindowTokens;
const compactAtTokens = finiteContextNumber(reading?.compactAtTokens);
const ratio = contextPressureRatio(reading);
const compactRatio = usableTokens && compactAtTokens ? Math.min(1, compactAtTokens / usableTokens) : undefined;
const tokenSource = reading?.tokenSource === "provider" ? "provider" : reading ? "estimate" : undefined;
const estimate = tokenSource !== "provider";
const remainingTokens = usableTokens !== undefined && inputTokens !== undefined
? Math.max(0, usableTokens - inputTokens)
: undefined;
const tokenSource = reading ? "pi" : undefined;
const remainingTokens = usableTokens !== undefined && inputTokens !== undefined ? Math.max(0, usableTokens - inputTokens) : undefined;
const base: AgentContextUsageView = {
phase: "idle",
ratio,
@@ -157,9 +181,9 @@ export function presentAgentContextUsage(usage: AgentContextUsage): AgentContext
contextWindowTokens,
protocolBytes: contextProtocolBytes(reading),
tokenSource,
estimate,
breakdown: contextBreakdown(reading),
lastCompaction: usage.lastCompaction,
compactionError: usage.compactionError ?? undefined,
};
if (usage.compactionPending) {
const basis = usage.compactionPending.basis === "bytes" ? "消息体积已到兜底线" : "已到上下文压缩线";
@@ -176,9 +200,15 @@ export function presentAgentContextUsage(usage: AgentContextUsage): AgentContext
const line = compactRatio && compactRatio > 0 ? compactRatio : 0.8;
const ring = Math.max(0, Math.min(1, ratio));
const percent = Math.round(ratio * 100);
const source = estimate ? "本地估算" : "模型实测校准";
const source = "Pi 会话估算";
if (ratio >= line) {
return { ...base, phase: "compress", ring, label: `${percent}%`, detail: `已到压缩线(模型窗口的 ${Math.round(line * 100)}%)。下一次调用前会暂停,把历史收成检查点后再继续。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。` };
return {
...base,
phase: "compress",
ring,
label: `${percent}%`,
detail: `已到压缩线(模型窗口的 ${Math.round(line * 100)}%)。下一次调用前会暂停,把历史收成检查点后再继续。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。`,
};
}
if (ring >= 0.72) {
return { ...base, phase: "watch", ring, label: `${percent}%`, detail: `接近压缩。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source}),到 ${formatContextTokens(compactAtTokens)} 时开始压缩。` };

View File

@@ -20,6 +20,13 @@ export function agentErrorPresentation(cause: unknown, fallback = "Agent 执行
return { title: fallback, text: text || fallback };
}
export function agentErrorContent(text: string, title: string) {
// 只拆分服务端公开提示中的诊断尾注,不据此推断错误类型或改变业务语义。
const diagnostic = text.trim().match(/^([\s\S]*?);如反复出现,请把诊断号\s+([a-zA-Z0-9:_-]+)\s+反馈给管理员[。.]?$/);
const description = (diagnostic?.[1] ?? text).trim();
return { description: description === title.trim() ? "" : description, diagnosticId: diagnostic?.[2] };
}
export function agentSubmissionErrorTitle(cause: unknown, accepted: boolean) {
if (accepted) return "运行已接收,但本地提交记录清理失败";
const status = cause instanceof ApiError ? cause.status : undefined;

View File

@@ -107,7 +107,11 @@ export function agentOperationSegmentLabel(items: readonly AgentFeedRecord[]): s
*/
export function buildAgentFeedSegments<T extends AgentFeedRecord>(messages: readonly T[]): AgentFeedSegment<T>[] {
const segments: AgentFeedSegment<T>[] = [];
// 缓存中的失败事件(runId:seq)与终态提示属于同一运行,不重复展示。
const terminalErrors = new Set(messages.filter((item) => item.role === "error" && item.id.startsWith("terminal-")).map((item) => item.id));
for (const item of messages) {
const failureRunId = item.role === "error" ? item.id.match(/^(.+):\d+$/)?.[1] : undefined;
if (failureRunId && terminalErrors.has(`terminal-${failureRunId}`)) continue;
if (isAgentCarrierRecord(item)) continue;
if (isAgentReasoningRecord(item)) {
const last = segments[segments.length - 1];

View File

@@ -0,0 +1,8 @@
export function appendAgentPromptPrefill(current: string, prefill: string) {
if (!prefill.trim()) return current;
if (!current.trim()) return prefill;
// 追加到末尾,用空格分隔而非换行,保持在同一段落中
const separator = current.endsWith(" ") ? "" : " ";
return `${current}${separator}${prefill.trim()}`;
}

View File

@@ -240,7 +240,8 @@ function InfiniteCanvasPage() {
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [agentPrefillPrompt, setAgentPrefillPrompt] = useState("");
// 每次发送带递增 id:重复发送同一节点时文本相同,仍需触发一次追加。
const [agentPrefillRequest, setAgentPrefillRequest] = useState<{ id: number; text: string } | null>(null);
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>(DEFAULT_CANVAS_BACKGROUND_MODE);
@@ -417,7 +418,8 @@ function InfiniteCanvasPage() {
selectedNodeIdsRef.current = selection;
setSelectedNodeIds(selection);
}
setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
const text = `${references.map(canvasResourceMentionToken).join(" ")} `;
setAgentPrefillRequest((current) => ({ id: (current?.id ?? 0) + 1, text }));
openAgent();
setContextMenu(null);
}, [agentMentionReferences, openAgent]);
@@ -2787,7 +2789,7 @@ function InfiniteCanvasPage() {
</div>
<div className={versions.open ? "hidden" : "contents"}>
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} canvasNodes={nodes} runningNodeId={runningNodeId} nodeCount={nodes.length} selectedNodeIds={Array.from(selectedNodeIds)} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} canvasNodes={nodes} runningNodeId={runningNodeId} nodeCount={nodes.length} selectedNodeIds={Array.from(selectedNodeIds)} references={agentMentionReferences} prefillRequest={agentPrefillRequest} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
const currentNodes = nodesRef.current;
const target = currentNodes.find((node) => node.id === nodeId);
if (!target) { message.info("该节点已删除或尚未同步到画布"); return; }

View File

@@ -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 {

View File

@@ -4,38 +4,50 @@ import { contextInputTokens, contextPressureRatio, emptyAgentContextUsage, prese
const event = (runId: string, type: string, payload: Record<string, unknown>, 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");

View File

@@ -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);
});

View File

@@ -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(<AgentChatMessage item={error} theme={theme} />);
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(<AgentChatMessage item={{ ...error, text: "Agent 执行中断,请重新发送消息;如反复出现,请把诊断号 ag-test-run 反馈给管理员" }} theme={canvasThemes.dark} />);
expect(html).toContain("Agent 执行中断,请重新发送消息</p>");
expect(html).toContain("诊断信息");
expect(html).toContain("<details");
expect(html).not.toContain("open=");
expect(html).toContain("<code>ag-test-run</code>");
});
test("specific business failures and recovery guidance remain visible and are escaped", () => {
const html = renderToStaticMarkup(<AgentChatMessage item={{ ...error, text: "余额不足 <script>bad()</script>", meta: "请联系管理员检查配额" }} theme={canvasThemes.light} />);
expect(html).toContain("余额不足 &lt;script&gt;bad()&lt;/script&gt;");
expect(html).toContain("请联系管理员检查配额");
expect(html).not.toContain("<details");
});
test("retry stays disabled while retrying and warnings retain a distinct tone", () => {
const html = renderToStaticMarkup(<AgentChatMessage item={{ ...error, errorSeverity: "warning" }} theme={canvasThemes.dark} onRetry={() => {}} retrying />);
expect(html).toContain("agent-status-message--warning");
expect(html).toContain("disabled=");
expect(html).toContain("重试中");
});

View File

@@ -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]");
});
});