feat(ai): 后台 AI 链路改走 HTTP 流式,WS 地址与心跳收敛到 utils

写作 / 图片 / 大纲 / 稿件四条链路用普通 POST 时,生成期间链路上零字节,
会被 60s 空闲超时掐成 504(出图实测 58s 断开)。后端已给每条链路开
/stream(SSE,每 15s 一帧心跳),这里改成消费它。

- 新增 utils/ai-stream.ts:SSE 帧解析 + 末帧 {type:'final', result} 拆包;
  runCreativeAssistantStream / runManuscriptGenStream 对外仍返回原来的 data,
  页面渲染逻辑不动
- 新增 utils/chat-socket.ts:buildWebSocketUrl 统一拼 /api/chat/{userId}
  (网关只放行 /api 前缀),attachHeartbeat 每 25s 发 ping 顶住代理空闲超时
- chatgpt 对话先试 WebSocket,握手失败落 /ai/chat/message/stream,
  帧内容与 WS 完全一致,处理函数直接复用
- 四个页面删掉各自重复的 buildWebSocketUrl,统一从 utils 引入
This commit is contained in:
2026-10-21 19:18:08 +08:00
parent 038e789cb0
commit 6f7f2f0d9a
7 changed files with 354 additions and 85 deletions
@@ -146,7 +146,7 @@
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
import { stopMessage } from '@/api/oa/chatgpt';
import { API_BASE_URL } from '@/config/setting';
import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common';
import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model';
@@ -249,17 +249,6 @@
});
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
@@ -402,6 +391,10 @@
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
socket.onopen = () => {
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
attachHeartbeat(socket);
};
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
+19 -27
View File
@@ -198,11 +198,12 @@
import { asBlob } from 'html-docx-js-typescript';
import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
import { activityOutlineGen } from '@/api/ai/activityOutlineGen';
import { streamActivityOutlineGen } from '@/utils/ai-stream';
import type {
ActivityOutlineGenParams,
ActivityOutlineGenResponse
} from '@/api/ai/activityOutlineGen/model';
import { API_BASE_URL } from '@/config/setting';
import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
@@ -422,17 +423,6 @@
return parts.join(' | ');
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
@@ -559,6 +549,10 @@
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
socket.onopen = () => {
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
attachHeartbeat(socket);
};
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
@@ -637,23 +631,21 @@
}
loading.value = true;
clearResult();
resultSummary.value = '正在建立 WebSocket 连接...';
resultSummary.value = '正在提交生成任务...';
try {
await connectWebSocket();
resultSummary.value = '连接成功,已提交生成任务,等待流式返回...';
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的 60s 空闲
// 超时掐成 504 Gateway Time-out。/stream 每 15s 有后端心跳续命。
const payload = buildPayload();
resultSummary.value = '已提交生成任务,等待流式返回...';
const waitStream = createStreamWaiter();
const data = await activityOutlineGen(buildPayload());
result.value = data || null;
if (!streamStarted.value && data && parseOutline(data)) {
outlineMarkdown.value = parseOutline(data);
syncEditableOutline();
resultSummary.value = buildSummary() || '接口直接返回生成结果';
loading.value = false;
resolveStream();
disconnectWebSocket();
} else {
await waitStream;
}
await streamActivityOutlineGen(
payload as unknown as Record<string, unknown>,
(raw) => handleSocketMessage(raw)
);
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
resolveStream();
await waitStream;
if (!visibleOutlineMarkdown.value) {
message.warning('接口已返回,但未解析到正文内容');
return;
+3 -2
View File
@@ -189,7 +189,7 @@
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { creativeAssistant } from '@/api/ai/creativeAssistant';
import { runCreativeAssistantStream } from '@/utils/ai-stream';
import type { FileRecord } from '@/api/system/file/model';
import type {
CreativeAssistantOutputs,
@@ -404,7 +404,8 @@
}
loading.value = true;
try {
const result = await creativeAssistant(buildRequestPayload());
// 出图中途零字节,普通 POST 会被网关按 60s 空闲超时掐成 504,改走流式
const result = await runCreativeAssistantStream(buildRequestPayload());
workflowResponse.value = result || null;
workflowOutputs.value = normalizeOutputPayload(result || null);
if (
+23 -23
View File
@@ -229,13 +229,14 @@
import { asBlob } from 'html-docx-js-typescript';
import { getProcessor } from 'bytemd';
import { manuscriptGen } from '@/api/ai/manuscriptGen';
import { runManuscriptGenStream } from '@/utils/ai-stream';
import type {
ManuscriptGenParams,
ManuscriptGenResult
} from '@/api/ai/manuscriptGen/model';
import { uploadFile } from '@/api/system/file';
import type { FileRecord } from '@/api/system/file/model';
import { API_BASE_URL } from '@/config/setting';
import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
@@ -560,17 +561,6 @@
return typeof payload === 'object';
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
@@ -696,6 +686,7 @@
}
};
socket.onopen = () => {
attachHeartbeat(socket);
wsConnected.value = false;
};
socket.onmessage = (event) => {
@@ -859,25 +850,34 @@
loading.value = true;
clearGenerated();
generatedTitle.value = form.theme.trim();
responseSummary.value = '正在建立 WebSocket 连接...';
responseSummary.value = '正在提交生成任务...';
try {
await connectWebSocket();
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的 60s 空闲
// 超时掐成 504 Gateway Time-out(自由创作、不选文体时最先撞上)。
// /stream 每 15s 有后端心跳续命,正文逐段喂给原来的帧处理,
// 结构化结果从末帧 {type:'final'} 取。
const payload = buildPayload();
responseSummary.value = templateMode.value
? '连接成功,已提交生成任务,等待流式返回...'
? '已提交生成任务,等待流式返回...'
: '自由创作已提交给 AI 创意助手,生成完成后一次性返回...';
const waitStream = createStreamWaiter();
const result = await manuscriptGen(buildPayload());
if (!streamStarted.value && shouldTreatAsDirectResult(result)) {
const result = await runManuscriptGenStream(
payload as unknown as Record<string, unknown>,
undefined,
(raw) => handleSocketMessage(raw)
);
if (result) {
applyParsedResult(
typeof result === 'string' ? parseSerializedResult(result) : (result as ManuscriptGenResult),
typeof result === 'string'
? parseSerializedResult(result)
: (result as ManuscriptGenResult),
'接口直接返回生成结果'
);
loading.value = false;
resolveStream();
disconnectWebSocket();
} else {
await waitStream;
}
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
resolveStream();
await waitStream;
message.success('内容生成完成');
} catch (e: any) {
disconnectWebSocket();
+23 -21
View File
@@ -389,15 +389,14 @@ import type { AiChatHistory } from "@/api/ai/aiChatHistory/model";
import { listAiChatList, removeAiChatList } from "@/api/ai/aiChatList";
import type { AiChatList } from "@/api/ai/aiChatList/model";
import { sendMessage, stopMessage } from "@/api/oa/chatgpt";
import { manuscriptGen } from "@/api/ai/manuscriptGen";
import { runCreativeAssistantStream, runManuscriptGenStream, streamChatMessage } from "@/utils/ai-stream";
import {
WRITING_DOC_TYPE_FREE,
WRITING_DOC_TYPE_OPTIONS,
WRITING_EVENT_TYPE_OPTIONS,
WRITING_WORD_COUNT_OPTIONS
} from "./writing-params";
import { creativeAssistant } from "@/api/ai/creativeAssistant";
import { API_BASE_URL } from "@/config/setting";
import { attachHeartbeat, buildWebSocketUrl } from "@/utils/chat-socket";
import type { Menu } from "@/api/system/menu/model";
import { useUserStore } from "@/store/modules/user";
import { logout } from "@/utils/page-tab-util";
@@ -785,17 +784,6 @@ const findFirstChildMenuPath = (menus: AuthorityTreeItem[]) => {
return "";
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = url.pathname.replace(/\/api\/?$/, "");
return `${url.toString().replace(/\/$/, "")}/chat/${userId}`;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const formatHistoryMessages = (list: AiChatHistory[]) => {
const result: ChatMessageItem[] = [];
[...list]
@@ -972,6 +960,7 @@ const connectWebSocket = async () => {
}
};
socket.onopen = () => {
attachHeartbeat(socket);
wsConnected.value = false;
};
socket.onmessage = (event) => {
@@ -1335,7 +1324,8 @@ const sendModeMessage = async (
}
};
if (activeChatMode.value === "writing") {
const result = await manuscriptGen(buildWritingPayload(query), { signal });
// 稿件生成同样会被链路的 60s 空闲超时掐掉,改走流式(正文边推边显示)
const result = await runManuscriptGenStream(buildWritingPayload(query), signal);
if (typeof (result as any)?.taskId === "string") {
currentTaskId.value = (result as any).taskId;
}
@@ -1343,7 +1333,8 @@ const sendModeMessage = async (
return;
}
if (activeChatMode.value === "image") {
const result = await creativeAssistant(buildImagePayload(query), { signal });
// 出图中途零字节,普通 POST 会被网关按 60s 空闲超时掐成 504,改走流式
const result = await runCreativeAssistantStream(buildImagePayload(query), signal);
if (typeof (result as any)?.taskId === "string") {
currentTaskId.value = (result as any).taskId;
}
@@ -1471,13 +1462,24 @@ const onSend = async () => {
content.value = "";
scrollToBottom();
if (activeChatMode.value === "chat") {
await connectWebSocket();
await sendMessage({
// 先试 WebSocket;生产网关在 /api 前丢 Upgrade 头,握手必然失败,
// 这时落到 HTTP 流式端点,帧内容与 WS 完全一致,解析逻辑不用改
const payload = {
query,
conversationId: activeConversationId.value
}, {
signal: abortController.signal
});
};
const viaSocket = await connectWebSocket()
.then(() => true)
.catch(() => false);
if (viaSocket) {
await sendMessage(payload, { signal: abortController.signal });
} else {
await streamChatMessage(
payload as Record<string, unknown>,
(raw) => void handleSocketMessage(raw),
abortController.signal
);
}
} else {
await sendModeMessage(query, assistantId, abortController.signal);
streamingMessageId.value = undefined;