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:
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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