feat(rectification): add agentic birth-time rectification MVP
Add a new agentic rectification flow that lets an LLM drive the full jyotish-vedic-astrology methodology on the web, with the Python engine as its computation layer (mirroring local Claude Code): - mastra/rectification-tools.ts: 7 engine tools (gate/scan/score/diagnostics/ candidate-features/confirm/save-birth-time) - mastra/agentic-rectification.ts: agent mounting the full skill + tools - lib/rectification-agentic/session.ts: server-owned profile + confirmation gate; the LLM can only persist the exact minute the engine's high-rigor gate confirmed - app/api/rectification/agent/route.ts: NDJSON streaming endpoint with credit reserve/settle - components/rectification-agentic-chat.tsx + entry switch: new sessions use the agentic chat; in-progress v4 cases still resume on the v4 panel - migration 20260801000000: service-role RPC writing profiles.active_birth_time with baseline concurrency guard - tests for tools + session (12 cases); full suite passes 1076 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadActiveRectificationV4 } from "../lib/rectification-v4/client.ts";
|
||||
import type { PublicLanguageModel } from "../lib/public-models.ts";
|
||||
import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx";
|
||||
import { ChatMessageRow } from "./chat-message-row.tsx";
|
||||
import {
|
||||
RectificationV4Panel,
|
||||
type RectificationV4Continuation,
|
||||
@@ -14,8 +18,56 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
continuationPending?: boolean;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
|
||||
onSaved?: (time: string) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Birth-time rectification surface.
|
||||
*
|
||||
* Resumes an existing v4 evidence case when one is still in progress (so users
|
||||
* never lose a saved candidate range), and otherwise opens the agentic chat
|
||||
* where the LLM drives the full Jyotish rectification methodology with the
|
||||
* engine as its computation layer.
|
||||
*/
|
||||
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
|
||||
return <RectificationV4Panel {...props} />;
|
||||
const [mode, setMode] = useState<"loading" | "v4" | "agentic">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void (async () => {
|
||||
const existing = await loadActiveRectificationV4().catch(() => null);
|
||||
if (mounted) setMode(existing ? "v4" : "agentic");
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (mode === "loading") {
|
||||
return (
|
||||
<section className="conversation" aria-label="生时校正对话" aria-busy>
|
||||
<div className="message-list" aria-live="polite">
|
||||
<ChatLoadingRow />
|
||||
<div />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "v4") {
|
||||
return <RectificationV4Panel {...props} />;
|
||||
}
|
||||
|
||||
return <AgenticRectificationChat {...props} />;
|
||||
}
|
||||
|
||||
function ChatLoadingRow() {
|
||||
return (
|
||||
<ChatMessageRow
|
||||
message={{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
renderKey: "agentic-loading",
|
||||
state: "thinking",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
type AgenticRectificationChatProps = Readonly<{
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onSaved?: (time: string) => void;
|
||||
}>;
|
||||
|
||||
type RenderMessage = ChatMessageView;
|
||||
|
||||
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
|
||||
|
||||
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
const pendingQuestion = props.pendingConsultationQuestion?.trim();
|
||||
const [messages, setMessages] = useState<RenderMessage[]>(() => pendingQuestion ? [{
|
||||
role: "assistant",
|
||||
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingQuestion}”`,
|
||||
renderKey: "agentic-pending-consultation",
|
||||
state: "settled",
|
||||
}] : []);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [savedTime, setSavedTime] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
const keyCounter = useRef(0);
|
||||
|
||||
const setPending = (value: boolean) => {
|
||||
setBusy(value);
|
||||
props.onPendingChange?.(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
conversationEnd.current?.scrollIntoView({
|
||||
behavior: busy || reduceMotion ? "auto" : "smooth",
|
||||
block: "end",
|
||||
});
|
||||
}, [busy, error, messages.length, savedTime]);
|
||||
|
||||
async function send(question: string) {
|
||||
const trimmed = question.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setError("");
|
||||
setSavedTime(null);
|
||||
setSuggestions([]);
|
||||
setPending(true);
|
||||
|
||||
keyCounter.current += 1;
|
||||
const requestId = globalThis.crypto.randomUUID();
|
||||
const history = messages
|
||||
.filter((message) => message.state === "settled")
|
||||
.map((message) => ({ role: message.role, text: message.text }));
|
||||
const turnKey = keyCounter.current;
|
||||
const userRenderKey = `agentic-user-${turnKey}`;
|
||||
const assistantRenderKey = `agentic-assistant-${turnKey}`;
|
||||
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" },
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
|
||||
]);
|
||||
setDraft("");
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
const response = await fetch("/api/rectification/agent", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ requestId, modelId: props.selectedModelId, history, message: trimmed }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
const message = payload?.message || payload?.error || `请求失败(${response.status})`;
|
||||
if (response.status === 402) setError(`咨询点数不足:${message}`);
|
||||
else if (response.status === 401) setError("请先登录。");
|
||||
else setError(message);
|
||||
return;
|
||||
}
|
||||
if (!response.body) {
|
||||
setError("服务暂时不可用,请稍后再试。");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let event: { type: string; text?: string; message?: string };
|
||||
try {
|
||||
event = JSON.parse(line) as { type: string; text?: string; message?: string };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "delta" && typeof event.text === "string") {
|
||||
raw += event.text;
|
||||
const parsed = parseAgentReply(raw, "general");
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: parsed.text, state: "streaming" }
|
||||
: message));
|
||||
setSuggestions(parsed.suggestions);
|
||||
const saved = raw.match(savedSentinel);
|
||||
if (saved) setSavedTime(saved[1]);
|
||||
} else if (event.type === "error") {
|
||||
setError(event.message || "生时校正暂时不可用,请稍后再试。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseAgentReply(raw, "general");
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: parsed.text, state: "settled" }
|
||||
: message));
|
||||
setSuggestions(parsed.suggestions);
|
||||
const saved = raw.match(savedSentinel);
|
||||
if (saved) {
|
||||
setSavedTime(saved[1]);
|
||||
props.onSaved?.(saved[1]);
|
||||
}
|
||||
} catch {
|
||||
setError("生时校正暂时不可用,请稍后再试。");
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
await send(draft);
|
||||
}
|
||||
|
||||
const canSend = !busy;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="conversation" aria-label="生时校正对话" aria-busy={busy}>
|
||||
<div className="message-list" aria-live="polite">
|
||||
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
|
||||
{savedTime && (
|
||||
<p className="error-message" role="status">
|
||||
出生时间已更新为 {savedTime},后续排盘将使用该时间。
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="error-message" role="alert">{error}</p>}
|
||||
<div ref={conversationEnd} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="composer-wrap">
|
||||
{suggestions.length > 0 && !busy && (
|
||||
<div className="composer-suggestions" aria-label="推荐继续提问">
|
||||
{suggestions.map((question) => (
|
||||
<button key={question} type="button" onClick={() => void send(question)}>{question}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="composer" onSubmit={submit}>
|
||||
<Textarea
|
||||
ref={composer}
|
||||
aria-label="继续描述你的经历或回答"
|
||||
value={draft}
|
||||
disabled={!canSend}
|
||||
placeholder="继续说你记得的人生经历,或回答刚才的问题…"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="composer-footer">
|
||||
<ModelSelector
|
||||
models={props.models}
|
||||
selectedModelId={props.selectedModelId}
|
||||
disabled={busy}
|
||||
onSelect={props.onSelectModel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user