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 { 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<ActivityOutlineGenResponse | null>(null);
|
||||
const outlineMarkdown = 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 streamResolverRef = ref<StreamResolver | null>(null);
|
||||
const loginUser = computed(() => userStore.info ?? {});
|
||||
|
||||
const STREAM_END_TEXT = '__END__';
|
||||
const SOCKET_CONNECTED_TEXT = '连接成功';
|
||||
const THINK_OPEN_TAG = '<think>';
|
||||
const THINK_CLOSE_TAG = '</think>';
|
||||
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
streamResolverRef.value = { resolve, reject };
|
||||
return new Promise<void>((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<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 = () => {
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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<Partial<FileRecord>[]>([]);
|
||||
const activeConversationId = ref<string>();
|
||||
const webSocketRef = ref<WebSocket | null>(null);
|
||||
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
|
||||
const streamStarted = ref(false);
|
||||
const streamResolverRef = ref<StreamResolver | null>(null);
|
||||
const loginUser = computed(() => userStore.info ?? {});
|
||||
|
||||
const STREAM_END_TEXT = '__END__';
|
||||
const SOCKET_CONNECTED_TEXT = '连接成功';
|
||||
const THINK_OPEN_TAG = '<think>';
|
||||
const THINK_CLOSE_TAG = '</think>';
|
||||
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
streamResolverRef.value = { resolve, reject };
|
||||
return new Promise<void>((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<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 = () => {
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user