Files
Jyotisha/frontend/src/lib/consultation-run-timeline.ts
T
Jesse_Chen 6b225a288c fix(web): keep later thinking below rectification tool steps
BUG-360: stream thinking and tool activity as an ordered trace so later CoT opens under 正在整理 instead of filling the first 思考 block.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 17:02:06 +08:00

365 lines
12 KiB
TypeScript

import type { AgentExecutionReceipt, ConsultationAgentPublicEvent } from "./consultation-agent-events.ts";
import {
CONSULTATION_CHART_CALCULATION_LABEL,
CONSULTATION_COMPOSING_LABEL,
CONSULTATION_DONE_CHART_LABEL,
CONSULTATION_DONE_SKILL_LABEL,
CONSULTATION_LOADING_METHOD_LABEL,
consultationWriteLabel,
} from "./consultation-activity-labels.ts";
import {
consultationDomainDefinition,
normalizeConsultationDomain,
} from "./consultation-domain-registry.ts";
import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
export const CONSULTATION_TIMELINE_KINDS = ["method", "calculate", "think", "write"] as const;
export type ConsultationTimelineKind = (typeof CONSULTATION_TIMELINE_KINDS)[number];
export type ConsultationTimelineStatus = "live" | "done";
export type ConsultationTimelineRow = Readonly<{
id: string;
kind: ConsultationTimelineKind;
status: ConsultationTimelineStatus;
label: string;
queries?: readonly string[];
sources?: readonly string[];
thinkingText?: string;
}>;
export type ConsultationTimelineState = Readonly<{
rows: readonly ConsultationTimelineRow[];
answer: string;
}>;
const METHOD_ID = "method";
const CALCULATE_ID = "calculate";
export function emptyConsultationTimeline(): ConsultationTimelineState {
return { rows: [], answer: "" };
}
export function reduceConsultationTimeline(
state: ConsultationTimelineState,
event: ConsultationAgentPublicEvent,
): ConsultationTimelineState {
if (event.type === "skill.started") {
return upsertRow(state, {
id: METHOD_ID,
kind: "method",
status: "live",
label: CONSULTATION_LOADING_METHOD_LABEL,
});
}
if (event.type === "skill.completed") {
return completeRow(state, METHOD_ID, CONSULTATION_DONE_SKILL_LABEL);
}
if (event.type === "tool.started") {
return upsertRow(completeLiveThink(state), {
id: CALCULATE_ID,
kind: "calculate",
status: "live",
label: event.label || CONSULTATION_CHART_CALCULATION_LABEL,
});
}
if (event.type === "activity") {
if (event.phase === "chart-calculation") {
return patchRow(state, CALCULATE_ID, {
status: "live",
label: event.label,
}, {
id: CALCULATE_ID,
kind: "calculate",
status: "live",
label: event.label,
});
}
if (event.phase === "evidence-validation") {
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
}
if (event.phase === "answer-composition") {
return upsertWrite(completeLiveThink(state), event.label);
}
return state;
}
if (event.type === "tool.completed") {
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
}
if (event.type === "tool.failed") {
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
}
if (event.type === "thinking.section") {
const section: PublicThinkingSection = {
id: event.id,
title: event.title,
heading: event.heading,
steps: event.steps,
};
const withCalc = enrichCalculate(completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL), section);
const withoutOpen = completeLiveThink(withCalc);
return upsertRow(withoutOpen, {
id: `think-${section.id}`,
kind: "think",
status: "live",
label: consultationThinkTitle(section.title),
});
}
if (event.type === "thinking.delta") {
const think = lastRow(state.rows, (row) => row.kind === "think" && row.status === "live");
if (think) {
const thinkingText = `${think.thinkingText ?? ""}${event.text}`.slice(0, 4_000);
return upsertRow(state, { ...think, status: "live", thinkingText });
}
const thinkCount = state.rows.filter((row) => row.kind === "think").length;
return upsertRow(state, {
id: `think-open-${thinkCount + 1}`,
kind: "think",
status: "live",
label: "正在分析…",
thinkingText: event.text.slice(0, 4_000),
});
}
if (event.type === "answer.delta") {
const next = { ...completeLiveThink(state), answer: `${state.answer}${event.text}` };
return syncWriteRows(next, false);
}
if (event.type === "run.completed" || event.type === "run.failed") {
const next = syncWriteRows(completeLiveThink(state), true);
return {
...next,
rows: next.rows.map((row) => (
row.status === "done" ? row : { ...row, status: "done" as const, label: doneLabel(row) }
)),
};
}
return state;
}
export function reduceConsultationTimelineEvents(
events: readonly ConsultationAgentPublicEvent[],
state = emptyConsultationTimeline(),
): ConsultationTimelineState {
return events.reduce(reduceConsultationTimeline, state);
}
export function consultationTimelineFromSettled(input: {
text?: string;
thinkingText?: string;
thinkingSections?: readonly PublicThinkingSection[];
agentExecutionReceipt?: AgentExecutionReceipt;
}): readonly ConsultationTimelineRow[] {
const text = input.text?.trim() ?? "";
const sections = input.thinkingSections ?? [];
const receipt = input.agentExecutionReceipt;
if (!text && !sections.length && !input.thinkingText?.trim() && !receipt) return [];
const events: ConsultationAgentPublicEvent[] = [
{ type: "skill.started", name: "jyotish-vedic-astrology" },
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
];
const calculated = receipt?.steps.some((step) => step.kind === "tool")
|| sections.some((section) => section.id.startsWith("domain-"));
if (calculated) {
events.push({
type: "tool.started",
callId: "settled",
tool: "run-jyotish-consultation",
label: CONSULTATION_CHART_CALCULATION_LABEL,
});
events.push({
type: "tool.completed",
callId: "settled",
tool: "run-jyotish-consultation",
status: "ready",
durationMs: 0,
});
}
for (const section of sections) {
events.push({ type: "thinking.section", ...section });
}
if (input.thinkingText?.trim() && sections.length === 0) {
events.push({ type: "thinking.delta", text: input.thinkingText });
}
if (text) events.push({ type: "answer.delta", text });
events.push({
type: "run.completed",
receipt: receipt ?? {
runId: "settled",
runtime: "mastra-agentic",
skill: {
name: "jyotish-vedic-astrology",
loaded: true,
referenceReads: 0,
methodologySections: 0,
},
steps: [],
workflow: { route: "settled", status: "ready", preciseTiming: "blocked", missingLayers: [] },
},
});
let state = reduceConsultationTimelineEvents(events);
if (input.thinkingText?.trim() && sections.length > 0) {
const lastThink = [...state.rows].reverse().find((row) => row.kind === "think");
if (lastThink) {
state = upsertRow(state, { ...lastThink, thinkingText: input.thinkingText.slice(0, 4_000) });
}
}
return state.rows;
}
export function consultationThinkTitle(title: string): string {
const cleaned = title.replace(/\s+/g, " ").trim();
if (cleaned.length <= 20) return cleaned || "正在分析";
return cleaned.slice(0, 20);
}
function answerHeadings(text: string): string[] {
return [...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]?.trim() ?? "").filter(Boolean);
}
function queriesFromSection(section: PublicThinkingSection): string[] {
const domain = section.id.startsWith("domain-")
? normalizeConsultationDomain(section.id.slice("domain-".length))
: null;
if (!domain) return [];
const definition = consultationDomainDefinition(domain);
return uniqueLabels([definition.label, ...definition.evidencePreview]).slice(0, 8);
}
function sourcesFromSection(section: PublicThinkingSection): string[] {
return uniqueLabels(section.steps
.map((step) => step.label.replace(/^对照\s+/, "").trim())
.filter((label) => label.length > 0 && label.length <= 24)).slice(0, 8);
}
function uniqueLabels(values: readonly string[]): string[] {
const seen = new Set<string>();
const kept: string[] = [];
for (const value of values) {
const label = value.replace(/\s+/g, " ").trim();
if (!label || seen.has(label)) continue;
seen.add(label);
kept.push(label);
}
return kept;
}
function lastRow(
rows: readonly ConsultationTimelineRow[],
match: (row: ConsultationTimelineRow) => boolean,
): ConsultationTimelineRow | undefined {
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
if (row && match(row)) return row;
}
return undefined;
}
function upsertRow(state: ConsultationTimelineState, row: ConsultationTimelineRow): ConsultationTimelineState {
const index = state.rows.findIndex((item) => item.id === row.id);
if (index < 0) return { ...state, rows: [...state.rows, row] };
return {
...state,
rows: state.rows.map((item, current) => (current === index ? { ...item, ...row } : item)),
};
}
function patchRow(
state: ConsultationTimelineState,
id: string,
patch: Partial<ConsultationTimelineRow>,
create?: ConsultationTimelineRow,
): ConsultationTimelineState {
const existing = state.rows.find((row) => row.id === id);
if (!existing) return create ? upsertRow(state, create) : state;
return upsertRow(state, { ...existing, ...patch, id, kind: existing.kind });
}
function completeRow(
state: ConsultationTimelineState,
id: string,
label?: string,
): ConsultationTimelineState {
const existing = state.rows.find((row) => row.id === id);
if (!existing) return state;
return upsertRow(state, {
...existing,
status: "done",
label: label ?? doneLabel(existing),
});
}
function completeLiveThink(state: ConsultationTimelineState): ConsultationTimelineState {
return {
...state,
rows: state.rows.map((row) => (
row.kind === "think" && row.status === "live"
? { ...row, status: "done" as const, label: doneLabel(row) }
: row
)),
};
}
function doneLabel(row: ConsultationTimelineRow): string {
if (row.kind === "method") return CONSULTATION_DONE_SKILL_LABEL;
if (row.kind === "calculate") return CONSULTATION_DONE_CHART_LABEL;
if (row.kind === "write") {
const heading = row.id.startsWith("write-") ? row.id.slice("write-".length) : "";
return heading ? consultationWriteLabel(heading, false) : row.label.replace(/…$/, "").replace(/^正在/, "");
}
return row.label.replace(/…$/, "").replace(/^正在/, "") || row.label;
}
function upsertWrite(state: ConsultationTimelineState, liveLabel: string): ConsultationTimelineState {
const headings = answerHeadings(state.answer);
const heading = headings.at(-1);
if (!heading) {
return upsertRow(state, {
id: "write-open",
kind: "write",
status: "live",
label: liveLabel || CONSULTATION_COMPOSING_LABEL,
});
}
return syncWriteRows(state, false);
}
function syncWriteRows(state: ConsultationTimelineState, settled: boolean): ConsultationTimelineState {
const headings = answerHeadings(state.answer);
if (headings.length === 0) {
if (!state.answer.trim() && !settled) return state;
if (!state.answer.trim()) return state;
return upsertRow(completeRow(state, "write-open"), {
id: "write-open",
kind: "write",
status: settled ? "done" : "live",
label: settled ? "组织回答" : CONSULTATION_COMPOSING_LABEL,
});
}
let next = completeRow(state, "write-open");
next = {
...next,
rows: next.rows.filter((row) => row.id !== "write-open"),
};
headings.forEach((heading, index) => {
const last = index === headings.length - 1;
next = upsertRow(next, {
id: `write-${heading}`,
kind: "write",
status: settled || !last ? "done" : "live",
label: consultationWriteLabel(heading, !settled && last),
});
});
return next;
}
function enrichCalculate(
state: ConsultationTimelineState,
section: PublicThinkingSection,
): ConsultationTimelineState {
const calculate = state.rows.find((row) => row.id === CALCULATE_ID);
if (!calculate) return state;
const queries = uniqueLabels([...(calculate.queries ?? []), ...queriesFromSection(section)]).slice(0, 8);
const sources = uniqueLabels([...(calculate.sources ?? []), ...sourcesFromSection(section)]).slice(0, 8);
return upsertRow(state, { ...calculate, queries, sources });
}