feat(cloud-agent): 云端 Agent - 完善运行时与资源持久化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 技能";
|
||||
|
||||
@@ -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] : [];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 || "获取对象存储地址失败");
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user