Fix AI result panel visibility and nginx streaming headers.

ResultAI had h-0 collapsing output; add X-Accel-Buffering no, clearer fetch errors, and NGINX.md for gate proxy setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-10 22:56:22 +08:00
parent 38bbc7145a
commit 39181f21ad
7 changed files with 118 additions and 39 deletions
+14 -2
View File
@@ -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,
},
});
}
}
+1 -1
View File
@@ -163,7 +163,7 @@ export default function BaziForm() {
)}
{showAi && (
<div className="mx-auto h-96 w-full max-w-lg flex-1">
<div className="mx-auto w-full max-w-lg pt-2">
<ResultAI
completion={completion}
isLoading={isLoading}
+1 -1
View File
@@ -299,7 +299,7 @@ export default function CombinedForm() {
)}
{showAi && (
<div className="mx-auto h-96 w-full max-w-lg flex-1">
<div className="mx-auto w-full max-w-lg pt-2">
<ResultAI
completion={completion}
isLoading={isLoading}
+1 -1
View File
@@ -229,7 +229,7 @@ export default function LiuyaoForm() {
</div>
{showAi && (
<div className="mx-auto h-96 w-full max-w-lg flex-1">
<div className="mx-auto w-full max-w-lg pt-2">
<ResultAI
completion={completion}
isLoading={isLoading}
+19 -31
View File
@@ -25,60 +25,48 @@ function ResultAI({
if (!autoScroll) {
return;
}
scrollTo();
});
function scrollTo() {
requestAnimationFrame(() => {
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);
}
}
return (
<div className="h-0 w-full flex-1 sm:max-w-md md:max-w-2xl">
<div className="flex w-full flex-col gap-2">
{isLoading && (
<div className="flex h-0">
<span className="flex-1" />
<div className="relative -top-4 flex w-fit items-center pr-1 text-muted-foreground sm:left-2 sm:pr-3">
<div className="flex items-center text-sm text-muted-foreground">
<RotateCw size={16} className="animate-spin" />
<span className="ml-1 text-sm">AI ...</span>
</div>
<span className="ml-1">AI ...</span>
</div>
)}
<div
ref={scrollRef}
onScroll={(e) => 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 ? (
<div className="text-destructive">
_ಠ
<br />
{error}
<div className="text-sm text-destructive">
<p className="font-medium"></p>
<p className="mt-1 whitespace-pre-wrap">{error}</p>
</div>
) : completion ? (
<Markdown className="prose max-w-none dark:prose-invert">
{completion}
</Markdown>
) : isLoading ? (
<p className="text-sm text-muted-foreground"> AI ...</p>
) : (
<Markdown className="prose dark:prose-invert">{completion}</Markdown>
<p className="text-sm text-muted-foreground"></p>
)}
{!isLoading && (
<Button onClick={onCompletion} size="sm" className="mt-2">
<Button onClick={onCompletion} size="sm" className="mt-3">
<RotateCw size={18} className="mr-1" />
</Button>
+60
View File
@@ -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 页面。
+21 -2
View File
@@ -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("<!DOCTYPE") ||
trimmed.startsWith("<html") ||
trimmed.includes("<title>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<void> {
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 返回内容为空,请检查模型配置或稍后重试");
}
}