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:
@@ -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 模式");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user