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}