feat(canvas): 云端 Agent 交互 - 展示错误动作并支持连接器与提示词预填
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
96
web/src/components/canvas/canvas-cloud-agent-connectors.css
Normal file
96
web/src/components/canvas/canvas-cloud-agent-connectors.css
Normal 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;
|
||||
}
|
||||
}
|
||||
130
web/src/components/canvas/canvas-cloud-agent-connectors.tsx
Normal file
130
web/src/components/canvas/canvas-cloud-agent-connectors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user