Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
203 lines
6.9 KiB
TypeScript
203 lines
6.9 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 responseKind?: "smalltalk";
|
|
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";
|
|
/**
|
|
* The run has ended and the reply is stored in full; the text shown is the
|
|
* part the client has written out so far (BUG-1075). The step timeline is
|
|
* already settled, so it is not live while this is true.
|
|
*/
|
|
readonly settling?: boolean;
|
|
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" && message.responseKind !== "smalltalk"
|
|
? { timeline: consultationTimelineFromSettled(message) }
|
|
: {}),
|
|
}));
|
|
}
|
|
|
|
export function streamingChatMessageView(
|
|
messages: readonly ChatMessage[],
|
|
loading: boolean,
|
|
streamingText: string,
|
|
activity?: AgentActivityView,
|
|
thinkingText?: string,
|
|
thinkingSections?: readonly PublicThinkingSection[],
|
|
timeline?: readonly ConsultationTimelineRow[],
|
|
responseKind?: "smalltalk",
|
|
): ChatMessageView | undefined {
|
|
if (!loading || messages.at(-1)?.role === "assistant") return undefined;
|
|
return {
|
|
role: "assistant",
|
|
text: streamingText,
|
|
responseKind,
|
|
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[],
|
|
responseKind?: "smalltalk",
|
|
): LatestAssistantView | undefined {
|
|
const settled = settledChatMessageViews(messages);
|
|
const streaming = streamingChatMessageView(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
activity,
|
|
thinkingText,
|
|
thinkingSections,
|
|
timeline,
|
|
responseKind,
|
|
);
|
|
if (streaming) return { view: streaming, views: [...settled, streaming] };
|
|
const last = settled.at(-1);
|
|
if (!last || last.role !== "assistant") return undefined;
|
|
const writing = settlingChatMessageView(last, loading, streamingText);
|
|
if (writing) return { view: writing, views: [...settled.slice(0, -1), writing] };
|
|
return { view: last, views: settled };
|
|
}
|
|
|
|
/**
|
|
* After the run has ended the stored reply is complete, but the client may
|
|
* still be writing it out at the typing pace (BUG-1075): while the released
|
|
* text is a strict prefix of the stored reply, the trailing row shows the
|
|
* released part under the same render key, without a live timeline. Anything
|
|
* else (no text, text already complete, text that is not a prefix) renders
|
|
* the stored reply as is.
|
|
*/
|
|
export function settlingChatMessageView(
|
|
last: ChatMessageView,
|
|
loading: boolean,
|
|
streamingText: string,
|
|
): ChatMessageView | undefined {
|
|
if (loading || !streamingText || last.role !== "assistant") return undefined;
|
|
if (last.text === streamingText || !last.text.startsWith(streamingText)) return undefined;
|
|
return { ...last, text: streamingText, state: "streaming", settling: true };
|
|
}
|
|
|
|
export function chatMessageViews(
|
|
messages: readonly ChatMessage[],
|
|
loading: boolean,
|
|
streamingText: string,
|
|
activity?: AgentActivityView,
|
|
thinkingText?: string,
|
|
thinkingSections?: readonly PublicThinkingSection[],
|
|
timeline?: readonly ConsultationTimelineRow[],
|
|
responseKind?: "smalltalk",
|
|
): readonly ChatMessageView[] {
|
|
const settled = settledChatMessageViews(messages);
|
|
const streaming = streamingChatMessageView(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
activity,
|
|
thinkingText,
|
|
thinkingSections,
|
|
timeline,
|
|
responseKind,
|
|
);
|
|
if (streaming) return [...settled, streaming];
|
|
const last = settled.at(-1);
|
|
const writing = last ? settlingChatMessageView(last, loading, streamingText) : undefined;
|
|
return writing ? [...settled.slice(0, -1), writing] : settled;
|
|
}
|