diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts index 01c5bb5..527e184 100644 --- a/app/api/ai/route.ts +++ b/app/api/ai/route.ts @@ -10,6 +10,11 @@ import type { AiRequestBody } from "@/lib/ai/types"; export const runtime = "nodejs"; +const STREAM_HEADERS = { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", +}; + export async function POST(req: Request) { try { const body = (await req.json()) as AiRequestBody; @@ -33,12 +38,19 @@ export async function POST(req: Request) { maxRetries: 0, }); - return result.toTextStreamResponse(); + const response = result.toTextStreamResponse(); + for (const [key, value] of Object.entries(STREAM_HEADERS)) { + response.headers.set(key, value); + } + return response; } 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" }, + headers: { + "Content-Type": "text/plain; charset=utf-8", + ...STREAM_HEADERS, + }, }); } } diff --git a/components/modes/bazi-form.tsx b/components/modes/bazi-form.tsx index f0f8d8e..cf0b4c3 100644 --- a/components/modes/bazi-form.tsx +++ b/components/modes/bazi-form.tsx @@ -163,7 +163,7 @@ export default function BaziForm() { )} {showAi && ( -
+
+
{showAi && ( -
+
{ - if ( - !scrollRef.current || - scrollRef.current.scrollHeight === - scrollRef.current.clientHeight + scrollRef.current.scrollTop - ) { - return; - } - scrollRef.current.scrollTo(0, scrollRef.current.scrollHeight); - }); - } + scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); + }, [completion, autoScroll]); function onScroll(e: HTMLElement) { if (!isLoading) { return; } const hitBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 15; - if (hitBottom === autoScroll) { - return; + if (hitBottom !== autoScroll) { + setAutoScroll(hitBottom); } - setAutoScroll(hitBottom); } return ( -
+
{isLoading && ( -
- -
- - AI 分析中... -
+
+ + AI 分析中...
)}
onScroll(e.currentTarget)} - className="max-h-full overflow-auto rounded-md border p-3 shadow dark:border-0 dark:bg-secondary/90 dark:shadow-none sm:p-5" + className="min-h-[240px] max-h-[420px] overflow-y-auto rounded-md border bg-background p-3 shadow sm:p-5 dark:border-0 dark:bg-secondary/90 dark:shadow-none" > {error ? ( -
- ಠ_ಠ 请求出错了! -
- {error} +
+

请求出错了

+

{error}

+ ) : completion ? ( + + {completion} + + ) : isLoading ? ( +

正在等待 AI 响应...

) : ( - {completion} +

暂无解读内容

)} {!isLoading && ( - diff --git a/docs/NGINX.md b/docs/NGINX.md new file mode 100644 index 0000000..3f0de1d --- /dev/null +++ b/docs/NGINX.md @@ -0,0 +1,60 @@ +# Nginx 反代 + AI 流式输出 + +浏览器访问 `https://你的域名/api/ai` 必须能到达 Next.js(3130),且**关闭缓冲**,否则页面上 AI 解读会一直空白或等到超时。 + +## 1. 验证 + +在**服务器**上分别测试: + +```bash +# 本地直连(应成功) +curl -s http://127.0.0.1:3130/api/health + +# 经域名(也必须成功,否则是 Nginx 问题) +curl -s https://gate.hyf2.cc/api/health +``` + +若本地成功、域名 404 或返回 HTML,说明 Nginx 未正确反代 `/api/`。 + +## 2. 推荐配置 + +```nginx +server { + listen 443 ssl http2; + server_name gate.hyf2.cc; + + # ssl_certificate ...; + + location / { + proxy_pass http://127.0.0.1:3130; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # AI 流式:必须关闭缓冲 + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + chunked_transfer_encoding on; + } +} +``` + +修改后: + +```bash +nginx -t && systemctl reload nginx +``` + +## 3. 浏览器测试 + +```bash +curl -N -X POST https://gate.hyf2.cc/api/ai \ + -H "Content-Type: application/json" \ + -d '{"mode":"bazi","payload":{"input":{"date":"1990-01-01","time":"12:00","gender":"male","longitude":120},"question":"测试","birthPlaceName":"上海市"}}' +``` + +应看到中文流式输出,而不是 HTML 404 页面。 diff --git a/lib/ai/client-stream.ts b/lib/ai/client-stream.ts index 0d024b0..5b50710 100644 --- a/lib/ai/client-stream.ts +++ b/lib/ai/client-stream.ts @@ -1,18 +1,30 @@ import type { AiRequestBody } from "@/lib/ai/types"; +function parseApiError(text: string, status: number): string { + const trimmed = text.trim(); + if ( + trimmed.startsWith("404") + ) { + return `AI 接口未到达后端 (${status})。请确认 Nginx 反代到 3130 且包含 /api/ 路径。`; + } + return trimmed.slice(0, 800) || `AI 请求失败 (${status})`; +} + export async function streamAiCompletion( body: AiRequestBody, onUpdate: (text: string) => void, ): Promise { const res = await fetch("/api/ai", { method: "POST", + cache: "no-store", 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})`); + throw new Error(parseApiError(await res.text(), res.status)); } if (!res.body) { @@ -31,4 +43,11 @@ export async function streamAiCompletion( text += decoder.decode(value, { stream: true }); onUpdate(text); } + + text += decoder.decode(); + onUpdate(text); + + if (!text.trim()) { + throw new Error("AI 返回内容为空,请检查模型配置或稍后重试"); + } }