From 0f3bc2c50a0e14e40e03978942b45e8848616329 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 10 Jun 2026 22:24:57 +0800 Subject: [PATCH] Fix AI stream by returning stream.value directly from Server Actions. createStreamableValue must be created and returned in the action itself; wrapping in { data } or a helper return caused production RSC serialization errors. Co-authored-by: Cursor --- app/actions/bazi.ts | 8 +- app/actions/combined.ts | 8 +- app/actions/liuyao.ts | 8 +- components/modes/bazi-form.tsx | 22 ++---- components/modes/combined-form.tsx | 22 ++---- components/modes/liuyao-form.tsx | 22 ++---- lib/ai/stream.ts | 121 ++++++++++++++--------------- 7 files changed, 102 insertions(+), 109 deletions(-) diff --git a/app/actions/bazi.ts b/app/actions/bazi.ts index 58a27ab..af7cc85 100644 --- a/app/actions/bazi.ts +++ b/app/actions/bazi.ts @@ -1,6 +1,7 @@ "use server"; -import { streamAIResponse } from "@/lib/ai/stream"; +import { createStreamableValue } from "ai/rsc"; +import { pumpAIStream } from "@/lib/ai/stream"; import { calculateBazi, formatBaziForPrompt, @@ -16,7 +17,9 @@ export async function getBaziAnswer( const chart = calculateBazi(input); const chartText = formatBaziForPrompt(chart); - return streamAIResponse( + const stream = createStreamableValue(); + pumpAIStream( + stream, BAZI_SYSTEM_PROMPT, `【出生时空】 出生地:${birthPlaceName} @@ -27,4 +30,5 @@ ${chartText} 【问事】 ${question}`, ); + return stream.value; } diff --git a/app/actions/combined.ts b/app/actions/combined.ts index d29c5eb..426629a 100644 --- a/app/actions/combined.ts +++ b/app/actions/combined.ts @@ -1,6 +1,7 @@ "use server"; -import { streamAIResponse } from "@/lib/ai/stream"; +import { createStreamableValue } from "ai/rsc"; +import { pumpAIStream } from "@/lib/ai/stream"; import { calculateBazi, formatBaziForPrompt, @@ -69,7 +70,9 @@ export async function getCombinedAnswer(input: CombinedInput) { } } - return streamAIResponse( + const stream = createStreamableValue(); + pumpAIStream( + stream, COMBINED_SYSTEM_PROMPT, `【人和 · 八字排盘】 出生地:${input.birthPlaceName} @@ -81,4 +84,5 @@ ${hexagramText} 【问事】 ${input.question}`, ); + return stream.value; } diff --git a/app/actions/liuyao.ts b/app/actions/liuyao.ts index 295c639..78af2c9 100644 --- a/app/actions/liuyao.ts +++ b/app/actions/liuyao.ts @@ -1,6 +1,7 @@ "use server"; -import { streamAIResponse } from "@/lib/ai/stream"; +import { createStreamableValue } from "ai/rsc"; +import { pumpAIStream } from "@/lib/ai/stream"; import { formatLiuyaoTimingForPrompt, getTimingInfoWithLongitude, @@ -51,7 +52,9 @@ export async function getLiuyaoAnswer(input: LiuyaoInput) { guaDetailText = ""; } - return streamAIResponse( + const stream = createStreamableValue(); + pumpAIStream( + stream, LIUYAO_SYSTEM_PROMPT, `${timingText} @@ -63,4 +66,5 @@ ${input.question} ${guaDetailText}`, ); + return stream.value; } diff --git a/components/modes/bazi-form.tsx b/components/modes/bazi-form.tsx index 674e770..7deb61d 100644 --- a/components/modes/bazi-form.tsx +++ b/components/modes/bazi-form.tsx @@ -70,25 +70,19 @@ export default function BaziForm() { setIsLoading(true); setShowAi(true); try { - const { data, error: apiError } = await getBaziAnswer( + const stream = await getBaziAnswer( input, question, location!.name, ); - if (apiError) { - setError(apiError); - return; - } - if (data) { - let ret = ""; - for await (const delta of readStreamableValue(data)) { - if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) { - setError(delta.slice(ERROR_PREFIX.length)); - return; - } - ret += delta ?? ""; - setCompletion(ret); + 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)); diff --git a/components/modes/combined-form.tsx b/components/modes/combined-form.tsx index ea8e4c3..9c3f8ed 100644 --- a/components/modes/combined-form.tsx +++ b/components/modes/combined-form.tsx @@ -109,7 +109,7 @@ export default function CombinedForm() { setShowAi(true); try { - const { data, error: apiError } = await getCombinedAnswer({ + const stream = await getCombinedAnswer({ birth: { date: birthDate, time: unknownHour ? "12:00" : birthTime, @@ -133,20 +133,14 @@ export default function CombinedForm() { : undefined, }); - if (apiError) { - setError(apiError); - return; - } - if (data) { - let ret = ""; - for await (const delta of readStreamableValue(data)) { - if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) { - setError(delta.slice(ERROR_PREFIX.length)); - return; - } - ret += delta ?? ""; - setCompletion(ret); + 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) { setError(e instanceof Error ? e.message : String(e)); diff --git a/components/modes/liuyao-form.tsx b/components/modes/liuyao-form.tsx index 2858dbf..42f54c3 100644 --- a/components/modes/liuyao-form.tsx +++ b/components/modes/liuyao-form.tsx @@ -72,7 +72,7 @@ export default function LiuyaoForm() { setShowAi(true); try { - const { data, error: apiError } = await getLiuyaoAnswer({ + const stream = await getLiuyaoAnswer({ question, calcDate, calcTime, @@ -84,20 +84,14 @@ export default function LiuyaoForm() { guaChange: guaData!.result.guaChange, }); - if (apiError) { - setError(apiError); - return; - } - if (data) { - let ret = ""; - for await (const delta of readStreamableValue(data)) { - if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) { - setError(delta.slice(ERROR_PREFIX.length)); - return; - } - ret += delta ?? ""; - setCompletion(ret); + 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) { setError(e instanceof Error ? e.message : String(e)); diff --git a/lib/ai/stream.ts b/lib/ai/stream.ts index a8b87c8..0d6d8f0 100644 --- a/lib/ai/stream.ts +++ b/lib/ai/stream.ts @@ -3,6 +3,8 @@ 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 ?? @@ -14,77 +16,74 @@ function getOpenAiBaseUrl(): string { const model = process.env.OPENAI_MODEL ?? "huihui_ai/gemma-4-abliterated:e4b"; -export async function streamAIResponse( - system: string, - user: string, -): Promise<{ data?: ReturnType>["value"]; error?: string }> { +export function assertOpenAiKey(): string { const apiKey = process.env.OPENAI_API_KEY?.trim(); if (!apiKey) { - return { error: "未配置 OPENAI_API_KEY,请在 .env.local 或 Docker env_file 中设置" }; + 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, + apiKey: assertOpenAiKey(), baseURL: getOpenAiBaseUrl(), }); - const stream = createStreamableValue(); + const { fullStream } = streamText({ + temperature: 0.5, + model: openai(model), + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + maxRetries: 0, + }); - try { - 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); - 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; - } + (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; - }); - - return { data: stream.value }; - } catch (err) { - stream.done(); - const message = err instanceof Error ? err.message : String(err); - return { error: message }; - } + } + })() + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + stream.update(ERROR_PREFIX + message); + }) + .finally(() => { + done = true; + }); }