feat(cloud-agent): 云端 Agent - 完善运行时与资源持久化

This commit is contained in:
ddcat666
2026-09-26 17:05:36 +08:00
parent 4613cca38c
commit f4480b129b
40 changed files with 1739 additions and 440 deletions

View File

@@ -140,33 +140,11 @@ export function AgentChatMessage({
const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming);
const color = item.role === "error" ? "#ef4444" : theme.node.text;
if (item.reasoning) {
return (
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
<details className={`agent-reasoning-card${item.streaming ? " is-streaming" : ""}`} open={item.streaming || undefined}>
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
<span className="agent-reasoning-icon" aria-hidden="true">
<Sparkles className="size-3.5" />
</span>
<span className="agent-reasoning-copy">
<span className="agent-reasoning-title">{item.streaming ? "模型正在思考" : "模型思考"}</span>
<span className="agent-reasoning-subtitle">{item.streaming ? "整理目标与下一步" : "推理摘要"}</span>
</span>
<span className={`agent-reasoning-status${item.streaming ? " is-live" : ""}`}>
{item.streaming ? <span className="agent-reasoning-status-dot" aria-hidden="true" /> : null}
{item.streaming ? "实时" : "查看"}
</span>
<ChevronDown className="agent-reasoning-chevron" aria-hidden="true" />
</summary>
<div className="agent-reasoning-content" data-canvas-wheel-scroll>
<div className="agent-reasoning-text">{item.text || (item.streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}</div>
</div>
</details>
</div>
);
return <AgentReasoningFeed items={[item]} theme={theme} />;
}
if (isSystem) {
return (
<div className="flex items-start gap-3 text-xs">
<div className="agent-status-message flex items-start gap-2 text-xs">
<AgentTimelineMarker theme={theme} tone="muted" icon={<Sparkles className="size-3" />} />
<div className="min-w-0 flex-1 py-0.5 leading-5" style={{ color: theme.node.muted }}>
{item.text}
@@ -185,7 +163,7 @@ export function AgentChatMessage({
}
if (item.role === "error") {
return (
<div className="flex items-start gap-3">
<div className="agent-status-message flex items-start gap-2">
<AgentTimelineMarker theme={theme} tone="error" icon={<CircleAlert className="size-3.5" />} />
<div className="min-w-0 flex-1 py-0.5 text-[13px] leading-5">
<div className="font-medium" style={{ color }}>
@@ -233,6 +211,42 @@ export function AgentChatMessage({
);
}
/**
* 推理是辅助信息,不应与正文和工具输出争夺主视觉。一个事件流里的连续摘要
* 合并成一个入口,默认收起;需要排查时再展开查看完整内容。
*/
export function AgentReasoningFeed({
items,
theme,
}: {
items: CloudAgentChatMessage[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
}) {
const streaming = items.some((item) => item.streaming);
const text = items.map((item) => item.text.trim()).filter(Boolean).join("\n\n");
const countLabel = items.length > 1 ? `${items.length} 段 · ` : "";
return (
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
<details className={`agent-reasoning-card${streaming ? " is-streaming" : ""}`}>
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
<span className="agent-reasoning-copy">
<span className="agent-reasoning-title">{streaming ? "模型正在思考" : "模型思考"}</span>
<span className="agent-reasoning-subtitle">{streaming ? "实时整理 · 点击查看" : `${countLabel}点击查看`}</span>
</span>
<span className={`agent-reasoning-status${streaming ? " is-live" : ""}`}>
{streaming ? <span className="agent-reasoning-status-dot" aria-hidden="true" /> : null}
{streaming ? "实时" : "查看"}
</span>
<ChevronDown className="agent-reasoning-chevron" aria-hidden="true" />
</summary>
<div className="agent-reasoning-content" data-canvas-wheel-scroll>
<div className="agent-reasoning-text">{text || (streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}</div>
</div>
</details>
</div>
);
}
/**
* Agent SSE events contain text chunks. Keep the full text in the message
* state, but reveal one code point at a time so a chunk never appears as a
@@ -348,7 +362,7 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
const impact = agentImpactFromDetail(detail);
const friendlySummary = friendlyAgentToolSummary(agentToolName("", detail), summary, detail, true);
return (
<div className="flex items-start gap-3">
<div className="agent-status-message flex items-start gap-2">
<AgentTimelineMarker theme={theme} tone="approval" icon={<CircleAlert className="size-3.5" />} />
<div className="agent-pending-tool min-w-0 flex-1 rounded-lg border py-2 pl-3 pr-3" style={{ borderColor: "rgba(249,115,22,.22)", background: "rgba(249,115,22,.05)", color: theme.node.text }}>
<div className="flex items-start gap-3">
@@ -462,7 +476,7 @@ export function AgentToolCard({
return (
<details data-agent-tool-retry className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
<summary className="cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2" style={{ outlineColor: theme.node.muted }}>
{label} · {retry.attempt}/{retry.maxAttempts} 次尝试未通过
{retry.status === "recovered" ? `${label} · 已恢复` : `${label} · ${retry.attempt}/${retry.maxAttempts} 次尝试未通过`}
</summary>
<ol className="mt-2 space-y-1 pl-4" aria-label="自动纠正详情">
{(attempts as AgentToolRetryAttempt[]).map((attempt, index) => (
@@ -567,7 +581,9 @@ export function AgentOperationFeed({
const category = agentOperationCategory(latest);
const categoryLabel = agentToolCategoryLabel(agentToolName(latest.title || "工具执行", latest.detail), category);
const label = agentOperationSegmentLabel(items);
const failed = items.some((item) => agentOperationFailed(item));
// 只看最新一步的状态。前面的参数错误如果已被自动纠正,不应让整段
// 操作流继续保持红色并默认展开,否则用户会误以为最终动作仍然失败。
const failed = agentOperationFailed(latest);
const expanded = userExpanded ?? failed;
const shimmering = live && !failed;
return (
@@ -581,8 +597,7 @@ export function AgentOperationFeed({
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步${categoryLabel}记录,最新一步:${label}`}
onClick={() => setUserExpanded(!expanded)}
>
<span className="agent-tool-category">
{agentToolCategoryIcon(category)}
<span className="agent-operation-kind">
<span>{categoryLabel}</span>
</span>
{/* 换步时旧文案高模糊淡出、新文案从下方上浮(先快后慢的非线性曲线)。 */}
@@ -1237,7 +1252,7 @@ function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasTheme
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
<span className="agent-timeline-marker relative" aria-hidden="true">
<span className="relative grid size-6 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
<span className="relative grid size-5 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
{icon}
</span>
</span>

View File

@@ -13,7 +13,6 @@ import { nanoid } from "nanoid";
import { ModelPicker } from "@/components/model-picker";
import { FluidOrb } from "@/components/ui/fluid-orb";
import { cn } from "@/lib/utils";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation";
@@ -33,7 +32,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
import { AGENT_SCENE_DEFS, AgentChatComposer, AgentChatMessage, AgentOperationFeed, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, AgentWorkingMessage, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
import { AGENT_SCENE_DEFS, AgentChatComposer, AgentChatMessage, AgentOperationFeed, AgentPlanBar, AgentQuestionBar, AgentReasoningFeed, AgentSceneCapsules, AgentWorkingMessage, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
@@ -128,8 +127,6 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const preferred = config.textModel || config.model || "";
return textModels.includes(preferred) ? preferred : (textModels[0] || "");
}, [config]);
const reasoningSupported = Boolean(modelCapabilityConfigFor(config, selectedModel).text?.thinking);
useEffect(() => { if (!reasoningSupported && reasoningMode !== "off") setReasoningMode("off"); }, [reasoningSupported, reasoningMode]);
const installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]);
const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]);
const installedSkillIds = useMemo(() => new Set(installedSkills.map((skill) => skill.skillId)), [installedSkills]);
@@ -541,7 +538,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const requestConfig = resolveModelRequestConfig(agentConfig, selectedModel);
const logicalModelId = logicalModelIDForConfig(agentConfig);
const input = {
canvasId, prompt: value, reasoningMode: reasoningSupported ? reasoningMode : "off", profileRevision: profileView.revision,
canvasId, prompt: value, reasoningMode, profileRevision: profileView.revision,
model: modelOptionName(selectedModel) || undefined,
...(logicalModelId ? { logicalModelId } : requestConfig.channelId ? { channelId: requestConfig.channelId, channelModelKey: modelOptionName(selectedModel) || undefined } : {}),
skillIds: [...new Set([...selectedSkillIds, ...resolveSkillMentions(value, installedSkills).map((skill) => skill.skillId)])],
@@ -910,9 +907,6 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
submitAccessory={<AgentContextRing view={presentAgentContextUsage(contextUsage)} />}
left={
<ComposerControls
reasoningMode={reasoningSupported ? reasoningMode : "off"}
reasoningSupported={reasoningSupported}
onReasoningModeChange={(value) => { if (reasoningSupported) setReasoningMode(value); }}
config={config}
selectedModel={selectedModel}
permissionMode={permissionMode}
@@ -1279,6 +1273,8 @@ function AgentConversation({
segment.kind === "operations" ? (
// 只有"对话末尾那一段 + 还在跑"才流光:历史段落留在静态态,任务完成即停。
<AgentOperationFeed key={segment.key} items={segment.items} theme={theme} references={references} onFocusNode={onFocusNode} live={busy && index === segments.length - 1} />
) : segment.kind === "reasoning" ? (
<AgentReasoningFeed key={segment.key} items={segment.items} theme={theme} />
) : (
<AgentChatMessage key={segment.key} item={segment.item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && segment.item.streaming === true && segment.item === lastMessage} />
),
@@ -1293,9 +1289,6 @@ function AgentConversation({
}
function ComposerControls({
reasoningMode,
reasoningSupported,
onReasoningModeChange,
config,
selectedModel,
permissionMode,
@@ -1306,9 +1299,6 @@ function ComposerControls({
onSkillsOpenChange,
selectedSkillCount,
}: {
reasoningMode: AgentReasoningMode;
reasoningSupported: boolean;
onReasoningModeChange: (value: AgentReasoningMode) => void;
config: ReturnType<typeof useEffectiveConfig>;
selectedModel: string;
permissionMode: AgentPermissionMode;
@@ -1336,11 +1326,6 @@ function ComposerControls({
showOptionPrices
placeholder="选择文本模型"
/>
{reasoningSupported ? <Dropdown trigger={["click"]} placement="topLeft" menu={{ items: reasoningMenuItems(reasoningMode, onReasoningModeChange) }}>
<button type="button" aria-label="选择 Agent 推理模式" title="推理模式:只用于规划和工具选择" className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25" style={{ color: reasoningMode === "off" ? theme.node.muted : theme.accent.primary, background: reasoningMode === "off" ? "transparent" : theme.node.fill }}>
<Sparkles className="size-3.5" />{reasoningModeLabel(reasoningMode)}
</button>
</Dropdown> : null}
<Dropdown trigger={["click"]} placement="topLeft" menu={{ items: agentPermissionMenuItems(permissionMode, onPermissionChange) }}>
<button
type="button"
@@ -1369,19 +1354,6 @@ function ComposerControls({
);
}
const reasoningLabels: Record<AgentReasoningMode, string> = { off: "直达", auto: "自动推理", deep: "深入推理" };
function reasoningModeLabel(mode: AgentReasoningMode) { return reasoningLabels[mode]; }
function reasoningMenuItems(mode: AgentReasoningMode, onChange: (value: AgentReasoningMode) => void) {
return (Object.keys(reasoningLabels) as AgentReasoningMode[]).map((value) => ({
key: value,
label: reasoningLabels[value],
icon: value === mode ? <Check className="size-3.5" /> : undefined,
onClick: () => onChange(value),
}));
}
function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: (settings?: AgentMediaSettings) => void; onReject: () => void }) {
const [showReason, setShowReason] = useState(Boolean(approval.reason));
const [mediaSettings, setMediaSettings] = useState<AgentMediaSettings>();

View File

@@ -388,25 +388,22 @@
scroll-padding-block: var(--space-5);
}
/*
* 过程内容的公共轴:正文的左边缘就是这条线,过程标记(星标、工具左轨、时间线标记)
* 一律挂在轴上居中。轴由星标几何推导,不是拍出来的数:星标中心 = 思考缩进 + 摘要内边距
* + 图标半宽;工具左轨用 -0.5px 让 1px 的线居中在轴上。改任一项,三处会一起跟着走。
*/
.agent-conversation-messages {
--agent-axis: 22px;
--agent-thought-icon: 16px;
--agent-thought-gap: 9px;
--agent-summary-inset: 4px;
--agent-timeline-marker: 24px;
--agent-activity-inset: 8px;
padding-left: var(--agent-axis);
min-width: 0;
}
.agent-conversation-messages > * + * {
margin-top: var(--space-4);
}
/* 状态行的图标只是语义标记,不应把正文向右推成另一条内容轴。 */
.agent-status-message {
min-width: 0;
}
.agent-message-user {
border-radius: var(--agent-radius-surface) var(--agent-radius-surface) var(--space-2) var(--agent-radius-surface);
background: var(--agent-surface-hover, color-mix(in srgb, var(--foreground) 7%, transparent));
@@ -684,11 +681,6 @@
.agent-context-panel-foot { display: grid; }
.agent-welcome-actions button { padding-inline: var(--space-2); }
/* 窄面板把公共轴收细,星标与左轨仍压在同一条线上,不吃正文宽度。 */
.agent-conversation-messages {
--agent-axis: 16px;
--agent-activity-inset: var(--space-1);
}
}
@media (max-width: 639px) {
@@ -803,7 +795,7 @@
border: 0;
border-radius: 8px;
background: transparent;
padding: 5px 2px;
padding: 5px 0;
transition: transform 160ms ease;
}
@@ -843,7 +835,7 @@
border-color: transparent;
border-radius: 9px;
background: transparent;
padding: 3px 2px;
padding: 3px 0;
}
.agent-tool-row--read {
@@ -871,7 +863,7 @@
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: 7px;
gap: 6px;
line-height: 18px;
}
@@ -891,6 +883,14 @@
line-height: 15px;
}
.agent-tool-label {
flex: 0 0 auto;
font-size: 10px;
font-weight: 600;
line-height: 15px;
white-space: nowrap;
}
.agent-tool-summary {
min-width: 0;
flex: 1 1 180px;
@@ -968,11 +968,8 @@
*/
.agent-operation-feed {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
/* 层级第二档:左轨与正文左边缘同轴(见 .agent-conversation-messages 的公共轴),
轨道把"一连串动作"拢成一条活动流,内容落在轨的右侧。 */
margin-left: -0.5px;
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
padding-left: var(--agent-activity-inset, 8px);
/* 操作流与正文共用同一左对齐轴。不要用竖线把每条工具记录切成
独立的日志栏:用户首先要看到的是 Agent 的输出和当前动作。 */
min-width: 0;
}
@@ -986,11 +983,11 @@
min-width: 0;
max-width: 100%;
align-items: center;
gap: var(--space-2);
gap: 6px;
border: 0;
border-radius: 9px;
background: transparent;
padding: 4px var(--space-2) 4px var(--space-1);
padding: 5px 0;
color: color-mix(in srgb, var(--agent-ink, currentColor) 72%, transparent);
font-size: var(--fs-caption);
line-height: 18px;
@@ -1001,6 +998,20 @@
color 140ms ease;
}
/* The folded receipt is a single sentence, not another pill-shaped card. */
.agent-operation-list .agent-tool-category {
background: transparent;
padding-inline: 0;
}
.agent-operation-kind {
flex: 0 0 auto;
color: color-mix(in srgb, var(--agent-tool-accent) 86%, var(--foreground));
font-size: var(--fs-caption);
font-weight: 600;
white-space: nowrap;
}
.agent-operation-toggle:hover {
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
color: var(--agent-ink, var(--foreground));
@@ -1052,9 +1063,7 @@
.agent-operation-count {
flex: 0 0 auto;
border-radius: 999px;
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 6%, transparent));
padding: 1px var(--space-2);
padding: 0 2px;
color: color-mix(in srgb, var(--agent-ink, currentColor) 62%, transparent);
font-size: var(--fs-tiny);
line-height: 16px;
@@ -1320,37 +1329,21 @@
* 比工具日志更安静、比正文更有层次。使用当前画布主题的 accent,避免
* 固定紫色的 AI 默认样式,同时把实时状态、摘要内容和展开动作分开。
*/
/*
* 信息层级:正文 / 工具调用 / 模型思考 三段只靠「位置 + 字号 + 对比度」分层,
* 不靠圆底图标或品牌 glyph。
*
* 正文(结论) 从公共轴开始、14px、满对比 —— 主角
* 工具调用 左轨压在公共轴上(活动流),内容在轨右侧,12px、次对比
* 模型思考 星标居中在公共轴上,12px/11px、最淡 —— 过程里的旁白
*/
/* 信息层级:正文 / 工具调用 / 模型思考三段只靠位置、字号和对比度分层。 */
.agent-reasoning {
min-width: 0;
/* 星标挂进旁注沟槽:中心落在公共轴上(正文左边缘)。 */
margin-left: calc(-1 * (var(--agent-thought-icon, 16px) / 2 + var(--agent-summary-inset, 4px)));
color: color-mix(in srgb, var(--foreground) 58%, transparent);
}
/*
* 时间线标记列(系统行 / 错误行 / 审批卡 / 上下文整理):宽度与挂点都走公共轴 token。
*/
/* 系统行、错误行和审批卡保留一个窄图标列,但不再向左挂出一条公共竖轨。 */
.agent-timeline-marker {
display: flex;
width: var(--agent-timeline-marker, 24px);
flex: 0 0 var(--agent-timeline-marker, 24px);
width: 20px;
flex: 0 0 20px;
align-self: stretch;
justify-content: center;
}
/* 只有对话流里的标记向左挂半格:中心与星标、工具左轨共用同一条公共轴。 */
.agent-conversation-messages .agent-timeline-marker {
margin-left: calc(-1 * (var(--agent-timeline-marker, 24px) / 2));
}
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
@@ -1369,11 +1362,10 @@
display: flex;
min-width: 0;
align-items: center;
gap: var(--agent-thought-gap, 9px);
gap: 0;
cursor: pointer;
list-style: none;
/* 内边距参与公共轴推导:改它,左轨会跟着一起挪(见 .agent-conversation-messages)。 */
padding: 6px var(--agent-summary-inset, 4px);
padding: 6px 0;
color: color-mix(in srgb, var(--foreground) 66%, transparent);
user-select: none;
}
@@ -1478,12 +1470,11 @@
.agent-reasoning-content {
position: relative;
display: flex;
max-height: min(232px, 30vh);
max-height: min(180px, 24vh);
gap: 0;
overflow: auto;
border-top: 0;
/* 与摘要的标题左对齐:摘要内边距 + 星标列 + 摘要 gap。 */
padding: 4px 10px 10px calc(var(--agent-summary-inset, 4px) + var(--agent-thought-icon, 16px) + var(--agent-thought-gap, 9px));
padding: 4px 0 10px;
scrollbar-gutter: stable;
}

View File

@@ -15,12 +15,16 @@ export type AgentFeedRecord = {
role: string;
title?: string;
text: string;
reasoning?: boolean;
detail?: unknown;
planItems?: readonly unknown[];
question?: unknown;
};
export type AgentFeedSegment<T> = { kind: "operations"; key: string; items: T[] } | { kind: "message"; key: string; item: T };
export type AgentFeedSegment<T> =
| { kind: "operations"; key: string; items: T[] }
| { kind: "reasoning"; key: string; items: T[] }
| { kind: "message"; key: string; item: T };
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
@@ -41,6 +45,11 @@ export function isAgentOperationRecord(item: AgentFeedRecord): boolean {
return item.role === "tool" && !isAgentCarrierRecord(item) && record(item.detail).status !== "pending";
}
/** 连续推理事件共用一条可展开记录,避免每个 SSE chunk/摘要都变成一行。 */
export function isAgentReasoningRecord(item: AgentFeedRecord): boolean {
return item.role === "assistant" && item.reasoning === true;
}
/** 折叠行上报的那一步:最新一条操作记录的友好摘要(等待执行时用"准备…"口径)。 */
export function agentOperationLabel(item: AgentFeedRecord): string {
const name = agentToolName(item.title || "工具执行", item.detail);
@@ -103,6 +112,12 @@ export function buildAgentFeedSegments<T extends AgentFeedRecord>(messages: read
const segments: AgentFeedSegment<T>[] = [];
for (const item of messages) {
if (isAgentCarrierRecord(item)) continue;
if (isAgentReasoningRecord(item)) {
const last = segments[segments.length - 1];
if (last?.kind === "reasoning") last.items.push(item);
else segments.push({ kind: "reasoning", key: item.id, items: [item] });
continue;
}
if (isAgentOperationRecord(item)) {
const last = segments[segments.length - 1];
if (last?.kind === "operations") last.items.push(item);

View File

@@ -135,7 +135,9 @@ function field(value: unknown, key: string): unknown {
export function agentToolStatus(title: string, text: string, detail?: unknown): ToolStatus {
const event = field(detail, "eventType");
if (event === "tool_failed" && agentToolRetry(detail)?.status === "retrying") return "retrying";
const retry = agentToolRetry(detail);
if (retry?.status === "retrying") return "retrying";
if (retry?.status === "recovered") return "completed";
if (event === "tool_completed" || event === "generation_task_created" || event === "canvas_updated") return "completed";
if (event === "tool_failed") return "failed";
const raw = `${title} ${text} ${field(detail, "error") || ""}`;
@@ -149,8 +151,14 @@ export function agentToolStatus(title: string, text: string, detail?: unknown):
export function friendlyAgentToolSummary(toolName: string, text: string, detail?: unknown, pending = false) {
const status = agentToolStatus(toolName, text, detail);
const failed = status === "failed" || status === "rejected";
if (toolName === "generate_media" && failed && field(field(detail, "result"), "phase") === "admission") return "生成请求未提交";
if (toolName === "generate_media" && failed && field(field(detail, "result"), "status") === "succeeded") return "生成成功,画布回写未完成";
const mediaTool = toolName === "generate_media" || toolName === "image_layer_split";
const result = record(field(detail, "result"));
const phase = result.phase;
if (mediaTool && failed && phase === "admission") return "参数未通过校验,未提交生成任务";
if (mediaTool && failed && phase === "completion" && result.taskSubmitted === true) {
if (result.writebackReason || result.status === "succeeded") return "生成成功,画布回写未完成";
return "任务已提交,但生成结果未完成";
}
if (toolName === "skills_load") {
const count = /(?:加载|启用)\s*(\d+)\s*个/u.exec(text)?.[1];
return failed ? "Skills 技能加载失败" : count ? `已加载 ${count} 个 Skills 技能` : "已加载 Skills 技能";

View File

@@ -67,7 +67,7 @@ export function uninsertedCanvasAssetHandoffPayloads(nodes: Iterable<{ metadata?
return payloads.filter((payload) => !payload.assetId || !insertedAssetIds.has(payload.assetId));
}
export function creationResultAssetIds(assets: Asset[], input: { messageId: string; taskIds: string[]; resultUrls: string[] }) {
export function creationResultAssetIds(assets: Asset[], input: { messageId: string; taskIds: string[]; resultUrls: string[]; resultStorageKeys?: string[] }) {
const taskOrder = new Map(input.taskIds.map((taskId, index) => [taskId, index]));
const candidates = assets
.filter((asset) => asset.kind === "image" || asset.kind === "video")
@@ -82,13 +82,24 @@ export function creationResultAssetIds(assets: Asset[], input: { messageId: stri
return leftResult - rightResult;
});
const unused = new Set(candidates.map((asset) => asset.id));
const allowOrderedFallback = candidates.length === input.resultUrls.length;
return input.resultUrls.flatMap((url) => {
const exact = candidates.find((asset) => unused.has(asset.id) && (asset.coverUrl === url || (asset.kind === "image" ? asset.data.dataUrl === url : asset.data.url === url)));
const stableKeys = input.resultStorageKeys || [];
const allowOrderedFallback = candidates.length === Math.max(input.resultUrls.length, stableKeys.length);
return Array.from({ length: Math.max(input.resultUrls.length, stableKeys.length) }, (_, index) => {
const storageKey = stableKeys[index];
const url = input.resultUrls[index] || "";
const exact = candidates.find((asset) => unused.has(asset.id) && Boolean(storageKey) && asset.data.storageKey === storageKey)
|| candidates.find((asset) => unused.has(asset.id) && (asset.coverUrl === url || (asset.kind === "image" ? asset.data.dataUrl === url : asset.data.url === url)));
const fallback = exact || (allowOrderedFallback ? candidates.find((asset) => unused.has(asset.id)) : undefined);
if (!fallback) return [];
if (!fallback) return undefined;
unused.delete(fallback.id);
return [fallback.id];
return fallback.id;
}).filter((id): id is string => Boolean(id));
}
export function creationResultStorageKeys(assets: Asset[], input: { messageId: string; taskIds: string[]; resultUrls: string[]; resultStorageKeys?: string[] }) {
return creationResultAssetIds(assets, input).flatMap((assetId) => {
const asset = assets.find((candidate) => candidate.id === assetId);
return asset && (asset.kind === "image" || asset.kind === "video") && asset.data.storageKey ? [asset.data.storageKey] : [];
});
}

View File

@@ -6,7 +6,7 @@ import { creationAttachmentKind, type CreationAttachment } from "./creation-asse
import type { CreationConversation, CreationMessage, CreationShotRailEntry } from "./creation-types";
type CreationRuntime = typeof import("./creation-runtime");
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationResultStorageKeys?: string[]; creationError?: string };
export function newConversation(): CreationConversation {
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
@@ -76,7 +76,8 @@ export async function materializeCreationTaskResults(runtime: CreationRuntime, t
try {
const materialized = await runtime.runGenerationConsumer(signal, (managedSignal: AbortSignal) => runtime.materializeGenerationTaskAssets(task, managedSignal));
const creationResultUrls = runtime.generationTaskMaterializedUrls(materialized);
return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized;
const creationResultStorageKeys = runtime.generationTaskMaterializedStorageKeys(materialized);
return creationResultUrls.length || creationResultStorageKeys.length ? { ...materialized, ...(creationResultUrls.length ? { creationResultUrls } : {}), ...(creationResultStorageKeys.length ? { creationResultStorageKeys } : {}) } : materialized;
} catch (error) {
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
}
@@ -105,16 +106,18 @@ export function reconcileCreationTaskMessages(runtime: CreationRuntime, conversa
const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0));
if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message;
const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls)));
const succeeded = matches.filter((task) => task.status === "succeeded");
const resultUrls = Array.from(new Set(succeeded.flatMap(creationTaskResultUrls)));
const resultStorageKeys = Array.from(new Set(succeeded.flatMap(creationTaskResultStorageKeys)));
const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length;
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
conversationChanged = true;
changed = true;
if (resultUrls.length) {
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
if (resultUrls.length || resultStorageKeys.length) {
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultStorageKeys.length || resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
return { ...message, status: "done" as const, content, ...(resultUrls.length ? { resultUrls } : {}), ...(resultStorageKeys.length ? { resultStorageKeys } : {}), error: undefined, taskIds: nextTaskIds };
}
if (matches.every((task) => task.status === "cancelled")) {
return { ...message, status: "cancelled" as const, content: "已停止", error: undefined, taskIds: nextTaskIds };
@@ -132,6 +135,11 @@ function creationTaskResultUrls(task: PersistedCreationTask) {
return [];
}
function creationTaskResultStorageKeys(task: PersistedCreationTask) {
if (task.creationResultStorageKeys?.length) return task.creationResultStorageKeys;
return [];
}
export function conversationTimestamp(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;

View File

@@ -3,7 +3,7 @@ export { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerati
export { subscribeGenerationTasks } from "@/services/api/task-center";
export { uploadMediaFile } from "@/services/file-storage";
export { uploadImage } from "@/services/image-storage";
export { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
export { consumeGenerationTaskMessage, generationTaskMaterializedStorageKeys, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
export { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
export { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
export { recoverCreationTextTask } from "@/services/creation-text-task-recovery";

View File

@@ -18,7 +18,10 @@ export type CreationMessage = {
createdAt: string;
status?: CreationStatus;
model?: string;
/** 当前会话内的临时访问地址;长期身份使用 resultStorageKeys。 */
resultUrls?: string[];
/** 生成结果对应的稳定素材定位符,不随 OSS 签名 URL 过期。 */
resultStorageKeys?: string[];
error?: string;
generationErrorCode?: string;
generationOperation?: string;

View File

@@ -14,7 +14,7 @@ import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
import { creationResultAssetIds, creationResultStorageKeys } from "@/lib/canvas/canvas-asset-handoff";
import { generationErrorMessage } from "@/lib/generation-error";
import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -36,7 +36,9 @@ import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
import type { Skill } from "@/services/api/skills";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { resolveResourceUrl } from "@/services/api/resources";
import { getResourceAccess, resolveResourceAccessURL, resolveResourceUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveImageUrl } from "@/services/image-storage";
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -253,16 +255,76 @@ function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas,
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const assets = useAssetStore((state) => state.assets);
const resultUrls = item.resultUrls || [];
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
const canContinueWithResults = resultUrls.length > 0 && resultAssetIds.length === resultUrls.length;
const storedResultUrls = item.resultUrls || [];
const resultStorageKeys = item.resultStorageKeys?.length
? item.resultStorageKeys
: creationResultStorageKeys(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: storedResultUrls });
const [resolvedResultUrls, setResolvedResultUrls] = useState(storedResultUrls);
useEffect(() => {
let active = true;
const timers: ReturnType<typeof setTimeout>[] = [];
const updateResultUrl = (index: number, url: string) => {
if (!active) return;
setResolvedResultUrls((current) => {
const next = [...current];
next[index] = url;
return next;
});
};
const refreshRemote = async (index: number, storageKey: string): Promise<void> => {
try {
const access = await getResourceAccess(storageKey, "display");
if (!active) return;
updateResultUrl(index, resolveResourceAccessURL(access.url));
const refreshAt = access.refreshAt ? new Date(access.refreshAt).getTime() : Number.NaN;
const expiresAt = access.expiresAt ? new Date(access.expiresAt).getTime() : Number.NaN;
const nextRefreshAt = Number.isFinite(refreshAt) ? refreshAt : expiresAt;
const delay = Number.isFinite(nextRefreshAt) ? Math.max(10_000, nextRefreshAt - Date.now()) : 4 * 60_000;
timers.push(setTimeout(() => void refreshRemote(index, storageKey), delay));
} catch {
if (active) timers.push(setTimeout(() => void refreshRemote(index, storageKey), 60_000));
}
};
if (!resultStorageKeys.length) {
setResolvedResultUrls(storedResultUrls);
return () => {
active = false;
};
}
// 历史记录只把 URL 当作短期 hint;真正恢复依赖稳定 storageKey,展示 URL 由访问缓存按需续签。
setResolvedResultUrls(storedResultUrls);
const resolver = item.mode === "video" ? resolveMediaUrl : resolveImageUrl;
void Promise.all(
resultStorageKeys.map(async (storageKey, index) => {
if (resourceIdFromStorageKey(storageKey)) {
await refreshRemote(index, storageKey);
return;
}
const url = await resolver(storageKey, storedResultUrls[index] || "");
updateResultUrl(index, url);
}),
).catch(() => {
// 保留当前页面已有的 URL hint;若已过期,页面会走明确的媒体空态而不是回退到平台正文代理。
});
return () => {
active = false;
timers.forEach((timer) => clearTimeout(timer));
};
}, [item.id, item.mode, resultStorageKeys.join("|"), storedResultUrls.join("|")]);
const alignedResultUrls = resultStorageKeys.length ? resolvedResultUrls : storedResultUrls;
const displayResultUrls = alignedResultUrls.filter(Boolean);
const resultAssetIds = alignedResultUrls.length || resultStorageKeys.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: alignedResultUrls, resultStorageKeys }) : [];
const expectedResultCount = resultStorageKeys.length || alignedResultUrls.length;
const canContinueWithResults = expectedResultCount > 0 && resultAssetIds.length === expectedResultCount;
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
if ((item.status === "error" || item.status === "cancelled") && !expectedResultCount) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!expectedResultCount || !displayResultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
const isVideo = item.mode === "video";
return <div className="creation-media-result">
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(displayResultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={displayResultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{displayResultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${displayResultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{displayResultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{displayResultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</div>;
}

View File

@@ -295,15 +295,15 @@ export default function CreatePage() {
const persistedTasks = await materializeCreationTaskResults(runtime, contextual, observationController.signal);
if (cancelled) return;
taskSyncWarningRef.current = false;
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length));
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length || task.creationResultStorageKeys?.length));
for (const task of attachable) {
try {
await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls, resultStorageKeys }) => {
if (cancelled) return;
await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) =>
runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value,
runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value,
);
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: runtime.generationTaskMaterializedUrls });
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: runtime.generationTaskMaterializedUrls, materializedStorageKeys: runtime.generationTaskMaterializedStorageKeys });
} catch (error) {
if (cancelled || observationController.signal.aborted) return;
console.warn("创作任务结果挂载失败,将使用已物化结果收敛消息状态", error);
@@ -719,8 +719,8 @@ export default function CreatePage() {
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => {
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value);
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value);
}, { signal: requestLifecycle.signal });
const url = runtime.generationTaskMaterializedUrls(materialized)[0];
if (!url) throw new Error("图片结果资源不可用");
@@ -752,8 +752,8 @@ export default function CreatePage() {
const taskId = Array.from(boundTaskIds)[0];
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "video", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id });
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls })).value);
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls, ...(resultStorageKeys.length ? { resultStorageKeys } : {}) })).value);
}, { signal: requestLifecycle.signal });
if (!runtime.generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用");
}
@@ -805,9 +805,11 @@ export default function CreatePage() {
try {
const assets = useAssetStore.getState().assets;
const generatedAssetIds = selectedAssetIds || source.messages.flatMap((item) => {
if (!item.resultUrls?.length) return [];
const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: item.resultUrls });
if (ids.length !== item.resultUrls.length) throw new Error("部分生成素材还未保存完成,请稍后转入画布。");
const resultStorageKeys = item.resultStorageKeys || [];
const resultUrls = item.resultUrls || [];
if (!resultStorageKeys.length && !resultUrls.length) return [];
const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls, resultStorageKeys });
if (ids.length !== Math.max(resultStorageKeys.length, resultUrls.length)) throw new Error("部分生成素材还未保存完成,请稍后转入画布。");
return ids;
});
const referenceKeys = new Set(source.messages.flatMap((item) => (item.attachments || []).map((attachment) => attachment.storageKey).filter(Boolean)));

View File

@@ -1,4 +1,5 @@
import { getActiveUserScope } from "@/lib/user-scope";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { http, apiBaseURL, ApiError } from "@/services/api/request";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
@@ -117,8 +118,59 @@ export type ResourceAccess = {
fallbackReason?: string;
};
const accessCache = new Map<string, { value: ResourceAccess; expiresAt: number }>();
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
type CachedResourceAccess = { value: ResourceAccess; expiresAt: number };
const accessCache = new Map<string, CachedResourceAccess>();
const accessRequests = new Map<string, Promise<ResourceAccess>>();
const PERSISTED_DISPLAY_ACCESS_PREFIX = "resource-access-v1:";
function persistedDisplayAccessKey(resourceId: string, variant: ResourceAccessVariant) {
return `${PERSISTED_DISPLAY_ACCESS_PREFIX}${resourceId}:${variant}`;
}
function shouldPersistDisplayAccess(value: ResourceAccess) {
// 只跨页面缓存 OSS/CDN 直连地址;平台本地/代理地址仍只在内存中保留,避免把平台签名凭据落到持久存储。
return value.delivery === "cdn" || value.delivery === "origin";
}
function resourceAccessCacheExpiry(value: ResourceAccess, now = Date.now()) {
const expiresAt = value.expiresAt ? new Date(value.expiresAt).getTime() : Number.NaN;
if (Number.isFinite(expiresAt) && expiresAt <= now + 1_000) return now;
const refreshAt = value.refreshAt ? new Date(value.refreshAt).getTime() : Number.NaN;
const candidates = [
Number.isFinite(refreshAt) && refreshAt > now ? refreshAt : Number.POSITIVE_INFINITY,
Number.isFinite(expiresAt) && expiresAt > now ? expiresAt - 15_000 : Number.POSITIVE_INFINITY,
].filter(Number.isFinite);
if (candidates.length) return Math.max(now, Math.min(...candidates));
return now + 5 * 60_000;
}
async function readPersistedDisplayAccess(scope: string, resourceId: string, variant: ResourceAccessVariant): Promise<CachedResourceAccess | undefined> {
try {
const raw = await localForageStorageForScope(scope).getItem(persistedDisplayAccessKey(resourceId, variant));
if (!raw) return undefined;
const parsed = JSON.parse(raw) as Partial<CachedResourceAccess>;
if (!parsed.value?.url || !shouldPersistDisplayAccess(parsed.value) || typeof parsed.expiresAt !== "number") return undefined;
const actualExpiresAt = parsed.value.expiresAt ? new Date(parsed.value.expiresAt).getTime() : Number.NaN;
if (parsed.expiresAt <= Date.now() || (Number.isFinite(actualExpiresAt) && actualExpiresAt <= Date.now() + 1_000)) {
await localForageStorageForScope(scope).removeItem(persistedDisplayAccessKey(resourceId, variant));
return undefined;
}
return parsed as CachedResourceAccess;
} catch {
// 签名地址缓存只是性能优化,存储不可用或数据损坏时继续走后端签发。
return undefined;
}
}
async function persistDisplayAccess(scope: string, resourceId: string, variant: ResourceAccessVariant, entry: CachedResourceAccess) {
if (!shouldPersistDisplayAccess(entry.value) || entry.expiresAt <= Date.now()) return;
try {
await localForageStorageForScope(scope).setItem(persistedDisplayAccessKey(resourceId, variant), JSON.stringify(entry));
} catch {
// 不把本地缓存失败升级为资源访问失败。
}
}
let accessGeneration = 0;
/**
@@ -335,15 +387,27 @@ export async function getResourceAccess(storageKey: string | undefined, purpose:
let request!: Promise<ResourceAccess>;
request = (async () => {
try {
const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
// 只跨页面复用普通展示地址。下载和模型输入地址可能包含更敏感的权限/文件名,仍只保留内存缓存。
if (purpose === "display") {
const persisted = await readPersistedDisplayAccess(scope, id, variant);
assertResourceRequestCurrent(generation, scope);
if (persisted) {
accessCache.set(key, persisted);
return persisted.value;
}
}
const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
if (generation !== accessGeneration || scope !== getActiveUserScope()) {
throw new DOMException("资源访问请求已因账号切换失效", "AbortError");
}
const item = data.items?.[0];
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
const value = item.access;
const ttl = value.expiresAt ? Math.max(10_000, new Date(value.expiresAt).getTime() - Date.now() - 15_000) : 5 * 60_000;
accessCache.set(key, { value, expiresAt: Date.now() + ttl });
const entry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
if (entry.expiresAt > Date.now()) {
accessCache.set(key, entry);
if (purpose === "display") await persistDisplayAccess(scope, id, variant, entry);
}
return value;
} catch (error) {
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");

View File

@@ -63,7 +63,19 @@ export async function loadCreationConversations<T extends StoredCreationConversa
return parsed as T[];
}
function persistableCreationConversations<T extends StoredCreationConversation>(conversations: T[]) {
return conversations.map((conversation) => ({
...conversation,
messages: conversation.messages.map((message) => {
const candidate = message as PendingCreationMessage & { resultUrls?: unknown; resultStorageKeys?: unknown };
if (!Array.isArray(candidate.resultStorageKeys) || candidate.resultStorageKeys.length === 0) return message;
const { resultUrls: _transientResultUrls, ...persistedMessage } = candidate;
return persistedMessage as typeof message;
}),
})) as T[];
}
export async function saveCreationConversations<T extends StoredCreationConversation>(conversations: T[]) {
const storage = localForageStorageForScope(getActiveUserScope());
await storage.setItem(CREATION_CONVERSATIONS_KEY, JSON.stringify(conversations));
await storage.setItem(CREATION_CONVERSATIONS_KEY, JSON.stringify(persistableCreationConversations(conversations)));
}

View File

@@ -520,12 +520,13 @@ export async function consumeGenerationTaskAgent(
export async function consumeGenerationTaskMessage(
task: GenerationTask,
messageId: string,
consumer: (input: { task: GenerationTask; resultUrls: string[]; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
consumer: (input: { task: GenerationTask; resultUrls: string[]; resultStorageKeys: string[]; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
materializedUrls?: typeof generationTaskMaterializedUrls;
materializedStorageKeys?: typeof generationTaskMaterializedStorageKeys;
attachMessage?: typeof attachGenerationTaskMessage;
} = {},
): Promise<GenerationTask> {
@@ -534,6 +535,7 @@ export async function consumeGenerationTaskMessage(
}
const materialized = await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal);
const resultUrls = (dependencies.materializedUrls ?? generationTaskMaterializedUrls)(materialized);
const resultStorageKeys = (dependencies.materializedStorageKeys ?? generationTaskMaterializedStorageKeys)(materialized);
const attach = dependencies.attachMessage ?? attachGenerationTaskMessage;
const outputs = materialized.outputs?.filter((output) => output.materializedAssetId) ?? [];
for (const output of outputs) {
@@ -542,7 +544,7 @@ export async function consumeGenerationTaskMessage(
messageId,
output.outputIndex,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, resultUrls, effectKey, signal });
await consumer({ task: materialized, resultUrls, resultStorageKeys, effectKey, signal });
},
dependencies.signal,
);
@@ -555,8 +557,21 @@ export function generationTaskMaterializedUrls(task: GenerationTask): string[] {
return (task.outputs || []).flatMap((output) => {
const asset = output.materializedAssetId ? assets.find((candidate) => candidate.id === output.materializedAssetId) : undefined;
if (!asset) return [];
if (asset.kind === "image") return [asset.data.dataUrl || asset.coverUrl];
if (asset.kind === "video" || asset.kind === "audio") return [asset.data.url];
if (asset.kind === "image") return [asset.data.dataUrl || asset.coverUrl].filter(Boolean);
if (asset.kind === "video" || asset.kind === "audio") return [asset.data.url].filter(Boolean);
return [];
});
}
/**
* 返回生成结果的稳定素材定位符。对于远程资源是 resource:<id>,对于本地降级素材是
* 本地 IndexedDB storageKey。它可以持久化到历史记录,不能被短时效签名 URL 替代。
*/
export function generationTaskMaterializedStorageKeys(task: GenerationTask): string[] {
const assets = useAssetStore.getState().assets;
return (task.outputs || []).flatMap((output) => {
const asset = output.materializedAssetId ? assets.find((candidate) => candidate.id === output.materializedAssetId) : undefined;
if (!asset || (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio")) return [];
return asset.data.storageKey ? [asset.data.storageKey] : [];
});
}

View File

@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AgentOperationFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { AgentOperationFeed, AgentReasoningFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
import { canvasThemes } from "@/lib/canvas-theme";
const step = (id: string, title: string, text: string, detail: unknown = { eventType: "tool_completed" }): CloudAgentChatMessage => ({ id, role: "tool", title, text, detail });
@@ -8,6 +9,22 @@ const step = (id: string, title: string, text: string, detail: unknown = { event
const steps: CloudAgentChatMessage[] = [step("t1", "canvas_get_state", "工具执行成功"), step("t2", "model_list", "工具执行成功")];
const viewed = (id: string, title: string): CloudAgentChatMessage => step(id, "canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: id, title } });
test("consecutive reasoning messages share one collapsed entry", () => {
const messages: CloudAgentChatMessage[] = [
{ id: "r1", role: "assistant", text: "先检查画布", reasoning: true },
{ id: "r2", role: "assistant", text: "再选择模型", reasoning: true },
{ id: "a1", role: "assistant", text: "我开始处理。" },
];
const segments = buildAgentFeedSegments(messages);
expect(segments.map((segment) => segment.kind)).toEqual(["reasoning", "message"]);
expect(segments[0]?.kind === "reasoning" ? segments[0].items : []).toHaveLength(2);
const html = renderToStaticMarkup(<AgentReasoningFeed items={messages.slice(0, 2)} theme={canvasThemes.light} />);
expect(html).toContain("2 段 · 点击查看");
expect(html).toContain('class="agent-reasoning-card"');
expect(html).not.toContain(" open");
});
test("operations fold into one line that reports only the latest action", () => {
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
const html = renderToStaticMarkup(<AgentOperationFeed items={steps} theme={theme} />);
@@ -74,7 +91,7 @@ test("style contract: no per-step status ticks, vision tint and shimmer stay tok
expect(css).toContain("--agent-tool-accent: var(--agent-accent, var(--foreground));");
});
test("style contract: 正文 / 工具调用 / 模型思考 三档靠公共轴与明度分层", async () => {
test("style contract: 正文 / 工具调用 / 模型思考三档不再制造左侧公共竖轨", async () => {
const css = await Bun.file(new URL("../src/components/canvas/canvas-cloud-agent.css", import.meta.url)).text();
// 同一选择器可能在容器查询里被覆盖,这里把所有命中块拼起来看整体契约。
const block = (selector: string) => [...css.matchAll(new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\{([^}]*)\\}`, "g"))].map((match) => match[1]).join("\n");
@@ -84,27 +101,23 @@ test("style contract: 正文 / 工具调用 / 模型思考 三档靠公共轴与
expect(icon).toContain("background: transparent");
expect(icon).not.toContain("border-radius: 50%");
// ② 公共轴:正文就从这条线开始(列表左内边距 = 轴)
// ② 对话正文从容器边缘开始,不为过程内容预留一条空的公共轴沟槽。
const axis = block(".agent-conversation-messages");
expect(axis).toContain("--agent-axis: 22px");
expect(axis).toContain("padding-left: var(--agent-axis)");
expect(axis).not.toContain("--agent-axis");
expect(axis).not.toContain("padding-left: var(--agent-axis)");
// ③ 过程标记挂在轴上:星标向左挂半格 + 摘要内边距,中心正好落在轴
// ③ 思维摘要与正文同一左边缘,不再用负 margin 把星标挂进沟槽。
const reasoning = block(".agent-reasoning");
expect(reasoning).toContain("margin-left: calc(-1 * (var(--agent-thought-icon, 16px) / 2 + var(--agent-summary-inset, 4px)))");
expect(reasoning).not.toContain("margin-left");
expect(reasoning).not.toContain("border-left");
expect(block(".agent-reasoning-summary")).toContain("padding: 6px var(--agent-summary-inset");
expect(block(".agent-reasoning-summary")).toContain("padding: 6px 0");
// ④ 工具左轨也压在轴上(1px 线居中),内容落在轨右侧
// ④ 操作流是轻量文本收据,不再绘制竖线或额外左缩进。
const feed = block(".agent-operation-feed");
expect(feed).toContain("margin-left: -0.5px");
expect(feed).toContain("border-left: 1px solid");
expect(feed).toContain("padding-left: var(--agent-activity-inset");
expect(feed).not.toContain("border-left");
expect(feed).not.toContain("padding-left");
// ⑤ 时间线标记同样挂半格,中心与星标/左轨同轴
expect(block(".agent-timeline-marker")).toContain("width: var(--agent-timeline-marker, 24px)");
expect(block(".agent-conversation-messages .agent-timeline-marker")).toContain("margin-left: calc(-1 * (var(--agent-timeline-marker, 24px) / 2))");
// ⑥ 窄面板只把轴收细,不让三处错位
expect(css).toContain("--agent-axis: 16px");
// ⑤ 时间线标记只占自己的窄图标列,不再把状态正文推得过远。
expect(block(".agent-timeline-marker")).toContain("width: 20px");
expect(block(".agent-conversation-messages .agent-timeline-marker")).not.toContain("margin-left");
});