forked from b2894lxlx/tuanwei-admin
fix(ai): 生成收尾不再只依赖 __END__,并清掉 WS 死代码
- handleGenerate 收尾改 try/catch/finally:loading 一定复位。原先只在收到 __END__ 帧时由 finishStream 复位,网关返回空流或上游中途报错时按钮会永久 停在加载态(issue 20) - 提示按结果区分:没有正文 → 提示重试;正文是失败文案 → 报错;否则才是成功 - 删掉已不会调用的 WebSocket 栈:connectWebSocket / disconnectWebSocket / wsConnected / webSocketRef / wsConnectPromiseRef / activeConversationId, 以及随之失效的 attachHeartbeat、buildWebSocketUrl、useUserStore 与 StreamResolver.reject - 大纲页删掉早已无调用点的 parseOutline(SSE 改造后遗留)及其 import
This commit is contained in:
@@ -174,21 +174,18 @@
|
|||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { asBlob } from 'html-docx-js-typescript';
|
import { asBlob } from 'html-docx-js-typescript';
|
||||||
import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
|
import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
|
||||||
import { activityOutlineGen } from '@/api/ai/activityOutlineGen';
|
|
||||||
import { streamActivityOutlineGen } from '@/utils/ai-stream';
|
import { streamActivityOutlineGen } from '@/utils/ai-stream';
|
||||||
import type {
|
import type {
|
||||||
ActivityOutlineGenParams,
|
ActivityOutlineGenParams,
|
||||||
ActivityOutlineGenResponse
|
ActivityOutlineGenResponse
|
||||||
} from '@/api/ai/activityOutlineGen/model';
|
} from '@/api/ai/activityOutlineGen/model';
|
||||||
import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
|
|
||||||
import { useUserStore } from '@/store/modules/user';
|
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
import { FAILED_TEXT } from '@/utils/ai-result';
|
||||||
|
|
||||||
type ExampleType = 'volunteer' | 'salon' | 'sports';
|
type ExampleType = 'volunteer' | 'salon' | 'sports';
|
||||||
|
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
|
||||||
type StreamResolver = {
|
type StreamResolver = {
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (error: Error) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -201,7 +198,6 @@
|
|||||||
event_meme: string;
|
event_meme: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
const plugins = ref([
|
const plugins = ref([
|
||||||
gfm({
|
gfm({
|
||||||
locale: zh_HansGfm
|
locale: zh_HansGfm
|
||||||
@@ -213,16 +209,10 @@
|
|||||||
const result = ref<ActivityOutlineGenResponse | null>(null);
|
const result = ref<ActivityOutlineGenResponse | null>(null);
|
||||||
const outlineMarkdown = ref('');
|
const outlineMarkdown = ref('');
|
||||||
const editableOutlineMarkdown = ref('');
|
const editableOutlineMarkdown = ref('');
|
||||||
const activeConversationId = ref<string>();
|
|
||||||
const activeMessageId = ref<string>();
|
|
||||||
const webSocketRef = ref<WebSocket | null>(null);
|
|
||||||
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
|
|
||||||
const streamStarted = ref(false);
|
const streamStarted = ref(false);
|
||||||
const streamResolverRef = ref<StreamResolver | null>(null);
|
const streamResolverRef = ref<StreamResolver | null>(null);
|
||||||
const loginUser = computed(() => userStore.info ?? {});
|
|
||||||
|
|
||||||
const STREAM_END_TEXT = '__END__';
|
const STREAM_END_TEXT = '__END__';
|
||||||
const SOCKET_CONNECTED_TEXT = '连接成功';
|
|
||||||
const THINK_OPEN_TAG = '<think>';
|
const THINK_OPEN_TAG = '<think>';
|
||||||
const THINK_CLOSE_TAG = '</think>';
|
const THINK_CLOSE_TAG = '</think>';
|
||||||
|
|
||||||
@@ -356,38 +346,6 @@
|
|||||||
// pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer
|
// pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer
|
||||||
// 当正文。现在统一走 @/utils/ai-result(见 issue 10)。
|
// 当正文。现在统一走 @/utils/ai-result(见 issue 10)。
|
||||||
|
|
||||||
const parseOutline = (payload?: ActivityOutlineGenResponse | null) => {
|
|
||||||
if (!payload) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// 失败态优先:后端失败时 answer 是失败文案(且带 status=failed),
|
|
||||||
// 不先判失败就会把失败文案当正文渲染(见 issue 10)。
|
|
||||||
if (isFailedPayload(payload)) {
|
|
||||||
return FAILED_TEXT;
|
|
||||||
}
|
|
||||||
// 用 pickDisplayable 而非 pickNonEmptyString:失败报文里的 answer
|
|
||||||
// 可能是 workflow_run_id 这类 UUID,非空判断挡不住。
|
|
||||||
const answer = pickDisplayable(
|
|
||||||
payload.answer,
|
|
||||||
payload.raw?.answer,
|
|
||||||
payload.raw?.data?.answer,
|
|
||||||
payload.raw?.output,
|
|
||||||
payload.raw?.result,
|
|
||||||
payload.raw?.content,
|
|
||||||
payload.raw?.message
|
|
||||||
);
|
|
||||||
if (answer) {
|
|
||||||
return answer;
|
|
||||||
}
|
|
||||||
// 不再 stringifyUnknown(payload.raw || payload):整包会带出
|
|
||||||
// conversationId / messageId 等内部标识符,且此值会直接进编辑器(见 issue 10)。
|
|
||||||
return resolveDisplayText({
|
|
||||||
payload,
|
|
||||||
scope: 'gxmu/activityOutlineGen.parseOutline',
|
|
||||||
candidates: []
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearStreamResolver = () => {
|
const clearStreamResolver = () => {
|
||||||
streamResolverRef.value = null;
|
streamResolverRef.value = null;
|
||||||
};
|
};
|
||||||
@@ -397,46 +355,17 @@
|
|||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectStream = (error: Error) => {
|
|
||||||
streamResolverRef.value?.reject(error);
|
|
||||||
clearStreamResolver();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createStreamWaiter = () => {
|
const createStreamWaiter = () => {
|
||||||
streamStarted.value = false;
|
streamStarted.value = false;
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve) => {
|
||||||
streamResolverRef.value = { resolve, reject };
|
streamResolverRef.value = { resolve };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const disconnectWebSocket = () => {
|
const appendOutlineChunk = (chunk: string, reset?: boolean) => {
|
||||||
wsConnectPromiseRef.value = null;
|
|
||||||
const socket = webSocketRef.value;
|
|
||||||
webSocketRef.value = null;
|
|
||||||
if (
|
|
||||||
socket &&
|
|
||||||
(socket.readyState === WebSocket.OPEN ||
|
|
||||||
socket.readyState === WebSocket.CONNECTING)
|
|
||||||
) {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendOutlineChunk = (
|
|
||||||
chunk: string,
|
|
||||||
conversationId?: string,
|
|
||||||
messageId?: string,
|
|
||||||
reset?: boolean
|
|
||||||
) => {
|
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (conversationId && conversationId !== '0') {
|
|
||||||
activeConversationId.value = conversationId;
|
|
||||||
}
|
|
||||||
if (messageId && messageId !== '0') {
|
|
||||||
activeMessageId.value = messageId;
|
|
||||||
}
|
|
||||||
if (!streamStarted.value) {
|
if (!streamStarted.value) {
|
||||||
streamStarted.value = true;
|
streamStarted.value = true;
|
||||||
outlineMarkdown.value = '';
|
outlineMarkdown.value = '';
|
||||||
@@ -452,16 +381,9 @@
|
|||||||
syncEditableOutline();
|
syncEditableOutline();
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishStream = (conversationId?: string, messageId?: string) => {
|
const finishStream = () => {
|
||||||
if (conversationId && conversationId !== '0') {
|
|
||||||
activeConversationId.value = conversationId;
|
|
||||||
}
|
|
||||||
if (messageId && messageId !== '0') {
|
|
||||||
activeMessageId.value = messageId;
|
|
||||||
}
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
resolveStream();
|
resolveStream();
|
||||||
disconnectWebSocket();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSocketMessage = (raw: string) => {
|
const handleSocketMessage = (raw: string) => {
|
||||||
@@ -471,7 +393,7 @@
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
if (data?.answer === STREAM_END_TEXT) {
|
if (data?.answer === STREAM_END_TEXT) {
|
||||||
finishStream(data?.conversationId, data?.messageId);
|
finishStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data?.loading === 'true' || data?.loading === true) {
|
if (data?.loading === 'true' || data?.loading === true) {
|
||||||
@@ -479,99 +401,17 @@
|
|||||||
}
|
}
|
||||||
const chunk = pickRawString(data?.notice, data?.answer);
|
const chunk = pickRawString(data?.notice, data?.answer);
|
||||||
if (typeof chunk === 'string') {
|
if (typeof chunk === 'string') {
|
||||||
appendOutlineChunk(
|
appendOutlineChunk(chunk, data?.reset === 'true' || data?.reset === true);
|
||||||
chunk,
|
|
||||||
data?.conversationId,
|
|
||||||
data?.messageId,
|
|
||||||
data?.reset === 'true' || data?.reset === true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appendOutlineChunk(raw);
|
appendOutlineChunk(raw);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectWebSocket = async () => {
|
|
||||||
const userId = loginUser.value.userId;
|
|
||||||
if (!userId) {
|
|
||||||
return Promise.reject(new Error('未获取到当前登录用户'));
|
|
||||||
}
|
|
||||||
const currentSocket = webSocketRef.value;
|
|
||||||
if (currentSocket?.readyState === WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(currentSocket?.readyState === WebSocket.OPEN ||
|
|
||||||
currentSocket?.readyState === WebSocket.CONNECTING) &&
|
|
||||||
wsConnectPromiseRef.value
|
|
||||||
) {
|
|
||||||
return wsConnectPromiseRef.value;
|
|
||||||
}
|
|
||||||
let cleanupPending = () => undefined;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
const raw = String(event.data || '');
|
|
||||||
if (raw === SOCKET_CONNECTED_TEXT) {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSocketMessage(raw);
|
|
||||||
};
|
|
||||||
socket.onclose = () => {
|
|
||||||
if (webSocketRef.value === socket) {
|
|
||||||
webSocketRef.value = null;
|
|
||||||
}
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
reject(new Error('活动策划大纲连接已关闭'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loading.value) {
|
|
||||||
rejectStream(new Error('活动策划大纲连接已关闭'));
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
cleanupPending();
|
|
||||||
};
|
|
||||||
socket.onerror = () => {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
reject(new Error('活动策划大纲连接失败'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loading.value) {
|
|
||||||
rejectStream(new Error('活动策划大纲连接失败'));
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
webSocketRef.value = socket;
|
|
||||||
});
|
|
||||||
wsConnectPromiseRef.value = connectPromise;
|
|
||||||
return connectPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearResult = () => {
|
const clearResult = () => {
|
||||||
result.value = null;
|
result.value = null;
|
||||||
outlineMarkdown.value = '';
|
outlineMarkdown.value = '';
|
||||||
editableOutlineMarkdown.value = '';
|
editableOutlineMarkdown.value = '';
|
||||||
activeConversationId.value = undefined;
|
|
||||||
activeMessageId.value = undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -605,16 +445,21 @@
|
|||||||
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
|
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
|
||||||
resolveStream();
|
resolveStream();
|
||||||
await waitStream;
|
await waitStream;
|
||||||
if (!visibleOutlineMarkdown.value) {
|
const text = visibleOutlineMarkdown.value.trim();
|
||||||
message.warning('接口已返回,但未解析到正文内容');
|
if (!text) {
|
||||||
return;
|
message.warning('这次没有生成出内容,请重试');
|
||||||
|
} else if (text === FAILED_TEXT) {
|
||||||
|
message.error('生成失败,请稍后重试');
|
||||||
|
} else {
|
||||||
|
message.success('活动策划大纲生成完成');
|
||||||
}
|
}
|
||||||
message.success('活动策划大纲生成完成');
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
disconnectWebSocket();
|
message.error(e.message || '生成失败');
|
||||||
|
} finally {
|
||||||
|
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
|
||||||
|
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.error(e.message || '生成失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -764,7 +609,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disconnectWebSocket();
|
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -205,7 +205,6 @@
|
|||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { asBlob } from 'html-docx-js-typescript';
|
import { asBlob } from 'html-docx-js-typescript';
|
||||||
import { getProcessor } from 'bytemd';
|
import { getProcessor } from 'bytemd';
|
||||||
import { manuscriptGen } from '@/api/ai/manuscriptGen';
|
|
||||||
import { runManuscriptGenStream } from '@/utils/ai-stream';
|
import { runManuscriptGenStream } from '@/utils/ai-stream';
|
||||||
import type {
|
import type {
|
||||||
ManuscriptGenParams,
|
ManuscriptGenParams,
|
||||||
@@ -213,8 +212,6 @@
|
|||||||
} from '@/api/ai/manuscriptGen/model';
|
} from '@/api/ai/manuscriptGen/model';
|
||||||
import { uploadFile } from '@/api/system/file';
|
import { uploadFile } from '@/api/system/file';
|
||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
|
|
||||||
import { useUserStore } from '@/store/modules/user';
|
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
||||||
|
|
||||||
@@ -237,13 +234,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DemoType = 'news' | 'wechat' | 'speech' | 'free';
|
type DemoType = 'news' | 'wechat' | 'speech' | 'free';
|
||||||
|
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
|
||||||
type StreamResolver = {
|
type StreamResolver = {
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (error: Error) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
const userStore = useUserStore();
|
|
||||||
const plugins = ref([
|
const plugins = ref([
|
||||||
gfm({
|
gfm({
|
||||||
locale: zh_HansGfm
|
locale: zh_HansGfm
|
||||||
@@ -251,19 +247,13 @@
|
|||||||
highlight()
|
highlight()
|
||||||
]);
|
]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const wsConnected = ref(false);
|
|
||||||
const generatedTitle = ref('');
|
const generatedTitle = ref('');
|
||||||
const generatedContent = ref('');
|
const generatedContent = ref('');
|
||||||
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
||||||
const activeConversationId = ref<string>();
|
|
||||||
const webSocketRef = ref<WebSocket | null>(null);
|
|
||||||
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
|
|
||||||
const streamStarted = ref(false);
|
const streamStarted = ref(false);
|
||||||
const streamResolverRef = ref<StreamResolver | null>(null);
|
const streamResolverRef = ref<StreamResolver | null>(null);
|
||||||
const loginUser = computed(() => userStore.info ?? {});
|
|
||||||
|
|
||||||
const STREAM_END_TEXT = '__END__';
|
const STREAM_END_TEXT = '__END__';
|
||||||
const SOCKET_CONNECTED_TEXT = '连接成功';
|
|
||||||
const THINK_OPEN_TAG = '<think>';
|
const THINK_OPEN_TAG = '<think>';
|
||||||
const THINK_CLOSE_TAG = '</think>';
|
const THINK_CLOSE_TAG = '</think>';
|
||||||
|
|
||||||
@@ -506,25 +496,6 @@
|
|||||||
generatedContent.value = parsed.content;
|
generatedContent.value = parsed.content;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldTreatAsDirectResult = (payload: unknown) => {
|
|
||||||
if (!payload) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (typeof payload === 'string') {
|
|
||||||
const text = payload.trim();
|
|
||||||
if (!text) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
text.startsWith('{') ||
|
|
||||||
text.startsWith('[') ||
|
|
||||||
text.includes('\n') ||
|
|
||||||
text.length > 80
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return typeof payload === 'object';
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearStreamResolver = () => {
|
const clearStreamResolver = () => {
|
||||||
streamResolverRef.value = null;
|
streamResolverRef.value = null;
|
||||||
};
|
};
|
||||||
@@ -534,43 +505,17 @@
|
|||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectStream = (error: Error) => {
|
|
||||||
streamResolverRef.value?.reject(error);
|
|
||||||
clearStreamResolver();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createStreamWaiter = () => {
|
const createStreamWaiter = () => {
|
||||||
streamStarted.value = false;
|
streamStarted.value = false;
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve) => {
|
||||||
streamResolverRef.value = { resolve, reject };
|
streamResolverRef.value = { resolve };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const disconnectWebSocket = () => {
|
const appendAssistantChunk = (chunk: string, reset?: boolean) => {
|
||||||
wsConnectPromiseRef.value = null;
|
|
||||||
wsConnected.value = false;
|
|
||||||
const socket = webSocketRef.value;
|
|
||||||
webSocketRef.value = null;
|
|
||||||
if (
|
|
||||||
socket &&
|
|
||||||
(socket.readyState === WebSocket.OPEN ||
|
|
||||||
socket.readyState === WebSocket.CONNECTING)
|
|
||||||
) {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendAssistantChunk = (
|
|
||||||
chunk: string,
|
|
||||||
conversationId?: string,
|
|
||||||
reset?: boolean
|
|
||||||
) => {
|
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (conversationId) {
|
|
||||||
activeConversationId.value = conversationId;
|
|
||||||
}
|
|
||||||
if (!streamStarted.value) {
|
if (!streamStarted.value) {
|
||||||
streamStarted.value = true;
|
streamStarted.value = true;
|
||||||
generatedTitle.value = generatedTitle.value || form.theme.trim();
|
generatedTitle.value = generatedTitle.value || form.theme.trim();
|
||||||
@@ -586,15 +531,11 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishStream = (conversationId?: string) => {
|
const finishStream = () => {
|
||||||
if (conversationId) {
|
|
||||||
activeConversationId.value = conversationId;
|
|
||||||
}
|
|
||||||
const payload = parseSerializedResult(generatedContent.value);
|
const payload = parseSerializedResult(generatedContent.value);
|
||||||
applyParsedResult(payload);
|
applyParsedResult(payload);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
resolveStream();
|
resolveStream();
|
||||||
disconnectWebSocket();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSocketMessage = (raw: string) => {
|
const handleSocketMessage = (raw: string) => {
|
||||||
@@ -604,7 +545,7 @@
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
if (data?.answer === STREAM_END_TEXT) {
|
if (data?.answer === STREAM_END_TEXT) {
|
||||||
finishStream(data.conversationId);
|
finishStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data?.loading === 'true' || data?.loading === true) {
|
if (data?.loading === 'true' || data?.loading === true) {
|
||||||
@@ -612,95 +553,13 @@
|
|||||||
}
|
}
|
||||||
const chunk = pickRawStreamString(data?.notice, data?.answer);
|
const chunk = pickRawStreamString(data?.notice, data?.answer);
|
||||||
if (typeof chunk === 'string' && chunk.length > 0) {
|
if (typeof chunk === 'string' && chunk.length > 0) {
|
||||||
appendAssistantChunk(
|
appendAssistantChunk(chunk, data?.reset === 'true' || data?.reset === true);
|
||||||
chunk,
|
|
||||||
data?.conversationId,
|
|
||||||
data?.reset === 'true' || data?.reset === true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appendAssistantChunk(raw);
|
appendAssistantChunk(raw);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectWebSocket = async () => {
|
|
||||||
const userId = loginUser.value.userId;
|
|
||||||
if (!userId) {
|
|
||||||
return Promise.reject(new Error('未获取到当前登录用户'));
|
|
||||||
}
|
|
||||||
const currentSocket = webSocketRef.value;
|
|
||||||
if (currentSocket?.readyState === WebSocket.OPEN && wsConnected.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(currentSocket?.readyState === WebSocket.OPEN ||
|
|
||||||
currentSocket?.readyState === WebSocket.CONNECTING) &&
|
|
||||||
wsConnectPromiseRef.value
|
|
||||||
) {
|
|
||||||
return wsConnectPromiseRef.value;
|
|
||||||
}
|
|
||||||
let cleanupPending = () => undefined;
|
|
||||||
const connectPromise = new Promise<void>((resolve, reject) => {
|
|
||||||
let settled = false;
|
|
||||||
const socket = new WebSocket(buildWebSocketUrl(userId));
|
|
||||||
cleanupPending = () => {
|
|
||||||
if (wsConnectPromiseRef.value === connectPromise) {
|
|
||||||
wsConnectPromiseRef.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.onopen = () => {
|
|
||||||
attachHeartbeat(socket);
|
|
||||||
wsConnected.value = false;
|
|
||||||
};
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
const raw = String(event.data || '');
|
|
||||||
if (raw === SOCKET_CONNECTED_TEXT) {
|
|
||||||
wsConnected.value = true;
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSocketMessage(raw);
|
|
||||||
};
|
|
||||||
socket.onclose = () => {
|
|
||||||
wsConnected.value = false;
|
|
||||||
if (webSocketRef.value === socket) {
|
|
||||||
webSocketRef.value = null;
|
|
||||||
}
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
reject(new Error('稿件生成连接已关闭'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loading.value) {
|
|
||||||
rejectStream(new Error('稿件生成连接已关闭'));
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
cleanupPending();
|
|
||||||
};
|
|
||||||
socket.onerror = () => {
|
|
||||||
wsConnected.value = false;
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
reject(new Error('稿件生成连接失败'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loading.value) {
|
|
||||||
rejectStream(new Error('稿件生成连接失败'));
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
webSocketRef.value = socket;
|
|
||||||
});
|
|
||||||
wsConnectPromiseRef.value = connectPromise;
|
|
||||||
return connectPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearGenerated = () => {
|
const clearGenerated = () => {
|
||||||
generatedTitle.value = '';
|
generatedTitle.value = '';
|
||||||
generatedContent.value = '';
|
generatedContent.value = '';
|
||||||
@@ -835,12 +694,21 @@
|
|||||||
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
|
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
|
||||||
resolveStream();
|
resolveStream();
|
||||||
await waitStream;
|
await waitStream;
|
||||||
message.success('内容生成完成');
|
const text = markdownContent.value.trim();
|
||||||
|
if (!text) {
|
||||||
|
message.warning('这次没有生成出内容,请重试');
|
||||||
|
} else if (text === FAILED_TEXT) {
|
||||||
|
message.error('生成失败,请稍后重试');
|
||||||
|
} else {
|
||||||
|
message.success('内容生成完成');
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
disconnectWebSocket();
|
message.error(e.message || '内容生成失败');
|
||||||
|
} finally {
|
||||||
|
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
|
||||||
|
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.error(e.message || '内容生成失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -972,7 +840,6 @@
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
disconnectWebSocket();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user