From 512d6815b2296c136f26f3a234862a52798c64f0 Mon Sep 17 00:00:00 2001 From: zhangzhanwei Date: Mon, 15 Jun 2026 14:57:13 +0800 Subject: [PATCH 1/2] fix: Tool card permission --- ui/src/views/tool/component/ToolListContainer.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/views/tool/component/ToolListContainer.vue b/ui/src/views/tool/component/ToolListContainer.vue index 13523c84801..fc74d9005fc 100644 --- a/ui/src/views/tool/component/ToolListContainer.vue +++ b/ui/src/views/tool/component/ToolListContainer.vue @@ -695,6 +695,9 @@ function openEditDialog(data?: any) { checkAll.value = multipleSelection.value.length === tool.toolList.length return } + if (!permissionPrecise.value.edit(data?.id)) { + return + } // 有template_id的不允许编辑,是模板转换来的 if (data?.template_id) { return From 62771551b588fc92cbebbadbd880282cdf241400 Mon Sep 17 00:00:00 2001 From: zhangzhanwei Date: Mon, 15 Jun 2026 16:47:12 +0800 Subject: [PATCH 2/2] feat: keep conversation streams alive across switches & scope chat state per-conversation --- ui/src/api/type/application.ts | 12 +++++++ .../component/answer-content/index.vue | 8 +---- .../component/chat-input-operate/index.vue | 12 ++----- ui/src/components/ai-chat/index.vue | 32 ++++++++++++++++--- ui/src/views/chat/embed/index.vue | 23 +++++++++++++ ui/src/views/chat/mobile/index.vue | 23 +++++++++++++ ui/src/views/chat/pc/index.vue | 23 +++++++++++++ 7 files changed, 113 insertions(+), 20 deletions(-) diff --git a/ui/src/api/type/application.ts b/ui/src/api/type/application.ts index 30de8fd1fa2..2b0108d004a 100644 --- a/ui/src/api/type/application.ts +++ b/ui/src/api/type/application.ts @@ -548,6 +548,18 @@ export class ChatManagement { return chatRecord ? chatRecord.is_stop : false } + /** + * 获取指定会话中仍在流式输出(尚未写完)的在途消息 + * 用于切回会话时, 把后台还在跑的流重新接回列表继续实时显示 + * @param chatId 会话id (chat.chat_id) + * @returns 在途的 chat 对象列表 + */ + static getActiveByChatId(chatId: string): chatType[] { + return Object.values(this.chatMessageContainer) + .filter((record) => record.chat.chat_id === chatId && !record.write_ed) + .map((record) => record.chat) + } + /** * 清除无用数据 也就是被close掉的和stop的数据 */ diff --git a/ui/src/components/ai-chat/component/answer-content/index.vue b/ui/src/components/ai-chat/component/answer-content/index.vue index 169db81ea38..e3fd9696ab3 100644 --- a/ui/src/components/ai-chat/component/answer-content/index.vue +++ b/ui/src/components/ai-chat/component/answer-content/index.vue @@ -99,7 +99,7 @@ diff --git a/ui/src/components/ai-chat/component/chat-input-operate/index.vue b/ui/src/components/ai-chat/component/chat-input-operate/index.vue index 15bc8998ae7..d9b9d1a521a 100644 --- a/ui/src/components/ai-chat/component/chat-input-operate/index.vue +++ b/ui/src/components/ai-chat/component/chat-input-operate/index.vue @@ -446,14 +446,8 @@ const chatId_context = computed({ emit('update:chatId', v) }, }) -const localLoading = computed({ - get: () => { - return props.loading - }, - set: (v) => { - emit('update:loading', v) - }, -}) +// 语音转写的请求 spinner, 独立于 loading prop(loading 现在是父级单向传入的"当前会话生成态") +const speechLoading = ref(false) const showURLSetting = ref(false) const urlForm = reactive({ @@ -807,7 +801,7 @@ const uploadRecording = async (audioBlob: Blob) => { if (props.applicationDetails.stt_autosend) { bus.emit('on:transcribing', true) } - speechToTextAPI(props.applicationDetails.id as string, formData, localLoading) + speechToTextAPI(props.applicationDetails.id as string, formData, speechLoading) .then((response) => { inputValue.value = typeof response.data === 'string' ? response.data : '' // 自动发送 diff --git a/ui/src/components/ai-chat/index.vue b/ui/src/components/ai-chat/index.vue index 7acbf51d936..f3a0cdddc98 100644 --- a/ui/src/components/ai-chat/index.vue +++ b/ui/src/components/ai-chat/index.vue @@ -140,7 +140,7 @@ @@ -310,6 +310,7 @@ const props = withDefaults( ) const emit = defineEmits([ 'refresh', + 'openChat', 'scroll', 'openExecutionDetail', 'openParagraph', @@ -346,6 +347,12 @@ const loading = ref(false) const inputValue = ref('') const chartOpenId = ref('') const chatList = ref([]) +// 当前正在查看的会话是否有在途消息(还在吐字)。 +// 用它驱动"停止回答"按钮、输入禁用、发送拦截, 替代组件级全局 loading, +// 这样后台其它会话的流式不会把当前会话的输入栏按住。 +const currentChatGenerating = computed(() => + chatList.value.some((c) => c && c.write_ed === false && c.is_stop !== true), +) const form_data = ref({}) const api_form_data = ref({}) const userFormRef = ref>() @@ -531,7 +538,7 @@ function sendMessage(val: string, other_params_data?: any, chat?: chatType): Pro showUserInput.value = false - if (!loading.value && props.applicationDetails?.name) { + if (!currentChatGenerating.value && props.applicationDetails?.name) { handleDebounceClick(val, other_params_data, chat) return true } @@ -551,7 +558,7 @@ function sendMessage(val: string, other_params_data?: any, chat?: chatType): Pro } } else { showUserInput.value = false - if (!loading.value && props.applicationDetails?.name) { + if (!currentChatGenerating.value && props.applicationDetails?.name) { handleDebounceClick(val, other_params_data, chat) return Promise.resolve(true) } @@ -656,6 +663,16 @@ const errorWrite = (chat: any, message?: string) => { ChatManagement.close(chat.id) } +// 停止"当前正在查看的会话"里在途的消息。 +// 只动 chatList(当前会话), 不会波及后台其它正在跑的会话。 +const stopGenerating = () => { + chatList.value.forEach((c) => { + if (c && c.write_ed === false && c.is_stop !== true) { + ChatManagement.stop(c.id) + } + }) +} + // 保存上传文件列表 function chatMessage(chat?: any, problem?: string, re_chat?: boolean, other_params_data?: any) { @@ -731,6 +748,11 @@ function chatMessage(chat?: any, problem?: string, re_chat?: boolean, other_para } else if (response.status === 461) { return Promise.reject(t('aiChat.tip.errorLimitMessage')) } else { + // 新建会话: 此刻后端已执行 set_chat 建好 Chat 行(在产出流之前), + // 通知父级把新会话加入历史列表, 这样长回答流式期间切走也能切回来继续看 + if (props.chatId === 'new') { + emit('openChat', chartOpenId.value) + } nextTick(() => { // 将滚动条滚动到最下面 scrollDiv.value.setScrollTop(getMaxHeight()) @@ -907,11 +929,13 @@ onMounted(() => { checkAll.value = multipleSelectionChat.value.length === chatList.value.length emit('update:selection', true) }) + bus.on('chat:stop', stopGenerating) }) onBeforeUnmount(() => { window.sendMessage = null window.chatUserProfile = null + bus.off('chat:stop', stopGenerating) }) function setScrollBottom() { diff --git a/ui/src/views/chat/embed/index.vue b/ui/src/views/chat/embed/index.vue index dbecb1b9627..1d7d51f0227 100644 --- a/ui/src/views/chat/embed/index.vue +++ b/ui/src/views/chat/embed/index.vue @@ -77,6 +77,7 @@ :chatId="currentChatId" type="ai-chat" @refresh="refresh" + @openChat="refresh" @scroll="handleScroll" class="AiChat-embed" v-model:selection="showSelection" @@ -107,6 +108,7 @@ import { hexToRgba } from '@/utils/theme' import { t } from '@/locales' import ChatHistoryDrawer from './component/ChatHistoryDrawer.vue' import chatAPI from '@/api/chat/chat' +import { ChatManagement } from '@/api/type/application' provide('scrollData', loadInfiniteScroll) provide('chatLogPagination', () => chatLogPagination) @@ -223,6 +225,26 @@ function loadInfiniteScroll() { getChatLog(true) } +/** + * 切回会话时, 把内存中属于该会话、仍在后台流式输出的在途消息接回列表, + * 这样切走时没被打断的流, 切回来能继续实时显示。 + * - 与 DB 记录 record_id 相同的, 用 live 对象覆盖(否则会显示落库前的空答案) + * - DB 里还没有的(尚未落库), 追加到末尾 + */ +function attachActiveStreams() { + const activeChats = ChatManagement.getActiveByChatId(currentChatId.value) + if (!activeChats.length) { + return + } + const activeMap = new Map(activeChats.map((chat) => [chat.record_id, chat])) + const existIds = new Set(currentRecordList.value.map((v: any) => v.record_id)) + const merged = currentRecordList.value.map((v: any) => + activeMap.has(v.record_id) ? activeMap.get(v.record_id) : v, + ) + const appendList = activeChats.filter((chat) => !existIds.has(chat.record_id)) + currentRecordList.value = [...merged, ...appendList] +} + function getChatRecord() { return chatAPI .pageChatRecord( @@ -242,6 +264,7 @@ function getChatRecord() { a.create_time.localeCompare(b.create_time), ) if (paginationConfig.current_page === 1) { + attachActiveStreams() nextTick(() => { // 将滚动条滚动到最下面 AiChatRef.value.setScrollBottom() diff --git a/ui/src/views/chat/mobile/index.vue b/ui/src/views/chat/mobile/index.vue index 525d970a494..ce0ea5d4363 100644 --- a/ui/src/views/chat/mobile/index.vue +++ b/ui/src/views/chat/mobile/index.vue @@ -77,6 +77,7 @@ :chatId="currentChatId" type="ai-chat" @refresh="refresh" + @openChat="refresh" @scroll="handleScroll" v-model:selection="showSelection" > @@ -106,6 +107,7 @@ import useStore from '@/stores' import { t } from '@/locales' import ChatHistoryDrawer from './component/ChatHistoryDrawer.vue' import chatAPI from '@/api/chat/chat' +import { ChatManagement } from '@/api/type/application' provide('scrollData', loadInfiniteScroll) provide('chatLogPagination', () => chatLogPagination) @@ -227,6 +229,26 @@ function loadInfiniteScroll() { getChatLog(true) } +/** + * 切回会话时, 把内存中属于该会话、仍在后台流式输出的在途消息接回列表, + * 这样切走时没被打断的流, 切回来能继续实时显示。 + * - 与 DB 记录 record_id 相同的, 用 live 对象覆盖(否则会显示落库前的空答案) + * - DB 里还没有的(尚未落库), 追加到末尾 + */ +function attachActiveStreams() { + const activeChats = ChatManagement.getActiveByChatId(currentChatId.value) + if (!activeChats.length) { + return + } + const activeMap = new Map(activeChats.map((chat) => [chat.record_id, chat])) + const existIds = new Set(currentRecordList.value.map((v: any) => v.record_id)) + const merged = currentRecordList.value.map((v: any) => + activeMap.has(v.record_id) ? activeMap.get(v.record_id) : v, + ) + const appendList = activeChats.filter((chat) => !existIds.has(chat.record_id)) + currentRecordList.value = [...merged, ...appendList] +} + function getChatRecord() { return chatAPI .pageChatRecord( @@ -246,6 +268,7 @@ function getChatRecord() { a.create_time.localeCompare(b.create_time), ) if (paginationConfig.current_page === 1) { + attachActiveStreams() nextTick(() => { // 将滚动条滚动到最下面 AiChatRef.value.setScrollBottom() diff --git a/ui/src/views/chat/pc/index.vue b/ui/src/views/chat/pc/index.vue index be23381f9ec..6e6ae2eb232 100644 --- a/ui/src/views/chat/pc/index.vue +++ b/ui/src/views/chat/pc/index.vue @@ -172,6 +172,7 @@ :chatId="currentChatId" executionIsRightPanel @refresh="refresh" + @openChat="refresh" @scroll="handleScroll" @open-execution-detail="openExecutionDetail" @openParagraph="openKnowledgeSource" @@ -258,6 +259,7 @@ import ExecutionDetailContent from '@/components/ai-chat/component/knowledge-sou import ParagraphSourceContent from '@/components/ai-chat/component/knowledge-source-component/ParagraphSourceContent.vue' import ParagraphDocumentContent from '@/components/ai-chat/component/knowledge-source-component/ParagraphDocumentContent.vue' import HistoryPanel from '@/views/chat/component/HistoryPanel.vue' +import { ChatManagement } from '@/api/type/application' import { cloneDeep } from 'lodash' import { getFileUrl } from '@/utils/common' import PdfExport from '@/components/pdf-export/index.vue' @@ -443,6 +445,26 @@ function loadInfiniteScroll() { getChatLog(true) } +/** + * 切回会话时, 把内存中属于该会话、仍在后台流式输出的在途消息接回列表, + * 这样切走时没被打断的流, 切回来能继续实时显示。 + * - 与 DB 记录 record_id 相同的, 用 live 对象覆盖(否则会显示落库前的空答案) + * - DB 里还没有的(尚未落库), 追加到末尾 + */ +function attachActiveStreams() { + const activeChats = ChatManagement.getActiveByChatId(currentChatId.value) + if (!activeChats.length) { + return + } + const activeMap = new Map(activeChats.map((chat) => [chat.record_id, chat])) + const existIds = new Set(currentRecordList.value.map((v: any) => v.record_id)) + const merged = currentRecordList.value.map((v: any) => + activeMap.has(v.record_id) ? activeMap.get(v.record_id) : v, + ) + const appendList = activeChats.filter((chat) => !existIds.has(chat.record_id)) + currentRecordList.value = [...merged, ...appendList] +} + function getChatRecord() { return chatAPI .pageChatRecord( @@ -462,6 +484,7 @@ function getChatRecord() { a.create_time.localeCompare(b.create_time), ) if (paginationConfig.value.current_page === 1) { + attachActiveStreams() nextTick(() => { // 将滚动条滚动到最下面 AiChatRef.value.setScrollBottom()