fix: restore rectification agent message actions
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user