From dba0245cb1f50a6b0ee770ddb6afcc2fb3e88238 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 10 Jun 2026 22:39:09 +0800 Subject: [PATCH] Replace RSC streaming with /api/ai Route Handler for Docker reliability. Server Actions + createStreamableValue kept failing in production; fetch-based text stream avoids RSC serialization issues and shows readable error messages. Co-authored-by: Cursor --- app/actions/bazi.ts | 34 ------- app/actions/combined.ts | 88 ----------------- app/actions/liuyao.ts | 70 -------------- app/api/ai/route.ts | 44 +++++++++ app/server.ts | 28 ------ components/modes/bazi-form.tsx | 27 +++--- components/modes/combined-form.tsx | 64 ++++++------- components/modes/liuyao-form.tsx | 42 ++++----- lib/ai/build-messages.ts | 145 +++++++++++++++++++++++++++++ lib/ai/client-stream.ts | 34 +++++++ lib/ai/config.ts | 21 +++++ lib/ai/stream.ts | 89 ------------------ lib/ai/types.ts | 40 ++++++++ 13 files changed, 342 insertions(+), 384 deletions(-) delete mode 100644 app/actions/bazi.ts delete mode 100644 app/actions/combined.ts delete mode 100644 app/actions/liuyao.ts create mode 100644 app/api/ai/route.ts delete mode 100644 app/server.ts create mode 100644 lib/ai/build-messages.ts create mode 100644 lib/ai/client-stream.ts create mode 100644 lib/ai/config.ts delete mode 100644 lib/ai/stream.ts create mode 100644 lib/ai/types.ts diff --git a/app/actions/bazi.ts b/app/actions/bazi.ts deleted file mode 100644 index af7cc85..0000000 --- a/app/actions/bazi.ts +++ /dev/null @@ -1,34 +0,0 @@ -"use server"; - -import { createStreamableValue } from "ai/rsc"; -import { pumpAIStream } from "@/lib/ai/stream"; -import { - calculateBazi, - formatBaziForPrompt, - type BaziInput, -} from "@/lib/calc/bazi"; -import { BAZI_SYSTEM_PROMPT } from "@/lib/prompts"; - -export async function getBaziAnswer( - input: BaziInput, - question: string, - birthPlaceName: string, -) { - const chart = calculateBazi(input); - const chartText = formatBaziForPrompt(chart); - - const stream = createStreamableValue(); - pumpAIStream( - stream, - BAZI_SYSTEM_PROMPT, - `【出生时空】 -出生地:${birthPlaceName} - -【排盘信息】 -${chartText} - -【问事】 -${question}`, - ); - return stream.value; -} diff --git a/app/actions/combined.ts b/app/actions/combined.ts deleted file mode 100644 index 426629a..0000000 --- a/app/actions/combined.ts +++ /dev/null @@ -1,88 +0,0 @@ -"use server"; - -import { createStreamableValue } from "ai/rsc"; -import { pumpAIStream } from "@/lib/ai/stream"; -import { - calculateBazi, - formatBaziForPrompt, - type BaziInput, -} from "@/lib/calc/bazi"; -import { - formatTimingForPrompt, - getTimingInfoWithLongitude, -} from "@/lib/calc/timing"; -import { COMBINED_SYSTEM_PROMPT } from "@/lib/prompts"; -import { - extractChangeDetails, - extractZhangMingRen, - readGuaMarkdown, -} from "@/lib/content/zhouyi"; - -export interface CombinedInput { - birth: BaziInput; - birthPlaceName: string; - currentPlaceName: string; - currentLongitude: number; - calcDate: string; - calcTime: string; - question: string; - hexagram?: { - guaMark: string; - guaTitle: string; - guaResult: string; - guaChange: string; - }; -} - -export async function getCombinedAnswer(input: CombinedInput) { - const chart = calculateBazi(input.birth); - const chartText = formatBaziForPrompt(chart); - const { timing, trueSolarTime } = getTimingInfoWithLongitude( - input.calcDate, - input.calcTime, - input.currentLongitude, - ); - const timingText = [ - formatTimingForPrompt(timing, input.currentPlaceName, input.currentLongitude), - `真太阳时:${trueSolarTime}`, - ].join("\n"); - - let hexagramText = ""; - if (input.hexagram) { - const { guaMark, guaTitle, guaResult, guaChange } = input.hexagram; - try { - const guaDetail = await readGuaMarkdown(guaMark); - const explain = extractZhangMingRen(guaDetail) ?? ""; - const changeList = extractChangeDetails(guaDetail, guaChange, guaTitle); - hexagramText = [ - "", - "【卦象 · 可选六爻】", - `${guaTitle} ${guaResult} ${guaChange}`, - explain, - changeList.join("\n"), - ].join("\n"); - } catch { - hexagramText = [ - "", - "【卦象 · 可选六爻】", - `${input.hexagram.guaTitle} ${input.hexagram.guaResult} ${input.hexagram.guaChange}`, - ].join("\n"); - } - } - - const stream = createStreamableValue(); - pumpAIStream( - stream, - COMBINED_SYSTEM_PROMPT, - `【人和 · 八字排盘】 -出生地:${input.birthPlaceName} -${chartText} - -${timingText} -${hexagramText} - -【问事】 -${input.question}`, - ); - return stream.value; -} diff --git a/app/actions/liuyao.ts b/app/actions/liuyao.ts deleted file mode 100644 index 78af2c9..0000000 --- a/app/actions/liuyao.ts +++ /dev/null @@ -1,70 +0,0 @@ -"use server"; - -import { createStreamableValue } from "ai/rsc"; -import { pumpAIStream } from "@/lib/ai/stream"; -import { - formatLiuyaoTimingForPrompt, - getTimingInfoWithLongitude, -} from "@/lib/calc/timing"; -import { - extractChangeDetails, - extractZhangMingRen, - readGuaMarkdown, -} from "@/lib/content/zhouyi"; -import { LIUYAO_SYSTEM_PROMPT } from "@/lib/prompts"; - -export interface LiuyaoInput { - question: string; - calcDate: string; - calcTime: string; - locationName: string; - longitude: number; - guaMark: string; - guaTitle: string; - guaResult: string; - guaChange: string; -} - -export async function getLiuyaoAnswer(input: LiuyaoInput) { - const { timing, trueSolarTime } = getTimingInfoWithLongitude( - input.calcDate, - input.calcTime, - input.longitude, - ); - const timingText = formatLiuyaoTimingForPrompt( - timing, - trueSolarTime, - input.locationName, - input.longitude, - ); - - let guaDetailText = ""; - try { - const guaDetail = await readGuaMarkdown(input.guaMark); - const explain = extractZhangMingRen(guaDetail) ?? ""; - const changeList = extractChangeDetails( - guaDetail, - input.guaChange, - input.guaTitle, - ); - guaDetailText = [explain, changeList.join("\n")].filter(Boolean).join("\n"); - } catch { - guaDetailText = ""; - } - - const stream = createStreamableValue(); - pumpAIStream( - stream, - LIUYAO_SYSTEM_PROMPT, - `${timingText} - -【卦象】 -${input.guaTitle} ${input.guaResult} ${input.guaChange} - -【问事】 -${input.question} - -${guaDetailText}`, - ); - return stream.value; -} diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts new file mode 100644 index 0000000..01c5bb5 --- /dev/null +++ b/app/api/ai/route.ts @@ -0,0 +1,44 @@ +import { streamText } from "ai"; +import { createOpenAI } from "@ai-sdk/openai"; +import { buildAiMessages } from "@/lib/ai/build-messages"; +import { + getOpenAiApiKey, + getOpenAiBaseUrl, + getOpenAiModel, +} from "@/lib/ai/config"; +import type { AiRequestBody } from "@/lib/ai/types"; + +export const runtime = "nodejs"; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as AiRequestBody; + if (!body?.mode || !body.payload) { + return new Response("请求格式错误", { status: 400 }); + } + + const { system, user } = await buildAiMessages(body); + const openai = createOpenAI({ + apiKey: getOpenAiApiKey(), + baseURL: getOpenAiBaseUrl(), + }); + + const result = streamText({ + temperature: 0.5, + model: openai(getOpenAiModel()), + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + maxRetries: 0, + }); + + return result.toTextStreamResponse(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return new Response(message, { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } +} diff --git a/app/server.ts b/app/server.ts deleted file mode 100644 index 89c01c7..0000000 --- a/app/server.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; - -import { getLiuyaoAnswer } from "@/app/actions/liuyao"; - -/** 兼容旧版 divination 组件签名 */ -export async function getAnswer( - prompt: string, - guaMark: string, - guaTitle: string, - guaResult: string, - guaChange: string, -) { - const now = new Date(); - const calcDate = now.toISOString().slice(0, 10); - const calcTime = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`; - - return getLiuyaoAnswer({ - question: prompt, - calcDate, - calcTime, - locationName: "未指定", - longitude: 120, - guaMark, - guaTitle, - guaResult, - guaChange, - }); -} diff --git a/components/modes/bazi-form.tsx b/components/modes/bazi-form.tsx index 7deb61d..f0f8d8e 100644 --- a/components/modes/bazi-form.tsx +++ b/components/modes/bazi-form.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { readStreamableValue } from "ai/rsc"; import { BrainCircuit } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -12,8 +11,7 @@ import RegionSelect, { useRegionLocation, } from "@/components/shared/region-select"; import { calculateBazi, type BaziChart } from "@/lib/calc/bazi"; -import { getBaziAnswer } from "@/app/actions/bazi"; -import { ERROR_PREFIX } from "@/lib/constant"; +import { streamAiCompletion } from "@/lib/ai/client-stream"; export default function BaziForm() { const [date, setDate] = useState(nowDateString()); @@ -70,20 +68,17 @@ export default function BaziForm() { setIsLoading(true); setShowAi(true); try { - const stream = await getBaziAnswer( - input, - question, - location!.name, + await streamAiCompletion( + { + mode: "bazi", + payload: { + input, + question, + birthPlaceName: location!.name, + }, + }, + setCompletion, ); - let ret = ""; - for await (const delta of readStreamableValue(stream)) { - if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) { - setError(delta.slice(ERROR_PREFIX.length)); - return; - } - ret += delta ?? ""; - setCompletion(ret); - } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/components/modes/combined-form.tsx b/components/modes/combined-form.tsx index 9c3f8ed..df83575 100644 --- a/components/modes/combined-form.tsx +++ b/components/modes/combined-form.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { readStreamableValue } from "ai/rsc"; import { Compass } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -18,8 +17,7 @@ import RegionSelect, { } from "@/components/shared/region-select"; import { calculateBazi, type BaziChart } from "@/lib/calc/bazi"; import type { GuaResult } from "@/lib/calc/hexagram"; -import { getCombinedAnswer } from "@/app/actions/combined"; -import { ERROR_PREFIX } from "@/lib/constant"; +import { streamAiCompletion } from "@/lib/ai/client-stream"; export default function CombinedForm() { const [birthDate, setBirthDate] = useState("1990-01-01"); @@ -109,39 +107,35 @@ export default function CombinedForm() { setShowAi(true); try { - const stream = await getCombinedAnswer({ - birth: { - date: birthDate, - time: unknownHour ? "12:00" : birthTime, - gender, - longitude: birthLocation!.longitude, - unknownHour, + await streamAiCompletion( + { + mode: "combined", + payload: { + birth: { + date: birthDate, + time: unknownHour ? "12:00" : birthTime, + gender, + longitude: birthLocation!.longitude, + unknownHour, + }, + birthPlaceName: birthLocation!.name, + currentPlaceName: currentLocation!.name, + currentLongitude: currentLocation!.longitude, + calcDate, + calcTime, + question, + hexagram: withHexagram && guaData + ? { + guaMark: guaData.result.guaMark, + guaTitle: guaData.result.guaTitle, + guaResult: guaData.result.guaResult, + guaChange: guaData.result.guaChange, + } + : undefined, + }, }, - birthPlaceName: birthLocation!.name, - currentPlaceName: currentLocation!.name, - currentLongitude: currentLocation!.longitude, - calcDate, - calcTime, - question, - hexagram: withHexagram && guaData - ? { - guaMark: guaData.result.guaMark, - guaTitle: guaData.result.guaTitle, - guaResult: guaData.result.guaResult, - guaChange: guaData.result.guaChange, - } - : undefined, - }); - - let ret = ""; - for await (const delta of readStreamableValue(stream)) { - if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) { - setError(delta.slice(ERROR_PREFIX.length)); - return; - } - ret += delta ?? ""; - setCompletion(ret); - } + setCompletion, + ); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { diff --git a/components/modes/liuyao-form.tsx b/components/modes/liuyao-form.tsx index 42f54c3..37297f2 100644 --- a/components/modes/liuyao-form.tsx +++ b/components/modes/liuyao-form.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { readStreamableValue } from "ai/rsc"; import { BrainCircuit, ListRestart } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -15,9 +14,8 @@ import HexagramInput from "@/components/shared/hexagram-input"; import RegionSelect, { useRegionLocation, } from "@/components/shared/region-select"; -import { getLiuyaoAnswer } from "@/app/actions/liuyao"; +import { streamAiCompletion } from "@/lib/ai/client-stream"; import type { GuaResult } from "@/lib/calc/hexagram"; -import { ERROR_PREFIX } from "@/lib/constant"; import todayJson from "@/lib/data/today.json"; const todayData: string[] = todayJson; @@ -72,27 +70,23 @@ export default function LiuyaoForm() { setShowAi(true); try { - const stream = await getLiuyaoAnswer({ - question, - calcDate, - calcTime, - locationName: location!.name, - longitude: location!.longitude, - guaMark: guaData!.result.guaMark, - guaTitle: guaData!.result.guaTitle, - guaResult: guaData!.result.guaResult, - guaChange: guaData!.result.guaChange, - }); - - let ret = ""; - for await (const delta of readStreamableValue(stream)) { - if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) { - setError(delta.slice(ERROR_PREFIX.length)); - return; - } - ret += delta ?? ""; - setCompletion(ret); - } + await streamAiCompletion( + { + mode: "liuyao", + payload: { + question, + calcDate, + calcTime, + locationName: location!.name, + longitude: location!.longitude, + guaMark: guaData!.result.guaMark, + guaTitle: guaData!.result.guaTitle, + guaResult: guaData!.result.guaResult, + guaChange: guaData!.result.guaChange, + }, + }, + setCompletion, + ); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { diff --git a/lib/ai/build-messages.ts b/lib/ai/build-messages.ts new file mode 100644 index 0000000..5480835 --- /dev/null +++ b/lib/ai/build-messages.ts @@ -0,0 +1,145 @@ +import { + calculateBazi, + formatBaziForPrompt, +} from "@/lib/calc/bazi"; +import { + formatLiuyaoTimingForPrompt, + formatTimingForPrompt, + getTimingInfoWithLongitude, +} from "@/lib/calc/timing"; +import { + extractChangeDetails, + extractZhangMingRen, + readGuaMarkdown, +} from "@/lib/content/zhouyi"; +import { + BAZI_SYSTEM_PROMPT, + COMBINED_SYSTEM_PROMPT, + LIUYAO_SYSTEM_PROMPT, +} from "@/lib/prompts"; +import type { AiRequestBody } from "@/lib/ai/types"; + +export async function buildAiMessages( + body: AiRequestBody, +): Promise<{ system: string; user: string }> { + switch (body.mode) { + case "liuyao": { + const input = body.payload; + const { timing, trueSolarTime } = getTimingInfoWithLongitude( + input.calcDate, + input.calcTime, + input.longitude, + ); + const timingText = formatLiuyaoTimingForPrompt( + timing, + trueSolarTime, + input.locationName, + input.longitude, + ); + + let guaDetailText = ""; + try { + const guaDetail = await readGuaMarkdown(input.guaMark); + const explain = extractZhangMingRen(guaDetail) ?? ""; + const changeList = extractChangeDetails( + guaDetail, + input.guaChange, + input.guaTitle, + ); + guaDetailText = [explain, changeList.join("\n")] + .filter(Boolean) + .join("\n"); + } catch { + guaDetailText = ""; + } + + return { + system: LIUYAO_SYSTEM_PROMPT, + user: `${timingText} + +【卦象】 +${input.guaTitle} ${input.guaResult} ${input.guaChange} + +【问事】 +${input.question} + +${guaDetailText}`, + }; + } + case "bazi": { + const { input, question, birthPlaceName } = body.payload; + const chart = calculateBazi(input); + const chartText = formatBaziForPrompt(chart); + return { + system: BAZI_SYSTEM_PROMPT, + user: `【出生时空】 +出生地:${birthPlaceName} + +【排盘信息】 +${chartText} + +【问事】 +${question}`, + }; + } + case "combined": { + const input = body.payload; + const chart = calculateBazi(input.birth); + const chartText = formatBaziForPrompt(chart); + const { timing, trueSolarTime } = getTimingInfoWithLongitude( + input.calcDate, + input.calcTime, + input.currentLongitude, + ); + const timingText = [ + formatTimingForPrompt( + timing, + input.currentPlaceName, + input.currentLongitude, + ), + `真太阳时:${trueSolarTime}`, + ].join("\n"); + + let hexagramText = ""; + if (input.hexagram) { + const { guaMark, guaTitle, guaResult, guaChange } = input.hexagram; + try { + const guaDetail = await readGuaMarkdown(guaMark); + const explain = extractZhangMingRen(guaDetail) ?? ""; + const changeList = extractChangeDetails( + guaDetail, + guaChange, + guaTitle, + ); + hexagramText = [ + "", + "【卦象 · 可选六爻】", + `${guaTitle} ${guaResult} ${guaChange}`, + explain, + changeList.join("\n"), + ].join("\n"); + } catch { + hexagramText = [ + "", + "【卦象 · 可选六爻】", + `${input.hexagram.guaTitle} ${input.hexagram.guaResult} ${input.hexagram.guaChange}`, + ].join("\n"); + } + } + + return { + system: COMBINED_SYSTEM_PROMPT, + user: `【人和 · 八字排盘】 +出生地:${input.birthPlaceName} +${chartText} + +${timingText} +${hexagramText} + +【问事】 +${input.question}`, + }; + } + } + throw new Error("未知 AI 模式"); +} diff --git a/lib/ai/client-stream.ts b/lib/ai/client-stream.ts new file mode 100644 index 0000000..0d024b0 --- /dev/null +++ b/lib/ai/client-stream.ts @@ -0,0 +1,34 @@ +import type { AiRequestBody } from "@/lib/ai/types"; + +export async function streamAiCompletion( + body: AiRequestBody, + onUpdate: (text: string) => void, +): Promise { + const res = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const message = (await res.text()).trim(); + throw new Error(message || `AI 请求失败 (${res.status})`); + } + + if (!res.body) { + throw new Error("AI 响应为空"); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value, { stream: true }); + onUpdate(text); + } +} diff --git a/lib/ai/config.ts b/lib/ai/config.ts new file mode 100644 index 0000000..6adfed4 --- /dev/null +++ b/lib/ai/config.ts @@ -0,0 +1,21 @@ +export function getOpenAiBaseUrl(): string { + return ( + process.env.OPENAI_BASE_URL ?? + process.env.OPENAI_API_BASE ?? + "https://op.bz121.com/v1" + ); +} + +export function getOpenAiModel(): string { + return process.env.OPENAI_MODEL ?? "huihui_ai/gemma-4-abliterated:e4b"; +} + +export function getOpenAiApiKey(): string { + const apiKey = process.env.OPENAI_API_KEY?.trim(); + if (!apiKey) { + throw new Error( + "未配置 OPENAI_API_KEY,请在 .env.local 或 Docker env_file 中设置", + ); + } + return apiKey; +} diff --git a/lib/ai/stream.ts b/lib/ai/stream.ts deleted file mode 100644 index 0d6d8f0..0000000 --- a/lib/ai/stream.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { streamText } from "ai"; -import { createOpenAI } from "@ai-sdk/openai"; -import { createStreamableValue } from "ai/rsc"; -import { ERROR_PREFIX } from "@/lib/constant"; - -export type AIStream = ReturnType>; - -function getOpenAiBaseUrl(): string { - return ( - process.env.OPENAI_BASE_URL ?? - process.env.OPENAI_API_BASE ?? - "https://op.bz121.com/v1" - ); -} - -const model = - process.env.OPENAI_MODEL ?? "huihui_ai/gemma-4-abliterated:e4b"; - -export function assertOpenAiKey(): string { - const apiKey = process.env.OPENAI_API_KEY?.trim(); - if (!apiKey) { - throw new Error( - "未配置 OPENAI_API_KEY,请在 .env.local 或 Docker env_file 中设置", - ); - } - return apiKey; -} - -/** 在 Server Action 内 createStreamableValue 后调用,不可从子函数返回 stream.value */ -export function pumpAIStream( - stream: AIStream, - system: string, - user: string, -): void { - const openai = createOpenAI({ - apiKey: assertOpenAiKey(), - baseURL: getOpenAiBaseUrl(), - }); - - const { fullStream } = streamText({ - temperature: 0.5, - model: openai(model), - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - maxRetries: 0, - }); - - let buffer = ""; - let done = false; - const intervalId = setInterval(() => { - if (done && buffer.length === 0) { - clearInterval(intervalId); - stream.done(); - return; - } - if (buffer.length <= 6) { - stream.update(buffer); - buffer = ""; - } else { - const chunk = buffer.slice(0, 6); - buffer = buffer.slice(6); - stream.update(chunk); - } - }, 60); - - (async () => { - for await (const part of fullStream) { - switch (part.type) { - case "text-delta": - buffer += part.textDelta; - break; - case "error": { - const err = part.error as { message?: string }; - stream.update(ERROR_PREFIX + (err.message ?? String(part.error))); - break; - } - } - } - })() - .catch((err) => { - const message = err instanceof Error ? err.message : String(err); - stream.update(ERROR_PREFIX + message); - }) - .finally(() => { - done = true; - }); -} diff --git a/lib/ai/types.ts b/lib/ai/types.ts new file mode 100644 index 0000000..62e1042 --- /dev/null +++ b/lib/ai/types.ts @@ -0,0 +1,40 @@ +import type { BaziInput } from "@/lib/calc/bazi"; + +export interface LiuyaoAiInput { + question: string; + calcDate: string; + calcTime: string; + locationName: string; + longitude: number; + guaMark: string; + guaTitle: string; + guaResult: string; + guaChange: string; +} + +export interface BaziAiInput { + input: BaziInput; + question: string; + birthPlaceName: string; +} + +export interface CombinedAiInput { + birth: BaziInput; + birthPlaceName: string; + currentPlaceName: string; + currentLongitude: number; + calcDate: string; + calcTime: string; + question: string; + hexagram?: { + guaMark: string; + guaTitle: string; + guaResult: string; + guaChange: string; + }; +} + +export type AiRequestBody = + | { mode: "liuyao"; payload: LiuyaoAiInput } + | { mode: "bazi"; payload: BaziAiInput } + | { mode: "combined"; payload: CombinedAiInput };