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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-10 22:39:09 +08:00
parent 0f3bc2c50a
commit dba0245cb1
13 changed files with 342 additions and 384 deletions
-34
View File
@@ -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<string>();
pumpAIStream(
stream,
BAZI_SYSTEM_PROMPT,
`【出生时空】
出生地:${birthPlaceName}
【排盘信息】
${chartText}
【问事】
${question}`,
);
return stream.value;
}
-88
View File
@@ -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<string>();
pumpAIStream(
stream,
COMBINED_SYSTEM_PROMPT,
`【人和 · 八字排盘】
出生地:${input.birthPlaceName}
${chartText}
${timingText}
${hexagramText}
【问事】
${input.question}`,
);
return stream.value;
}
-70
View File
@@ -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<string>();
pumpAIStream(
stream,
LIUYAO_SYSTEM_PROMPT,
`${timingText}
【卦象】
${input.guaTitle} ${input.guaResult} ${input.guaChange}
【问事】
${input.question}
${guaDetailText}`,
);
return stream.value;
}
+44
View File
@@ -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" },
});
}
}
-28
View File
@@ -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,
});
}
+11 -16
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { readStreamableValue } from "ai/rsc";
import { BrainCircuit } from "lucide-react"; import { BrainCircuit } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -12,8 +11,7 @@ import RegionSelect, {
useRegionLocation, useRegionLocation,
} from "@/components/shared/region-select"; } from "@/components/shared/region-select";
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi"; import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
import { getBaziAnswer } from "@/app/actions/bazi"; import { streamAiCompletion } from "@/lib/ai/client-stream";
import { ERROR_PREFIX } from "@/lib/constant";
export default function BaziForm() { export default function BaziForm() {
const [date, setDate] = useState(nowDateString()); const [date, setDate] = useState(nowDateString());
@@ -70,20 +68,17 @@ export default function BaziForm() {
setIsLoading(true); setIsLoading(true);
setShowAi(true); setShowAi(true);
try { try {
const stream = await getBaziAnswer( await streamAiCompletion(
input, {
question, mode: "bazi",
location!.name, 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) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
+29 -35
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { readStreamableValue } from "ai/rsc";
import { Compass } from "lucide-react"; import { Compass } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -18,8 +17,7 @@ import RegionSelect, {
} from "@/components/shared/region-select"; } from "@/components/shared/region-select";
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi"; import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
import type { GuaResult } from "@/lib/calc/hexagram"; import type { GuaResult } from "@/lib/calc/hexagram";
import { getCombinedAnswer } from "@/app/actions/combined"; import { streamAiCompletion } from "@/lib/ai/client-stream";
import { ERROR_PREFIX } from "@/lib/constant";
export default function CombinedForm() { export default function CombinedForm() {
const [birthDate, setBirthDate] = useState("1990-01-01"); const [birthDate, setBirthDate] = useState("1990-01-01");
@@ -109,39 +107,35 @@ export default function CombinedForm() {
setShowAi(true); setShowAi(true);
try { try {
const stream = await getCombinedAnswer({ await streamAiCompletion(
birth: { {
date: birthDate, mode: "combined",
time: unknownHour ? "12:00" : birthTime, payload: {
gender, birth: {
longitude: birthLocation!.longitude, date: birthDate,
unknownHour, 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, setCompletion,
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);
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
+18 -24
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { readStreamableValue } from "ai/rsc";
import { BrainCircuit, ListRestart } from "lucide-react"; import { BrainCircuit, ListRestart } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -15,9 +14,8 @@ import HexagramInput from "@/components/shared/hexagram-input";
import RegionSelect, { import RegionSelect, {
useRegionLocation, useRegionLocation,
} from "@/components/shared/region-select"; } 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 type { GuaResult } from "@/lib/calc/hexagram";
import { ERROR_PREFIX } from "@/lib/constant";
import todayJson from "@/lib/data/today.json"; import todayJson from "@/lib/data/today.json";
const todayData: string[] = todayJson; const todayData: string[] = todayJson;
@@ -72,27 +70,23 @@ export default function LiuyaoForm() {
setShowAi(true); setShowAi(true);
try { try {
const stream = await getLiuyaoAnswer({ await streamAiCompletion(
question, {
calcDate, mode: "liuyao",
calcTime, payload: {
locationName: location!.name, question,
longitude: location!.longitude, calcDate,
guaMark: guaData!.result.guaMark, calcTime,
guaTitle: guaData!.result.guaTitle, locationName: location!.name,
guaResult: guaData!.result.guaResult, longitude: location!.longitude,
guaChange: guaData!.result.guaChange, guaMark: guaData!.result.guaMark,
}); guaTitle: guaData!.result.guaTitle,
guaResult: guaData!.result.guaResult,
let ret = ""; guaChange: guaData!.result.guaChange,
for await (const delta of readStreamableValue(stream)) { },
if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) { },
setError(delta.slice(ERROR_PREFIX.length)); setCompletion,
return; );
}
ret += delta ?? "";
setCompletion(ret);
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
+145
View File
@@ -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 模式");
}
+34
View File
@@ -0,0 +1,34 @@
import type { AiRequestBody } from "@/lib/ai/types";
export async function streamAiCompletion(
body: AiRequestBody,
onUpdate: (text: string) => void,
): Promise<void> {
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);
}
}
+21
View File
@@ -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;
}
-89
View File
@@ -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<typeof createStreamableValue<string>>;
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;
});
}
+40
View File
@@ -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 };