Files
Jyotisha/frontend/src/lib/chat-message-view.ts
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 12:58:58 +08:00

208 lines
7.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 type { RectificationOfferRecord, RectificationReturnRecord } from "./rectification-in-chat.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;
/** This answer offered birth-time rectification; the card hangs under it (BUG-1248). */
readonly rectificationOffer?: RectificationOfferRecord;
/** A divider written when the user came back from rectification (BUG-1250); never a model turn. */
readonly rectificationReturn?: RectificationReturnRecord;
};
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" && !message.rectificationReturn
? { 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;
}