From d087ee6b35c0d771df1509850702e4bcdc8279ce Mon Sep 17 00:00:00 2001 From: zoujing Date: Wed, 20 May 2026 15:26:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=95=BF=E6=96=87=E6=9C=AC=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E5=AF=B9=E6=8E=A5=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatMain/ChatLongAnswer/index.vue | 83 +++++++++++-- src/pages/ChatMain/ChatMainList/index.vue | 36 ++++-- .../ChatModule/AnswerComponent/index.vue | 105 +++++++++++----- src/request/base/config.js | 6 +- src/utils/StreamManager.js | 34 +++--- src/utils/longTextCard.js | 115 ++++++++++++++++++ 6 files changed, 316 insertions(+), 63 deletions(-) create mode 100644 src/utils/longTextCard.js diff --git a/src/pages/ChatMain/ChatLongAnswer/index.vue b/src/pages/ChatMain/ChatLongAnswer/index.vue index 6667bfa..3d90e1f 100644 --- a/src/pages/ChatMain/ChatLongAnswer/index.vue +++ b/src/pages/ChatMain/ChatLongAnswer/index.vue @@ -8,8 +8,19 @@ - - + @@ -21,9 +32,13 @@ - \ No newline at end of file + diff --git a/src/pages/ChatMain/ChatMainList/index.vue b/src/pages/ChatMain/ChatMainList/index.vue index 1e5a72e..1f8c93d 100644 --- a/src/pages/ChatMain/ChatMainList/index.vue +++ b/src/pages/ChatMain/ChatMainList/index.vue @@ -76,8 +76,7 @@ > { messageId: currentSessionMessageId, replyMessageId: data.replyMessageId || "", componentName: "", - title: "", + longTextData: null, finish: false, }; chatMsgList.value.push(aiMsg); @@ -803,7 +806,7 @@ const handleWebSocketMessage = (data) => { messageId: currentSessionMessageId, replyMessageId: data.replyMessageId || "", componentName: "", - title: "", + longTextData: null, finish: false, }; chatMsgList.value.push(aiMsg); @@ -842,13 +845,30 @@ const handleWebSocketMessage = (data) => { if (data.componentName) { aiItem.componentName = data.componentName; if (data.componentName === CompName.longTextCard) { + aiItem.longTextData = aiItem.longTextData || createLongTextData(); if (aiItem.msg && aiItem.msg.length > 0) { - aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg; + if (!aiItem.isLoading) { + aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg; + } aiItem.msg = ""; } } } + const isLongText = + aiItem.componentName === CompName.longTextCard || + data.componentName === CompName.longTextCard; + + if (isLongText && data.contentKey) { + aiItem.longTextData = aiItem.longTextData || createLongTextData(); + appendLongTextChunk(aiItem.longTextData, data); + if (aiItem.isLoading) { + aiItem.msg = ""; + aiItem.isLoading = false; + } + nextTick(() => scrollToBottom()); + } + // 确保消息内容是字符串类型 if (data.content && typeof data.content !== "string") { try { @@ -861,9 +881,6 @@ const handleWebSocketMessage = (data) => { // 直接拼接内容到对应 AI 消息 if (data.content) { // 如果该条消息属于 longTextCard,使用 componentMsg 存储内容并保持 ChatCardAI 的 text 为空 - const isLongText = - aiItem.componentName === CompName.longTextCard || - data.componentName === CompName.longTextCard; if (isLongText) { if (aiItem.isLoading) { aiItem.componentMsg = (aiItem.componentMsg || "") + data.content; @@ -900,7 +917,6 @@ const handleWebSocketMessage = (data) => { // 处理组件调用 if (data.componentName) { - chatMsgList.value[aiMsgIndex].title = data.content; chatMsgList.value[aiMsgIndex].componentName = data.componentName; } @@ -1173,7 +1189,7 @@ const sendChat = async (message, isInstruct = false) => { messageId: currentSessionMessageId, replyMessageId: "", componentName: "", - title: "", + longTextData: null, finish: false, }; chatMsgList.value.push(aiMsg); diff --git a/src/pages/ChatModule/AnswerComponent/index.vue b/src/pages/ChatModule/AnswerComponent/index.vue index a07c9e8..8f5c7e4 100644 --- a/src/pages/ChatModule/AnswerComponent/index.vue +++ b/src/pages/ChatModule/AnswerComponent/index.vue @@ -2,22 +2,23 @@ - + + {{ tag }} {{ title }} - - + + 正在生成 - - 查看详情 + + 查看完整{{ tag }} @@ -32,16 +33,21 @@ import { defineProps, computed, watch, onBeforeUnmount } from "vue"; import ChatMarkdown from "../../ChatMain/ChatMarkdown/index.vue"; import ChatLoading from "../../ChatMain/ChatLoading/index.vue"; import StreamManager from '@/utils/StreamManager.js'; +import { + getLongTextPreviewText, + getLongTextValue, + hasLongTextExtraSections, +} from "@/utils/longTextCard"; // 直接根据文字长度判断,超过约100个字符认为会溢出(约3行) const props = defineProps({ - title: { + content: { type: String, default: "", }, - text: { - type: String, - default: "", + longTextData: { + type: Object, + default: null, }, finish: { type: Boolean, @@ -49,16 +55,22 @@ const props = defineProps({ }, }); +const tag = computed(() => getLongTextValue(props.longTextData, "tag")); +const title = computed(() => getLongTextValue(props.longTextData, "title")); +const previewContent = computed(() => { + return getLongTextPreviewText(props.longTextData) || (props.content ? String(props.content) : ""); +}); + // 处理文本内容:按行截断以保证预览最多显示三行(更贴近视觉行数) // 点击“查看详情”会跳转到完整页面(不受预览截断影响)。 const PREVIEW_LINES = 3; const PREVIEW_CHAR_LIMIT = 100; // 作为备用,当没有换行但过长时也会截断 -const processedText = computed(() => { - const txt = props.text ? String(props.text) : ""; - if (!txt) return ""; +const processedContent = computed(() => { + const content = previewContent.value ? String(previewContent.value) : ""; + if (!content) return ""; // 按行分割(保留空行) - const lines = txt.split(/\r?\n/); + const lines = content.split(/\r?\n/); // 如果行数超过限制,截取前 PREVIEW_LINES 行并添加省略号 if (lines.length > PREVIEW_LINES) { @@ -66,21 +78,26 @@ const processedText = computed(() => { } // 若虽然行数不超过,但总长度仍然很长,做字符级截断作为兜底 - if (txt.length > PREVIEW_CHAR_LIMIT) { - return txt.slice(0, PREVIEW_CHAR_LIMIT) + "..."; + if (content.length > PREVIEW_CHAR_LIMIT) { + return content.slice(0, PREVIEW_CHAR_LIMIT) + "..."; } - return txt; + return content; }); const isOverflow = computed(() => { - const textStr = props.text ? String(props.text) : ""; - const lines = textStr.split(/\r?\n/); - return lines.length > PREVIEW_LINES || textStr.length > PREVIEW_CHAR_LIMIT; + const contentStr = previewContent.value ? String(previewContent.value) : ""; + const lines = contentStr.split(/\r?\n/); + return ( + hasLongTextExtraSections(props.longTextData) || + lines.length > PREVIEW_LINES || + contentStr.length > PREVIEW_CHAR_LIMIT + ); }); let stopForwardWatcher = null; let stopFinishWatcher = null; +let stopLongTextWatcher = null; const cleanupStreamWatchers = () => { if (stopForwardWatcher) { @@ -91,30 +108,51 @@ const cleanupStreamWatchers = () => { stopFinishWatcher(); stopFinishWatcher = null; } + if (stopLongTextWatcher) { + stopLongTextWatcher(); + stopLongTextWatcher = null; + } }; onBeforeUnmount(cleanupStreamWatchers); const lookDetailAction = () => { - const message = props.text ? String(props.text) : ""; + const message = previewContent.value ? String(previewContent.value) : ""; // 使用 StreamManager 以 streamId 转发当前及后续流式更新,详情页通过 streamId 订阅 const streamId = `stream_${Date.now()}_${Math.random().toString(36).slice(2,8)}`; - StreamManager.openStream(streamId, message, !!props.finish); + const updateStream = () => { + StreamManager.updateStream( + streamId, + previewContent.value ? String(previewContent.value) : "", + !!props.finish, + props.longTextData || null, + ); + }; + + StreamManager.openStream(streamId, message, !!props.finish, props.longTextData || null); cleanupStreamWatchers(); if (!props.finish) { - // 将当前组件后续 props.text/props.finish 的更新转发到 StreamManager + // 将当前组件后续 props.content/props.finish 的更新转发到 StreamManager stopForwardWatcher = watch( - () => props.text, - (v) => { - StreamManager.updateStream(streamId, v ? String(v) : "", !!props.finish); - } + () => props.content, + updateStream + ); + stopLongTextWatcher = watch( + () => props.longTextData, + updateStream, + { deep: true } ); stopFinishWatcher = watch( () => props.finish, (f) => { - StreamManager.updateStream(streamId, props.text ? String(props.text) : "", !!f); + StreamManager.updateStream( + streamId, + previewContent.value ? String(previewContent.value) : "", + !!f, + props.longTextData || null, + ); if (f) { cleanupStreamWatchers(); } @@ -142,7 +180,16 @@ const lookDetailAction = () => { line-height: 16px; max-height: 80px; } -.border-left-4 { - border-left: 4px solid $theme-color-500; +.long-answer-tag { + display: inline-flex; + width: fit-content; + margin-bottom: 8px; + padding: 3px 8px; + border-radius: 12px; + border: 1px solid rgba($theme-color-500, 0.2); + background: rgba($theme-color-500, 0.08); + color: $theme-color-500; + font-size: 12px; + line-height: 18px; } diff --git a/src/request/base/config.js b/src/request/base/config.js index 4f022c2..7de39e0 100644 --- a/src/request/base/config.js +++ b/src/request/base/config.js @@ -15,8 +15,10 @@ const getEvnUrl = async () => { if (developVersion) { const appStore = useAppStore(); appStore.setServerConfig({ - baseUrl: "https://abroadbiz.nianxx.com/ingress", // 服务器基础地址 - wssUrl: "wss://abroadbiz.nianxx.com/ingress/agent/ws/chat", // 服务器wss地址 + // baseUrl: "https://abroadbiz.nianxx.com/ingress", // 服务器基础地址 + // wssUrl: "wss://abroadbiz.nianxx.com/ingress/agent/ws/chat", // 服务器wss地址 + baseUrl: devUrl, // 服务器基础地址 + wssUrl: wssDevUrl, // 服务器wss地址 }); return; } diff --git a/src/utils/StreamManager.js b/src/utils/StreamManager.js index b98e08b..4b8c26a 100644 --- a/src/utils/StreamManager.js +++ b/src/utils/StreamManager.js @@ -1,28 +1,34 @@ // 简单的流式数据管理器:开启流、更新流、订阅流、关闭流 const streams = {}; -function openStream(id, initial = '', finished = false) { - if (!id) return; - streams[id] = streams[id] || { text: '', finished: false, subs: new Set() }; - streams[id].text = initial || ''; - streams[id].finished = !!finished; - // notify existing subscribers - streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished)); +function notify(stream) { + stream.subs.forEach((cb) => cb(stream.text, stream.finished, stream.payload)); } -function updateStream(id, text, finished = false) { +function openStream(id, initial = '', finished = false, payload = null) { + if (!id) return; + streams[id] = streams[id] || { text: '', finished: false, payload: null, subs: new Set() }; + streams[id].text = initial || ''; + streams[id].finished = !!finished; + streams[id].payload = payload || null; + // notify existing subscribers + notify(streams[id]); +} + +function updateStream(id, text, finished = false, payload = null) { if (!id || !streams[id]) return; streams[id].text = text || ''; streams[id].finished = !!finished; - streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished)); + streams[id].payload = payload || null; + notify(streams[id]); } function subscribe(id, cb) { if (!id) return () => {}; - streams[id] = streams[id] || { text: '', finished: false, subs: new Set() }; + streams[id] = streams[id] || { text: '', finished: false, payload: null, subs: new Set() }; streams[id].subs.add(cb); // send current snapshot immediately - cb(streams[id].text, streams[id].finished); + cb(streams[id].text, streams[id].finished, streams[id].payload); return () => { streams[id] && streams[id].subs.delete(cb); // 移除空流 @@ -34,13 +40,13 @@ function subscribe(id, cb) { function closeStream(id) { if (!id || !streams[id]) return; - streams[id].subs.forEach((cb) => cb(streams[id].text, true)); + streams[id].subs.forEach((cb) => cb(streams[id].text, true, streams[id].payload)); delete streams[id]; } function getSnapshot(id) { - if (!id || !streams[id]) return { text: '', finished: false }; - return { text: streams[id].text, finished: streams[id].finished }; + if (!id || !streams[id]) return { text: '', finished: false, payload: null }; + return { text: streams[id].text, finished: streams[id].finished, payload: streams[id].payload }; } export default { diff --git a/src/utils/longTextCard.js b/src/utils/longTextCard.js new file mode 100644 index 0000000..3ba03be --- /dev/null +++ b/src/utils/longTextCard.js @@ -0,0 +1,115 @@ +export const LONG_TEXT_KEYS = { + tag: "tag", + title: "title", + content: "content", + checklist: "checklist", + suggest: "suggest", + commodity: "commodity", + actionZone: "action_zone", +}; + +export const LONG_TEXT_FIELD_CONFIG = [ + { key: LONG_TEXT_KEYS.tag }, + { key: LONG_TEXT_KEYS.title }, + { key: LONG_TEXT_KEYS.content }, + { key: LONG_TEXT_KEYS.checklist }, + { key: LONG_TEXT_KEYS.suggest }, + { key: LONG_TEXT_KEYS.commodity }, + { key: LONG_TEXT_KEYS.actionZone }, +]; + +export const LONG_TEXT_PREVIEW_KEYS = [ + LONG_TEXT_KEYS.content, + LONG_TEXT_KEYS.title, + LONG_TEXT_KEYS.tag, +]; + +const CONFIGURED_KEYS = LONG_TEXT_FIELD_CONFIG.map((item) => item.key); + +export const createLongTextData = () => ({ + values: {}, + parsedValues: {}, +}); + +const toText = (value) => { + if (value === undefined || value === null) return ""; + return typeof value === "string" ? value : String(value); +}; + +const shouldParseJSON = (raw) => { + if (!raw || typeof raw !== "string") return false; + return /^[\s]*[\[{]/.test(raw); +}; + +const tryParseJSON = (raw) => { + if (!shouldParseJSON(raw)) { + return { ok: false, value: null }; + } + try { + return { ok: true, value: JSON.parse(raw) }; + } catch (e) { + return { ok: false, value: null }; + } +}; + +export const appendLongTextChunk = (target, chunk = {}) => { + if (!target || !chunk.contentKey) return target; + + const key = String(chunk.contentKey); + const value = toText(chunk.contentValue); + + if (!target.values) target.values = {}; + if (!target.parsedValues) target.parsedValues = {}; + + target.values[key] = (target.values[key] || "") + value; + + const parsed = tryParseJSON(target.values[key]); + if (parsed.ok) { + target.parsedValues[key] = parsed.value; + } else { + delete target.parsedValues[key]; + } + + return target; +}; + +export const getLongTextValue = (data, key) => { + if (!data || !data.values || !key) return ""; + return data.values[key] || ""; +}; + +export const getLongTextParsedValue = (data, key, fallback = undefined) => { + if (!data || !data.parsedValues || !key) return fallback; + return Object.prototype.hasOwnProperty.call(data.parsedValues, key) + ? data.parsedValues[key] + : fallback; +}; + +export const getLongTextPreviewText = (data, keys = LONG_TEXT_PREVIEW_KEYS) => { + for (const key of keys) { + const value = getLongTextValue(data, key); + if (value) return value; + } + return ""; +}; + +export const hasLongTextExtraSections = (data, previewKeys = LONG_TEXT_PREVIEW_KEYS) => { + if (!data || !data.values) return false; + return Object.keys(data.values).some((key) => !previewKeys.includes(key)); +}; + +export const getLongTextSections = (data) => { + if (!data || !data.values) return []; + + const extraKeys = Object.keys(data.values).filter( + (key) => !CONFIGURED_KEYS.includes(key), + ); + + return [...CONFIGURED_KEYS, ...extraKeys] + .filter((key) => Object.prototype.hasOwnProperty.call(data.values, key)) + .map((key) => ({ + contentKey: key, + contentValue: getLongTextValue(data, key), + parsedValue: getLongTextParsedValue(data, key, null), + })); +};