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:
+14
-2
@@ -10,6 +10,11 @@ import type { AiRequestBody } from "@/lib/ai/types";
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const STREAM_HEADERS = {
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as AiRequestBody;
|
const body = (await req.json()) as AiRequestBody;
|
||||||
@@ -33,12 +38,19 @@ export async function POST(req: Request) {
|
|||||||
maxRetries: 0,
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return new Response(message, {
|
return new Response(message, {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
...STREAM_HEADERS,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export default function BaziForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showAi && (
|
{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
|
<ResultAI
|
||||||
completion={completion}
|
completion={completion}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ export default function CombinedForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showAi && (
|
{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
|
<ResultAI
|
||||||
completion={completion}
|
completion={completion}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export default function LiuyaoForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAi && (
|
{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
|
<ResultAI
|
||||||
completion={completion}
|
completion={completion}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
+20
-32
@@ -25,60 +25,48 @@ function ResultAI({
|
|||||||
if (!autoScroll) {
|
if (!autoScroll) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scrollTo();
|
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
||||||
});
|
}, [completion, autoScroll]);
|
||||||
|
|
||||||
function scrollTo() {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (
|
|
||||||
!scrollRef.current ||
|
|
||||||
scrollRef.current.scrollHeight ===
|
|
||||||
scrollRef.current.clientHeight + scrollRef.current.scrollTop
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollRef.current.scrollTo(0, scrollRef.current.scrollHeight);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll(e: HTMLElement) {
|
function onScroll(e: HTMLElement) {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hitBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 15;
|
const hitBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 15;
|
||||||
if (hitBottom === autoScroll) {
|
if (hitBottom !== autoScroll) {
|
||||||
return;
|
setAutoScroll(hitBottom);
|
||||||
}
|
}
|
||||||
setAutoScroll(hitBottom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 && (
|
{isLoading && (
|
||||||
<div className="flex h-0">
|
<div className="flex items-center text-sm text-muted-foreground">
|
||||||
<span className="flex-1" />
|
<RotateCw size={16} className="animate-spin" />
|
||||||
<div className="relative -top-4 flex w-fit items-center pr-1 text-muted-foreground sm:left-2 sm:pr-3">
|
<span className="ml-1">AI 分析中...</span>
|
||||||
<RotateCw size={16} className="animate-spin" />
|
|
||||||
<span className="ml-1 text-sm">AI 分析中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={(e) => onScroll(e.currentTarget)}
|
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 ? (
|
{error ? (
|
||||||
<div className="text-destructive">
|
<div className="text-sm text-destructive">
|
||||||
ಠ_ಠ 请求出错了!
|
<p className="font-medium">请求出错了</p>
|
||||||
<br />
|
<p className="mt-1 whitespace-pre-wrap">{error}</p>
|
||||||
{error}
|
|
||||||
</div>
|
</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 && (
|
{!isLoading && (
|
||||||
<Button onClick={onCompletion} size="sm" className="mt-2">
|
<Button onClick={onCompletion} size="sm" className="mt-3">
|
||||||
<RotateCw size={18} className="mr-1" />
|
<RotateCw size={18} className="mr-1" />
|
||||||
重新生成
|
重新生成
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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
@@ -1,18 +1,30 @@
|
|||||||
import type { AiRequestBody } from "@/lib/ai/types";
|
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(
|
export async function streamAiCompletion(
|
||||||
body: AiRequestBody,
|
body: AiRequestBody,
|
||||||
onUpdate: (text: string) => void,
|
onUpdate: (text: string) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch("/api/ai", {
|
const res = await fetch("/api/ai", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = (await res.text()).trim();
|
throw new Error(parseApiError(await res.text(), res.status));
|
||||||
throw new Error(message || `AI 请求失败 (${res.status})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
@@ -31,4 +43,11 @@ export async function streamAiCompletion(
|
|||||||
text += decoder.decode(value, { stream: true });
|
text += decoder.decode(value, { stream: true });
|
||||||
onUpdate(text);
|
onUpdate(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text += decoder.decode();
|
||||||
|
onUpdate(text);
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error("AI 返回内容为空,请检查模型配置或稍后重试");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user