aff9d19343
The streaming and settled versions of the trailing assistant reply were two components, so settling unmounted one and mounted the other and the entrance tween replayed over text the reader was already on. One LatestAssistantEntry now owns that row under a single key, and the history list excludes it. The CSS entrance keyframe that doubled the GSAP tween is gone and the tween matches the documented 160ms. The step timeline no longer remounts on settle: it is a button-controlled disclosure with a 180ms grid-rows transition, the reader's own toggle wins over the live default, and an in-flight request with no events yet shows a queued row instead of an empty shell. BUG-474 BUG-475 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
|
|
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
|
|
import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
|
|
import {
|
|
consultationTimelineFromSettled,
|
|
type ConsultationTimelineRow,
|
|
} from "./consultation-run-timeline.ts";
|
|
|
|
export const ACTIVITY_ELAPSED_VISIBLE_AFTER_MS = 8_000;
|
|
export const ACTIVITY_COMPLETED_TRAIL_LIMIT = 3;
|
|
|
|
export type AgentActivityView = Readonly<{
|
|
phase: PublicActivityPhase;
|
|
label: string;
|
|
startedAt?: number;
|
|
completedTrail?: string;
|
|
}>;
|
|
|
|
export function activityCompletedTrail(steps: readonly string[]): string | undefined {
|
|
if (steps.length === 0) return undefined;
|
|
return `已完成:${steps.slice(-ACTIVITY_COMPLETED_TRAIL_LIMIT).join(" · ")}`;
|
|
}
|
|
|
|
export function activityCompletedSteps(trail: string | undefined): string[] {
|
|
if (!trail) return [];
|
|
const body = trail.replace(/^已完成:/, "").trim();
|
|
if (!body) return [];
|
|
return body.split(" · ").map((step) => step.trim()).filter(Boolean);
|
|
}
|
|
|
|
export function nextActivityView(
|
|
previous: AgentActivityView | undefined,
|
|
next: Omit<AgentActivityView, "startedAt">,
|
|
now = Date.now(),
|
|
): AgentActivityView {
|
|
const startedAt = previous?.label === next.label && previous.startedAt ? previous.startedAt : now;
|
|
const completedTrail = next.phase === "answer-composition"
|
|
? undefined
|
|
: next.completedTrail !== undefined
|
|
? next.completedTrail || undefined
|
|
: previous?.completedTrail;
|
|
return {
|
|
phase: next.phase,
|
|
label: next.label,
|
|
startedAt,
|
|
...(completedTrail ? { completedTrail } : {}),
|
|
};
|
|
}
|
|
|
|
export function activityElapsedLabel(startedAt: number, now: number): string | null {
|
|
const elapsedMs = now - startedAt;
|
|
if (elapsedMs < ACTIVITY_ELAPSED_VISIBLE_AFTER_MS) return null;
|
|
return `已用时 ${Math.floor(elapsedMs / 1000)} 秒`;
|
|
}
|
|
|
|
export type ChatMessage = {
|
|
readonly role: "user" | "assistant";
|
|
readonly text: string;
|
|
readonly thinkingText?: string;
|
|
readonly thinkingSections?: readonly PublicThinkingSection[];
|
|
readonly techniqueTruth?: string;
|
|
readonly agentExecutionReceipt?: AgentExecutionReceipt;
|
|
readonly workflowReceipt?: WorkflowReceipt;
|
|
};
|
|
|
|
export type ChatMessageView = ChatMessage & {
|
|
readonly renderKey: string;
|
|
readonly state: "settled" | "streaming" | "thinking";
|
|
readonly activity?: AgentActivityView;
|
|
readonly activityTrace?: readonly AgentActivityTraceItem[];
|
|
readonly timeline?: readonly ConsultationTimelineRow[];
|
|
};
|
|
|
|
export function settledChatMessageViews(
|
|
messages: readonly ChatMessage[],
|
|
): readonly ChatMessageView[] {
|
|
return messages.map((message, index) => ({
|
|
...message,
|
|
renderKey: `message-${index}`,
|
|
state: "settled" as const,
|
|
...(message.role === "assistant"
|
|
? { timeline: consultationTimelineFromSettled(message) }
|
|
: {}),
|
|
}));
|
|
}
|
|
|
|
export function streamingChatMessageView(
|
|
messages: readonly ChatMessage[],
|
|
loading: boolean,
|
|
streamingText: string,
|
|
activity?: AgentActivityView,
|
|
thinkingText?: string,
|
|
thinkingSections?: readonly PublicThinkingSection[],
|
|
timeline?: readonly ConsultationTimelineRow[],
|
|
): ChatMessageView | undefined {
|
|
if (!loading || messages.at(-1)?.role === "assistant") return undefined;
|
|
return {
|
|
role: "assistant",
|
|
text: streamingText,
|
|
thinkingText,
|
|
thinkingSections,
|
|
timeline: timeline ?? [],
|
|
renderKey: `message-${messages.length}`,
|
|
state: streamingText ? "streaming" : "thinking",
|
|
activity,
|
|
};
|
|
}
|
|
|
|
export type LatestAssistantView = Readonly<{
|
|
/** The trailing assistant reply: the live stream, or the last settled answer. */
|
|
view: ChatMessageView;
|
|
/** Every view in order, ending with `view`, for follow-up and regenerate lookups. */
|
|
views: readonly ChatMessageView[];
|
|
}>;
|
|
|
|
/**
|
|
* The trailing assistant reply keeps one render key from its first streamed
|
|
* token through settlement (`message-<index>` in both cases), so the same
|
|
* component instance can carry it across the transition without remounting.
|
|
*/
|
|
export function latestAssistantView(
|
|
messages: readonly ChatMessage[],
|
|
loading: boolean,
|
|
streamingText: string,
|
|
activity?: AgentActivityView,
|
|
thinkingText?: string,
|
|
thinkingSections?: readonly PublicThinkingSection[],
|
|
timeline?: readonly ConsultationTimelineRow[],
|
|
): LatestAssistantView | undefined {
|
|
const settled = settledChatMessageViews(messages);
|
|
const streaming = streamingChatMessageView(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
activity,
|
|
thinkingText,
|
|
thinkingSections,
|
|
timeline,
|
|
);
|
|
if (streaming) return { view: streaming, views: [...settled, streaming] };
|
|
const last = settled.at(-1);
|
|
if (!last || last.role !== "assistant") return undefined;
|
|
return { view: last, views: settled };
|
|
}
|
|
|
|
export function chatMessageViews(
|
|
messages: readonly ChatMessage[],
|
|
loading: boolean,
|
|
streamingText: string,
|
|
activity?: AgentActivityView,
|
|
thinkingText?: string,
|
|
thinkingSections?: readonly PublicThinkingSection[],
|
|
timeline?: readonly ConsultationTimelineRow[],
|
|
): readonly ChatMessageView[] {
|
|
const settled = settledChatMessageViews(messages);
|
|
const streaming = streamingChatMessageView(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
activity,
|
|
thinkingText,
|
|
thinkingSections,
|
|
timeline,
|
|
);
|
|
return streaming ? [...settled, streaming] : settled;
|
|
}
|