fix: restore rectification agent message actions

This commit is contained in:
Jesse_Chen
2026-07-30 00:41:48 +08:00
parent a5f409a500
commit 0a5b8cd239
15 changed files with 513 additions and 4 deletions
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts";
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
const body = await requestBody(request, caseActionRequestSchema);
const context = await rectificationV4Context();
const result = await context.service.regenerateQuestion({
...body,
userId: context.userId,
caseId: routeId((await params).caseId),
});
return result
? NextResponse.json(result)
: NextResponse.json({ error: "当前问题不能重新生成,请刷新后重试。" }, { status: 409 });
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -1,6 +1,6 @@
"use client";
import { ArrowUp } from "lucide-react";
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useRectificationV4 } from "@/hooks/use-rectification-v4";
import type { ChatMessageView } from "@/lib/chat-message-view";
@@ -29,6 +29,28 @@ type RectificationV4PanelProps = Readonly<{
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>;
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
): "up" | "down" | undefined {
return current === requested ? undefined : requested;
}
export function canRegenerateRectificationMessage(input: Readonly<{
message: ChatMessageView;
currentMessageKey: string | null;
deploymentMode: RectificationV4ApiResponse["case"]["deploymentMode"] | null;
busy: boolean;
canAnswer: boolean;
}>): boolean {
return input.deploymentMode === "v5_agent"
&& input.message.role === "assistant"
&& input.message.state === "settled"
&& input.message.renderKey === input.currentMessageKey
&& !input.busy
&& input.canAnswer;
}
export function rectificationV4ChatMessages(
data: RectificationV4ApiResponse | null,
processing: boolean,
@@ -129,6 +151,9 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
onPendingChange: props.onPendingChange,
});
const [draft, setDraft] = useState("");
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
const caseValue = controller.data?.case;
@@ -145,6 +170,10 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
&& !processing
&& !controller.pending
&& ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? "");
const currentMessageKey = caseValue?.currentQuestion
? `rectification-current-${caseValue.currentQuestion.id}`
: null;
const busy = processing || controller.pending || regeneratingMessageKey !== null;
const canAcceptRange = caseValue?.status === "range_ready"
&& Boolean(caseValue.latestSnapshot?.canAcceptRange)
&& !caseValue.acceptedRange;
@@ -174,6 +203,27 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
if (result) setDraft("");
}
async function copyMessage(message: ChatMessageView) {
try {
await navigator.clipboard.writeText(message.text);
setCopiedMessageKey(message.renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === message.renderKey ? null : current
)), 1_500);
} catch {
// Clipboard permission failures must not interrupt the conversation.
}
}
async function regenerateMessage(messageKey: string) {
setRegeneratingMessageKey(messageKey);
try {
await controller.regenerate();
} finally {
setRegeneratingMessageKey((current) => current === messageKey ? null : current);
}
}
function continueOriginalQuestion() {
if (!caseValue?.acceptedRange || !handoff) return;
props.onContinueOriginalQuestion?.({
@@ -189,7 +239,71 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
<>
<section className="conversation" aria-label="生时校正对话" aria-busy={processing || controller.pending}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{messages.map((message) => {
const showActions = caseValue?.deploymentMode === "v5_agent"
&& message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = canRegenerateRectificationMessage({
message,
currentMessageKey,
deploymentMode: caseValue?.deploymentMode ?? null,
busy,
canAnswer,
});
return (
<div className="rectification-message-entry" key={message.renderKey}>
<ChatMessageRow message={regenerating
? { ...message, text: "", state: "thinking" }
: message} />
{showActions && !regenerating && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback[message.renderKey] === "up"}
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "up"),
}))}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback[message.renderKey] === "down"}
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "down"),
}))}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
{copiedMessageKey === message.renderKey
? <Check aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
<button
aria-label="重新生成回答"
disabled={!canRegenerate}
title={canRegenerate ? "重新生成" : "只能重新生成当前问题"}
type="button"
onClick={() => void regenerateMessage(message.renderKey)}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
)}
</div>
);
})}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
<div ref={conversationEnd} />
</div>
@@ -16,6 +16,7 @@ import {
loadRectificationV4,
loadRectificationV4Handoff,
loadRectificationV4Job,
regenerateRectificationV4Question,
transitionRectificationV4,
} from "@/lib/rectification-v4/client";
@@ -136,6 +137,9 @@ export function useRectificationV4(input: {
answer: (answer: string, modelId?: string | null) => data
? mutate(() => answerRectificationV4(data.case.id, data.case.version, answer, modelId))
: Promise.resolve(null),
regenerate: () => data
? mutate(() => regenerateRectificationV4Question(data.case.id, data.case.version))
: Promise.resolve(null),
pause: () => data
? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "pause"))
: Promise.resolve(null),
@@ -1,5 +1,6 @@
import path from "node:path";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts";
import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts";
@@ -14,6 +15,7 @@ const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便
const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']??请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对)/;
const exactClockMinute = /(?:[01]?\d|2[0-3])[:][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/;
const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/;
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel();
@@ -199,3 +201,42 @@ export async function renderPublicTurn(input: Readonly<{
return fallback;
}
}
export async function regenerateQuestionRealization(input: Readonly<{
caseValue: RectificationV4Case;
currentPrompt: string;
latestAnswer: string;
acceptedEvents: readonly LifeEventRevision[];
opportunity: QuestionOpportunity;
timeoutMs?: number;
}>): Promise<string> {
const selected = agentFor(input.caseValue.narrationModelId);
if (!selected) return input.currentPrompt;
try {
const result = await selected.agent.generate(JSON.stringify({
task: "Rewrite the current question naturally without changing its semantic target. Return one question only.",
currentPrompt: input.currentPrompt,
latestAnswer: input.latestAnswer,
recentEvents: input.acceptedEvents.slice(-5).map((event) => ({
summary: event.summary,
date: event.dateRange.label,
subject: event.subject,
})),
selectedOpportunity: {
kind: input.opportunity.kind,
goal: input.opportunity.goal,
requestedFields: input.opportunity.requestedFields,
anchors: input.opportunity.anchors,
contextFacts: input.opportunity.contextFacts,
forbiddenMoves: input.opportunity.forbiddenMoves,
},
}), {
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000),
structuredOutput: { schema: questionRealizationSchema, jsonPromptInjection: "inline" },
});
const question = questionRealizationSchema.parse(result.object).question;
return validateQuestionRealization(question, input.opportunity).valid ? question : input.currentPrompt;
} catch {
return input.currentPrompt;
}
}
@@ -8,6 +8,7 @@ import type {
import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts";
import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts";
import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts";
import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts";
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
import { openingQuestion } from "./opening-question.ts";
import type { RectificationV4Store } from "./store.ts";
@@ -88,6 +89,40 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
return response(input.userId, saved.case, saved.job.id);
},
async regenerateQuestion(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
}) {
const current = await store.loadCase(input.userId, input.caseId);
if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null;
const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId);
const opportunity = validated?.selectedOpportunity;
if (!opportunity) return null;
const [events, turns] = await Promise.all([
store.loadEvents(input.userId, input.caseId),
store.loadTurns(input.userId, input.caseId),
]);
const prompt = await regenerateQuestionRealization({
caseValue: current,
currentPrompt: current.currentQuestion.prompt,
latestAnswer: turns.at(-1)?.answer ?? "",
acceptedEvents: events,
opportunity,
});
const nextQuestion = {
...current.currentQuestion,
id: randomUUID(),
prompt,
};
return response(input.userId, await store.replaceCurrentQuestion({
...input,
question: nextQuestion,
now: now().toISOString(),
}));
},
async reviseEvent(input: {
readonly userId: string;
readonly caseId: string;
@@ -63,6 +63,12 @@ export function answerRectificationV4(caseId: string, expectedCaseVersion: numbe
});
}
export function regenerateRectificationV4Question(caseId: string, expectedCaseVersion: number) {
return post(`/api/rectification/v4/cases/${caseId}/regenerate`, {
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion,
});
}
export function transitionRectificationV4(
caseId: string,
expectedCaseVersion: number,
@@ -69,6 +69,14 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
.filter((turn) => turn.caseId === caseId)
.sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt));
},
async loadLatestValidatedDecision(userId, caseId) {
const caseValue = cases.get(caseId);
if (!caseValue || caseValue.userId !== userId) return null;
const latest = [...agentRuns.values()]
.filter((run) => run.caseId === caseId)
.sort((left, right) => right.caseVersion - left.caseVersion || right.createdAt.localeCompare(left.createdAt))[0];
return latest ? validatedDecisions.get(latest.jobId) ?? latest.validatedDecision : null;
},
async createCase(input) {
const replay = actionResults.get(`${input.case.userId}:${input.actionId}`);
if (replay) return owned(input.case.userId, replay.caseId);
@@ -91,6 +99,29 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: input.case.id, jobId: null });
return input.case;
},
async replaceCurrentQuestion(input) {
const key = `${input.userId}:${input.actionId}`;
const replay = actionResults.get(key);
if (replay) return owned(input.userId, replay.caseId);
const current = owned(input.userId, input.caseId);
if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
if (current.deploymentMode !== "v5_agent"
|| !["awaiting_answer", "range_ready"].includes(current.status)
|| !current.currentQuestion) throw new RectificationV4StoreError("invalid_state");
const updated: RectificationV4Case = {
...current,
version: current.version + 1,
currentQuestion: {
...input.question,
domain: current.currentQuestion.domain,
targetEventId: current.currentQuestion.targetEventId,
},
updatedAt: input.now,
};
cases.set(current.id, updated);
actionResults.set(key, { caseId: current.id, jobId: null });
return updated;
},
async submitAnswer(input) {
const key = `${input.userId}:${input.actionId}`;
const replay = actionResults.get(key);
@@ -45,7 +45,16 @@ export interface RectificationV4Store {
loadCase(userId: string, caseId: string): Promise<RectificationV4Case | null>;
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
loadTurns(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]>;
loadLatestValidatedDecision(userId: string, caseId: string): Promise<ValidatedDecision | null>;
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
replaceCurrentQuestion(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
readonly question: RectificationV4Question;
readonly now: string;
}): Promise<RectificationV4Case>;
submitAnswer(input: {
readonly userId: string;
readonly caseId: string;
@@ -1,4 +1,5 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { validatedDecisionSchema, type ValidatedDecision } from "../rectification-agent/contracts.ts";
import {
candidateSnapshotSchema,
lifeEventRevisionSchema,
@@ -199,6 +200,14 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
loadCase: loadCaseById,
loadEvents: loadEventsByCase,
loadTurns: loadTurnsByCase,
async loadLatestValidatedDecision(userId, caseId): Promise<ValidatedDecision | null> {
const { data, error } = await supabase.from("birth_time_rectification_agent_runs")
.select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId)
.order("case_version", { ascending: false }).order("created_at", { ascending: false })
.limit(1).maybeSingle();
if (error) throw storeError(error);
return data ? validatedDecisionSchema.parse((data as Row).validated_decision_json) : null;
},
async createCase(input) {
const id = String(await rpc("create_birth_time_rectification_v5_case", {
p_user_id: input.case.userId,
@@ -222,6 +231,19 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
if (!value) throw new RectificationV4StoreError("not_found");
return value;
},
async replaceCurrentQuestion(input) {
const id = String(await rpc("replace_birth_time_rectification_v4_current_question", {
p_user_id: input.userId,
p_case_id: input.caseId,
p_action_id: input.actionId,
p_expected_version: input.expectedCaseVersion,
p_question: input.question,
p_now: input.now,
}));
const value = await loadCaseById(input.userId, id);
if (!value) throw new RectificationV4StoreError("not_found");
return value;
},
async submitAnswer(input) {
const jobId = String(await rpc("submit_birth_time_rectification_v4_answer", {
p_user_id: input.userId,