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`