- {(live || completedSteps.length > 0) && (
+ {(live || completedSteps.length > 0 || showVarga) && (
: null}
diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx
index fec5ae32..03d0a1c2 100644
--- a/frontend/src/components/chat-message-row.tsx
+++ b/frontend/src/components/chat-message-row.tsx
@@ -104,6 +104,7 @@ export function ChatMessageRow({
completedTrail={message.activity?.completedTrail}
thinkingText={hasTrace ? undefined : message.thinkingText}
activityTrace={message.activityTrace}
+ vargaSentence={vargaSentence}
hasAnswer={hasAnswer}
showLive={showLiveActivity}
/>
@@ -114,7 +115,7 @@ export function ChatMessageRow({
)
: null;
@@ -157,7 +158,7 @@ export function ChatMessageRow({
/>
)}
{stackedThinkingAndAnswer ? (
-
+
{thinkingPanel}
{spokenAnswer}
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 7b198f6d..3eb2699f 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -17,6 +17,7 @@ import {
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
RECTIFICATION_TOOL_DONE_LABELS,
RECTIFICATION_TOOL_PROGRESS_LABELS,
+ activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
@@ -45,7 +46,7 @@ import {
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
-import { isNearBottom } from "@/lib/rectification-sticky-scroll";
+import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky-scroll";
import {
CHOICE_ACTION,
STOP_ACTION,
@@ -226,12 +227,14 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
if (failed && !raw) return [];
if (isIncompleteRunBanner(raw)) return [];
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
+ const completedReceipt = completedReceiptFromPersisted(turn.receipt);
return [{
role: "assistant",
text: split.spoken,
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
- completedReceipt: completedReceiptFromPersisted(turn.receipt),
+ completedReceipt,
+ activityTrace: activityTraceFromReceipt(completedReceipt),
failed,
turnId: turn.id,
}];
@@ -291,6 +294,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const followTailRef = useRef(true);
const scrollFrameRef = useRef(null);
const choiceActionIds = useRef(new Map());
+ const choiceCardsOpen = useRef(false);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
@@ -332,7 +336,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!viewport) return;
const nearBottom = isNearBottom(viewport.scrollHeight, viewport.scrollTop, viewport.clientHeight);
followTailRef.current = nearBottom;
- setShowJumpToLatest(!nearBottom);
+ setShowJumpToLatest(shouldShowJumpToLatest(
+ viewport.scrollHeight,
+ viewport.scrollTop,
+ viewport.clientHeight,
+ choiceCardsOpen.current,
+ ));
}, []);
const scrollToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
@@ -974,6 +983,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !readonly
&& regeneratingMessageKey === null,
);
+ choiceCardsOpen.current = showChoiceCards;
+ useLayoutEffect(() => {
+ updateFollowState();
+ }, [showChoiceCards, updateFollowState]);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& offeredSelectionOnce
@@ -1051,7 +1064,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}),
}
: message;
- const vargaSentence = message.state === "settled" && !message.failed
+ const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
return (
diff --git a/frontend/src/lib/rectification-activity-labels.ts b/frontend/src/lib/rectification-activity-labels.ts
index 2e89f12d..277fb2bb 100644
--- a/frontend/src/lib/rectification-activity-labels.ts
+++ b/frontend/src/lib/rectification-activity-labels.ts
@@ -1,5 +1,7 @@
+import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
import { activityCompletedTrail } from "./chat-message-view.ts";
import type { PublicActivityPhase } from "./consultation-agent-events.ts";
+import type { CompletedActivityReceiptView } from "./rectification-activity-receipt.ts";
import type {
PublicRectificationActivity,
PublicRectificationTool,
@@ -62,6 +64,19 @@ export function rectificationCompletedTrail(steps: readonly PublicRectificationT
return activityCompletedTrail(steps.map((tool) => RECTIFICATION_TOOL_DONE_LABELS[tool]));
}
+export function activityTraceFromReceipt(
+ receipt: CompletedActivityReceiptView | null | undefined,
+): readonly AgentActivityTraceItem[] {
+ if (!receipt?.steps.length) return [];
+ return receipt.steps.map((tool) => ({
+ id: `persisted-${tool}`,
+ kind: "activity" as const,
+ status: "done" as const,
+ label: RECTIFICATION_TOOL_DONE_LABELS[tool],
+ tool,
+ }));
+}
+
export function rectificationToolActivityPhase(tool: PublicRectificationTool): PublicActivityPhase {
if (LOAD_TOOLS.has(tool)) return "loading-method";
if (COMPARE_TOOLS.has(tool)) return "chart-calculation";
diff --git a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts
index 8b07d2a0..a3f8ac13 100644
--- a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts
+++ b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts
@@ -106,6 +106,23 @@ export function isPublicRectificationToolName(value: unknown): value is PublicRe
return isPublicRectificationTool(value);
}
+function executedMethodsFromRecord(value: unknown): PublicRectificationMethod[] {
+ if (!value || typeof value !== "object") return [];
+ const record = value as Record;
+ const found: PublicRectificationMethod[] = [];
+ const add = (methods: unknown) => {
+ if (!Array.isArray(methods)) return;
+ for (const method of methods) {
+ if (isPublicRectificationMethod(method) && !found.includes(method)) found.push(method);
+ }
+ };
+ add(record.executed_methods);
+ if (record.rescore && typeof record.rescore === "object") {
+ add((record.rescore as Record).executed_methods);
+ }
+ return found;
+}
+
function resultMethods(chunk: AgentChunkType): PublicRectificationMethod[] {
if (chunk.type !== "tool-result") return [];
const payload = chunk.payload && typeof chunk.payload === "object"
@@ -113,9 +130,8 @@ function resultMethods(chunk: AgentChunkType): PublicRectificationMethod[] {
: {};
const candidates = [payload.result, payload.output, (chunk as unknown as { object?: unknown }).object];
for (const candidate of candidates) {
- if (!candidate || typeof candidate !== "object") continue;
- const methods = (candidate as Record).executed_methods;
- if (Array.isArray(methods)) return [...new Set(methods.filter(isPublicRectificationMethod))];
+ const methods = executedMethodsFromRecord(candidate);
+ if (methods.length > 0) return methods;
}
return [];
}
diff --git a/frontend/src/lib/rectification-sticky-scroll.ts b/frontend/src/lib/rectification-sticky-scroll.ts
index 9bb63373..bd112109 100644
--- a/frontend/src/lib/rectification-sticky-scroll.ts
+++ b/frontend/src/lib/rectification-sticky-scroll.ts
@@ -6,6 +6,8 @@
*/
export const RECTIFICATION_NEAR_BOTTOM_PX = 96;
+/** Keep the jump chip hidden while a choice card still occupies the composer overlay band. */
+export const RECTIFICATION_CHOICE_NEAR_BOTTOM_PX = 360;
export function distanceFromBottom(
scrollHeight: number,
@@ -24,6 +26,18 @@ export function isNearBottom(
return distanceFromBottom(scrollHeight, scrollTop, clientHeight) <= thresholdPx;
}
+export function shouldShowJumpToLatest(
+ scrollHeight: number,
+ scrollTop: number,
+ clientHeight: number,
+ choiceCardOpen = false,
+): boolean {
+ const distance = distanceFromBottom(scrollHeight, scrollTop, clientHeight);
+ if (distance <= RECTIFICATION_NEAR_BOTTOM_PX) return false;
+ if (choiceCardOpen && distance <= RECTIFICATION_CHOICE_NEAR_BOTTOM_PX) return false;
+ return true;
+}
+
export function shouldFollowLatest(followTail: boolean): boolean {
return followTail === true;
}
diff --git a/frontend/tests/agent-activity-progress.test.ts b/frontend/tests/agent-activity-progress.test.ts
index 5fce86de..9523a161 100644
--- a/frontend/tests/agent-activity-progress.test.ts
+++ b/frontend/tests/agent-activity-progress.test.ts
@@ -14,6 +14,7 @@ import {
} from "../src/lib/consultation-activity-labels.ts";
import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
+ activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "../src/lib/rectification-activity-labels.ts";
@@ -96,6 +97,17 @@ test("live rectification labels name the actual public tool", () => {
);
});
+test("persisted receipts rebuild the public activity steps without thinking text", () => {
+ assert.deepEqual(
+ activityTraceFromReceipt({
+ steps: ["rectification-read-case", "rectification-record-evidence-batch"],
+ methods: ["d1-rashi", "d10-dashamsa"],
+ }).map((row) => `${row.kind}:${row.label}`),
+ ["activity:读取校正记录", "activity:整理多条事件证据"],
+ );
+ assert.deepEqual(activityTraceFromReceipt({ steps: [], methods: ["d1-rashi"] }), []);
+});
+
test("multi-domain chart calculation names the current item without domain ids", () => {
assert.equal(chartCalculationProgressLabel(1, 1), CONSULTATION_CHART_CALCULATION_LABEL);
assert.equal(chartCalculationProgressLabel(2, 3), "正在计算本命盘(第 2/3 项)…");
diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts
index 547d5b3c..d1bae1ce 100644
--- a/frontend/tests/chat-stream-layout.test.ts
+++ b/frontend/tests/chat-stream-layout.test.ts
@@ -75,6 +75,10 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(messageRowSource, / {
assert.equal(shouldFollowLatest(true), true);
});
+test("jump-to-latest stays hidden while a choice card still sits in the overlay band", () => {
+ assert.equal(shouldShowJumpToLatest(1_000, 100, 400), true);
+ assert.equal(shouldShowJumpToLatest(1_000, 520, 400), false);
+ assert.equal(shouldShowJumpToLatest(1_000, 500, 400, true), false);
+ assert.equal(shouldShowJumpToLatest(1_000, 200, 400, true), true);
+});
+
test("choice quotes come from the option label, not the assistant question year", () => {
const question = "2016 年前后,有没有明显高考或重要考试发挥失常?";
assert.equal(quoteIsFromAssistantQuestion("2016年", question), true);
diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts
index 470b6ffe..6e060fa8 100644
--- a/frontend/tests/rectification-v9-stream.test.ts
+++ b/frontend/tests/rectification-v9-stream.test.ts
@@ -135,6 +135,27 @@ test("every public rectification tool maps its real lifecycle to public activity
assert.equal(mapStreamChunkToActivity(chunk("text-delta", { text: "x" }) as never), null);
});
+test("evidence-batch rescore methods surface on the public activity event", () => {
+ assert.deepEqual(
+ mapStreamChunkToActivity(chunk("tool-result", {
+ toolName: "rectification-record-evidence-batch",
+ result: {
+ accepted_count: 1,
+ rescore: {
+ status: "completed",
+ executed_methods: ["d1-rashi", "d10-dashamsa", "private-method"],
+ },
+ },
+ }) as never),
+ {
+ type: "tool.activity",
+ tool: "rectification-record-evidence-batch",
+ status: "completed",
+ methods: ["d1-rashi", "d10-dashamsa"],
+ },
+ );
+});
+
test("reasoning, raw payloads, provider metadata and step internals never map to the answer channel", () => {
assert.equal(mapStreamChunkToPhase(chunk("reasoning-start", { id: "r1" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "内部推理" }) as never), null);