From ec66196a2b8c85c988708c4d33106a25e0e62a63 Mon Sep 17 00:00:00 2001 From: wangdan-fit2cloud Date: Tue, 10 Nov 2026 14:58:21 +0800 Subject: [PATCH] feat: chat speak --- ui/src/assets/iconfont.js | 2 +- .../components/message-footer/index.vue | 54 ++++++++++++++++++- .../components/message-list/index.ts | 21 +++++++- ui/src/conversation-panel/index.scss | 3 +- ui/src/styles/STYLE_README.md | 3 +- ui/src/views/VIEW_README.md | 7 +++ 6 files changed, 85 insertions(+), 5 deletions(-) diff --git a/ui/src/assets/iconfont.js b/ui/src/assets/iconfont.js index 35c0df4b29a..28ffd0a0218 100644 --- a/ui/src/assets/iconfont.js +++ b/ui/src/assets/iconfont.js @@ -1 +1 @@ -window._iconfont_svg_string_5200172='',(h=>{var a=(l=(l=document.getElementsByTagName("script"))[l.length-1]).getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var o,v,t,i,c,d=function(a,l){l.parentNode.insertBefore(a,l)};if(a&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}o=function(){var a,l=document.createElement("div");l.innerHTML=h._iconfont_svg_string_5200172,(l=l.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",l=l,(a=document.body).firstChild?d(l,a.firstChild):a.appendChild(l))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(v=function(){document.removeEventListener("DOMContentLoaded",v,!1),o()},document.addEventListener("DOMContentLoaded",v,!1)):document.attachEvent&&(t=o,i=h.document,c=!1,z(),i.onreadystatechange=function(){"complete"==i.readyState&&(i.onreadystatechange=null,m())})}function m(){c||(c=!0,t())}function z(){try{i.documentElement.doScroll("left")}catch(a){return void setTimeout(z,50)}m()}})(window); \ No newline at end of file +window._iconfont_svg_string_5200172='',(h=>{var a=(l=(l=document.getElementsByTagName("script"))[l.length-1]).getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var o,v,t,i,c,d=function(a,l){l.parentNode.insertBefore(a,l)};if(a&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}o=function(){var a,l=document.createElement("div");l.innerHTML=h._iconfont_svg_string_5200172,(l=l.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",l=l,(a=document.body).firstChild?d(l,a.firstChild):a.appendChild(l))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(v=function(){document.removeEventListener("DOMContentLoaded",v,!1),o()},document.addEventListener("DOMContentLoaded",v,!1)):document.attachEvent&&(t=o,i=h.document,c=!1,m(),i.onreadystatechange=function(){"complete"==i.readyState&&(i.onreadystatechange=null,z())})}function z(){c||(c=!0,t())}function m(){try{i.documentElement.doScroll("left")}catch(a){return void setTimeout(m,50)}z()}})(window); \ No newline at end of file diff --git a/ui/src/conversation-panel/components/message-footer/index.vue b/ui/src/conversation-panel/components/message-footer/index.vue index c67ed3eff7e..3f6312d45fc 100644 --- a/ui/src/conversation-panel/components/message-footer/index.vue +++ b/ui/src/conversation-panel/components/message-footer/index.vue @@ -57,6 +57,41 @@ function collectAnswerText(contents: ChatContent[]): string { } const answerText = computed(() => collectAnswerText(props.message.content)) +// 重新生成沿用当前记录及原问题,由列表 Store 管理流式替换。 +const canRegenerate = computed(() => Boolean(messageList && props.message.recordId && !messageList.streamLoading.value && !messageList.loading.value)) +function regenerateAnswer() { + if (!canRegenerate.value) return + stopReading() + messageList?.regenerate(props.message) +} + +// 浏览器朗读仅使用回答正文;切换回答或卸载时释放本组件发起的语音。 +const speechSupported = typeof window !== 'undefined' && 'speechSynthesis' in window +const speaking = ref(false) +let utterance: SpeechSynthesisUtterance | undefined +function stopReading() { + if (utterance) { + utterance.onend = null + utterance.onerror = null + window.speechSynthesis.cancel() + utterance = undefined + } + speaking.value = false +} +function toggleReading() { + if (speaking.value) return stopReading() + if (!speechSupported || !answerText.value) return + window.speechSynthesis.cancel() + utterance = new SpeechSynthesisUtterance(answerText.value) + utterance.onend = utterance.onerror = () => { + speaking.value = false + utterance = undefined + } + speaking.value = true + window.speechSynthesis.speak(utterance) +} +onBeforeUnmount(stopReading) + const openExecutionDetail = () => { detail.showExecutionDetail(meta.value.executionDetails || []) } @@ -125,6 +160,24 @@ function openFeedback(status: '0' | '1') { + + + + + + + + + + + + + + + + + + @@ -133,7 +186,6 @@ function openFeedback(status: '0' | '1') { - {{ datetimeFormat(meta.createTime) }} diff --git a/ui/src/conversation-panel/components/message-list/index.ts b/ui/src/conversation-panel/components/message-list/index.ts index 599021fef9c..bcd04e0b5ee 100644 --- a/ui/src/conversation-panel/components/message-list/index.ts +++ b/ui/src/conversation-panel/components/message-list/index.ts @@ -169,7 +169,9 @@ export function createMessageListStore(deps: MessageListDeps) { } const target = opts.newQuestion ? messages.value[messages.value.length - 1] - : messages.value.find((message) => message.id === `${opts.chatRecordId}_ASSISTANT`) || messages.value[messages.value.length - 1] + : messages.value.find( + (message) => (!!opts.chatRecordId && message.recordId === opts.chatRecordId) || message.id === `${opts.chatRecordId}_ASSISTANT`, + ) || messages.value[messages.value.length - 1] if (!target) return const payload: Dict = { message: opts.message, stream: true, re_chat: opts.reChat ?? false } @@ -178,10 +180,17 @@ export function createMessageListStore(deps: MessageListDeps) { if (opts.chatRecordId) payload.chat_record_id = opts.chatRecordId if (opts.chunkId) payload.chunk_id = opts.chunkId + let replacementStarted = false runStream( () => chatMessage(cid, payload), (chunk) => { if (chunk?.chat_record_id && !target.recordId) target.recordId = chunk.chat_record_id + if (opts.reChat && !replacementStarted) { + target.content = [] + target.record = undefined + target.write_ed = false + replacementStarted = true + } appendChunk(target, chunk) opts.onNext?.(chunk) }, @@ -193,6 +202,15 @@ export function createMessageListStore(deps: MessageListDeps) { ) } + // 原回答保留到重新生成收到首个分片,避免请求失败时丢失已展示内容。 + const regenerate = (message: ChatMessage) => { + if (streamLoading.value || loading.value || !message.recordId) return + const index = messages.value.indexOf(message) + const question = messages.value[index - 1] + if (index < 1 || question?.role !== 'USER' || !question.content[0]) return + sendMessage({ message: question.content[0], newQuestion: false, reChat: true, chatRecordId: message.recordId }) + } + // 优雅停止:仅通知后端取消,本地流继续读到 [DONE] 自行结束 const stop = () => { const cid = getChatId() @@ -245,6 +263,7 @@ export function createMessageListStore(deps: MessageListDeps) { load, loadMore, sendMessage, + regenerate, uploadFile, onStreamProgress, stop, diff --git a/ui/src/conversation-panel/index.scss b/ui/src/conversation-panel/index.scss index b607d42a0e6..f29dc494ddc 100644 --- a/ui/src/conversation-panel/index.scss +++ b/ui/src/conversation-panel/index.scss @@ -15,8 +15,9 @@ /* 对话正文:消息列表与底部输入区共用宽度和居中布局。 */ .mk-conversation-content { + box-sizing: border-box; margin-inline: auto; - max-width: 800px; + max-width: calc(800px + var(--spacing) * 8); width: 100%; } diff --git a/ui/src/styles/STYLE_README.md b/ui/src/styles/STYLE_README.md index 6521dd5f728..80288a90b93 100644 --- a/ui/src/styles/STYLE_README.md +++ b/ui/src/styles/STYLE_README.md @@ -39,7 +39,8 @@ Element Plus 覆盖、主题变量和应用样式。Chat 入口还显式引入 E 也不额外设置 `min-height: 0`、`height: 100%` 或 `max-height: 100%`,消息列表负责内部滚动。 消息列表与底部输入区可共用 `mk-conversation-content`,在对话模块公共样式中统一设置 -宽度 100%、最大宽度 800px 和水平居中。 +`border-box`、宽度 100% 和水平居中。调用方配合 `p-4`,最大外宽为 +`800px + var(--spacing) * 8`,使正文最大宽度保持 800px,窄容器下连同左右内边距一起收缩。 用户提问容器使用 `mk-conversation-question-content`,在对话模块公共样式中控制靠右、 随内容收缩及最大 85% 宽度。文字气泡使用 `mk-gray-card-lg`,换行规则在模板中组合工具类。 diff --git a/ui/src/views/VIEW_README.md b/ui/src/views/VIEW_README.md index c4dbe8a6bc3..9efd3a9ada3 100644 --- a/ui/src/views/VIEW_README.md +++ b/ui/src/views/VIEW_README.md @@ -520,6 +520,13 @@ System 资源管理的修正内容与添加知识库选项仅包含当前智能 门户复用 Chat 分享 Store,抽屉点击分享后关闭并进入消息选择模式。 Chat/Portal 视图将左栏布局模式绑定到会话列表 Store;开启分享时,抽屉模式自动收起左栏。 +### 对话消息操作 + +消息 Footer 在复制后提供重新生成和朗读内容入口。列表 Store 的 `regenerate(message)` 沿用 +原问题与记录 ID,生成中及历史加载中禁用;收到首个分片后替换原回答,请求失败前保留原内容。 +朗读使用浏览器语音合成,仅收集回答正文,不包含思考与工具参数;再次点击停止,卸载时清理语音。 +不支持语音合成的浏览器禁用朗读按钮。 + ### 对话消息反馈 `conversation-panel/components/message-footer/feedback/` 内的 `ButtonFeedback.vue` 和 `FeedbackCard.vue`