diff --git a/src/views/gxmu/activityOutlineGen/index.vue b/src/views/gxmu/activityOutlineGen/index.vue index 75a01e0..3f96380 100644 --- a/src/views/gxmu/activityOutlineGen/index.vue +++ b/src/views/gxmu/activityOutlineGen/index.vue @@ -174,21 +174,18 @@ import { saveAs } from 'file-saver'; 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 { 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'; + import { FAILED_TEXT } from '@/utils/ai-result'; type ExampleType = 'volunteer' | 'salon' | 'sports'; + /** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */ type StreamResolver = { resolve: () => void; - reject: (error: Error) => void; }; interface FormState { @@ -201,7 +198,6 @@ event_meme: string; } - const userStore = useUserStore(); const plugins = ref([ gfm({ locale: zh_HansGfm @@ -213,16 +209,10 @@ const result = ref(null); const outlineMarkdown = ref(''); const editableOutlineMarkdown = ref(''); - const activeConversationId = ref(); - const activeMessageId = ref(); - const webSocketRef = ref(null); - const wsConnectPromiseRef = ref | null>(null); const streamStarted = ref(false); const streamResolverRef = ref(null); - const loginUser = computed(() => userStore.info ?? {}); const STREAM_END_TEXT = '__END__'; - const SOCKET_CONNECTED_TEXT = '连接成功'; const THINK_OPEN_TAG = ''; const THINK_CLOSE_TAG = ''; @@ -356,38 +346,6 @@ // pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer // 当正文。现在统一走 @/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 = () => { streamResolverRef.value = null; }; @@ -397,46 +355,17 @@ clearStreamResolver(); }; - const rejectStream = (error: Error) => { - streamResolverRef.value?.reject(error); - clearStreamResolver(); - }; - const createStreamWaiter = () => { streamStarted.value = false; - return new Promise((resolve, reject) => { - streamResolverRef.value = { resolve, reject }; + return new Promise((resolve) => { + streamResolverRef.value = { resolve }; }); }; - const disconnectWebSocket = () => { - 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 - ) => { + const appendOutlineChunk = (chunk: string, reset?: boolean) => { if (!chunk) { return; } - if (conversationId && conversationId !== '0') { - activeConversationId.value = conversationId; - } - if (messageId && messageId !== '0') { - activeMessageId.value = messageId; - } if (!streamStarted.value) { streamStarted.value = true; outlineMarkdown.value = ''; @@ -452,16 +381,9 @@ syncEditableOutline(); }; - const finishStream = (conversationId?: string, messageId?: string) => { - if (conversationId && conversationId !== '0') { - activeConversationId.value = conversationId; - } - if (messageId && messageId !== '0') { - activeMessageId.value = messageId; - } + const finishStream = () => { loading.value = false; resolveStream(); - disconnectWebSocket(); }; const handleSocketMessage = (raw: string) => { @@ -471,7 +393,7 @@ try { const data = JSON.parse(raw); if (data?.answer === STREAM_END_TEXT) { - finishStream(data?.conversationId, data?.messageId); + finishStream(); return; } if (data?.loading === 'true' || data?.loading === true) { @@ -479,99 +401,17 @@ } const chunk = pickRawString(data?.notice, data?.answer); if (typeof chunk === 'string') { - appendOutlineChunk( - chunk, - data?.conversationId, - data?.messageId, - data?.reset === 'true' || data?.reset === true - ); + appendOutlineChunk(chunk, data?.reset === 'true' || data?.reset === true); } } catch (e) { 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((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 = () => { result.value = null; outlineMarkdown.value = ''; editableOutlineMarkdown.value = ''; - activeConversationId.value = undefined; - activeMessageId.value = undefined; }; const resetForm = () => { @@ -605,16 +445,21 @@ // 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中 resolveStream(); await waitStream; - if (!visibleOutlineMarkdown.value) { - message.warning('接口已返回,但未解析到正文内容'); - return; + const text = visibleOutlineMarkdown.value.trim(); + if (!text) { + message.warning('这次没有生成出内容,请重试'); + } else if (text === FAILED_TEXT) { + message.error('生成失败,请稍后重试'); + } else { + message.success('活动策划大纲生成完成'); } - message.success('活动策划大纲生成完成'); } catch (e: any) { - disconnectWebSocket(); + message.error(e.message || '生成失败'); + } finally { + // 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、 + // 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。 clearStreamResolver(); loading.value = false; - message.error(e.message || '生成失败'); } }; @@ -764,7 +609,6 @@ }; onBeforeUnmount(() => { - disconnectWebSocket(); clearStreamResolver(); }); diff --git a/src/views/gxmu/manuscriptGen/index.vue b/src/views/gxmu/manuscriptGen/index.vue index 9be5260..58df50e 100644 --- a/src/views/gxmu/manuscriptGen/index.vue +++ b/src/views/gxmu/manuscriptGen/index.vue @@ -205,7 +205,6 @@ import { saveAs } from 'file-saver'; 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, @@ -213,8 +212,6 @@ } from '@/api/ai/manuscriptGen/model'; import { uploadFile } from '@/api/system/file'; 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 { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result'; @@ -237,13 +234,12 @@ } type DemoType = 'news' | 'wechat' | 'speech' | 'free'; + /** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */ type StreamResolver = { resolve: () => void; - reject: (error: Error) => void; }; const { push } = useRouter(); - const userStore = useUserStore(); const plugins = ref([ gfm({ locale: zh_HansGfm @@ -251,19 +247,13 @@ highlight() ]); const loading = ref(false); - const wsConnected = ref(false); const generatedTitle = ref(''); const generatedContent = ref(''); const uploadedFiles = ref[]>([]); - const activeConversationId = ref(); - const webSocketRef = ref(null); - const wsConnectPromiseRef = ref | null>(null); const streamStarted = ref(false); const streamResolverRef = ref(null); - const loginUser = computed(() => userStore.info ?? {}); const STREAM_END_TEXT = '__END__'; - const SOCKET_CONNECTED_TEXT = '连接成功'; const THINK_OPEN_TAG = ''; const THINK_CLOSE_TAG = ''; @@ -506,25 +496,6 @@ 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 = () => { streamResolverRef.value = null; }; @@ -534,43 +505,17 @@ clearStreamResolver(); }; - const rejectStream = (error: Error) => { - streamResolverRef.value?.reject(error); - clearStreamResolver(); - }; - const createStreamWaiter = () => { streamStarted.value = false; - return new Promise((resolve, reject) => { - streamResolverRef.value = { resolve, reject }; + return new Promise((resolve) => { + streamResolverRef.value = { resolve }; }); }; - const disconnectWebSocket = () => { - 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 - ) => { + const appendAssistantChunk = (chunk: string, reset?: boolean) => { if (!chunk) { return; } - if (conversationId) { - activeConversationId.value = conversationId; - } if (!streamStarted.value) { streamStarted.value = true; generatedTitle.value = generatedTitle.value || form.theme.trim(); @@ -586,15 +531,11 @@ } }; - const finishStream = (conversationId?: string) => { - if (conversationId) { - activeConversationId.value = conversationId; - } + const finishStream = () => { const payload = parseSerializedResult(generatedContent.value); applyParsedResult(payload); loading.value = false; resolveStream(); - disconnectWebSocket(); }; const handleSocketMessage = (raw: string) => { @@ -604,7 +545,7 @@ try { const data = JSON.parse(raw); if (data?.answer === STREAM_END_TEXT) { - finishStream(data.conversationId); + finishStream(); return; } if (data?.loading === 'true' || data?.loading === true) { @@ -612,95 +553,13 @@ } const chunk = pickRawStreamString(data?.notice, data?.answer); if (typeof chunk === 'string' && chunk.length > 0) { - appendAssistantChunk( - chunk, - data?.conversationId, - data?.reset === 'true' || data?.reset === true - ); + appendAssistantChunk(chunk, data?.reset === 'true' || data?.reset === true); } } catch (e) { 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((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 = () => { generatedTitle.value = ''; generatedContent.value = ''; @@ -835,12 +694,21 @@ // 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中 resolveStream(); 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) { - disconnectWebSocket(); + message.error(e.message || '内容生成失败'); + } finally { + // 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、 + // 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。 clearStreamResolver(); loading.value = false; - message.error(e.message || '内容生成失败'); } }; @@ -972,7 +840,6 @@ onBeforeUnmount(() => { clearStreamResolver(); - disconnectWebSocket(); });