Commit 9b1c3f148315835e1c8a47e95f251ffbfffefa0b

Authored by yibo
1 parent d48457ac

fix(agent-chat): 聊天状态按 agent 隔离,切换不再串台/互相阻塞

AgentChat.vue 之前把 sending/streaming/streamContent/thinkingText/
thinkingContent/subAgents/acpLog/messages/activeConvId 全部做成页面级单例 ref,
不区分是哪个 agent 的。切 agent 时既不取消上一个还在跑的读流循环,也不重置这些
状态,导致三个连环症状:①思考中/等待回复横幅带着切过去;②只要有任意一个 agent
在跑,所有 agent 的输入框都被同一个 sending 锁成"等待回复中";③A 流式输出时切去
看 B,A 的内容被当成 B 的渲染,A 说完那句话直接被 push 进 B 的消息列表。

改为按 agentId 存一份 reactive Map(chatStates),模板读"当前选中 agent"那一份
slice;handleSend/handleEvent 认发起时捕获的 requestAgentId 写自己的 slice,不认
"用户现在选中的是谁"。switchAgent 不再无条件清空目标 agent 状态——只有它空闲时
才去刷新/定位会话,正在生成就原样展示缓存,回切能看到完整后台进度。真机验证:
向 Master 发消息流式生成期间切到另一个 agent,输入框未被锁定、无状态污染,切
回 Master 能看到完整正确的输出。

顺带把已经在这个文件里的一处未提交小改动(agent 列表排序:Master 置顶)一并
收进这次提交,不是本次改动的一部分。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing 1 changed file with 161 additions and 125 deletions
web/src/views/AgentChat.vue
... ... @@ -58,7 +58,7 @@
58 58 v-for="conv in filteredConversations"
59 59 :key="conv.conversationId || conv.id"
60 60 class="conv-card"
61   - :class="{ active: activeConvId === (conv.conversationId || conv.id) }"
  61 + :class="{ active: chatState.activeConvId === (conv.conversationId || conv.id) }"
62 62 @click="switchConversation(conv.conversationId || conv.id)"
63 63 >
64 64 <div class="conv-card-top">
... ... @@ -86,7 +86,7 @@
86 86  
87 87 <!-- 第3列: 消息区 -->
88 88 <section class="chat-panel">
89   - <div v-if="messages.length === 0 && !streaming" class="welcome-screen">
  89 + <div v-if="chatState.messages.length === 0 && !chatState.streaming" class="welcome-screen">
90 90 <div class="welcome-content">
91 91 <div class="welcome-emoji">{{ agentInfo.emoji || '🏗️' }}</div>
92 92 <h2 class="welcome-title">{{ agentInfo.name || 'Agent' }}</h2>
... ... @@ -99,7 +99,7 @@
99 99 </div>
100 100 </div>
101 101 <div v-else ref="messageListRef" class="message-list">
102   - <div v-for="(msg, idx) in messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`">
  102 + <div v-for="(msg, idx) in chatState.messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`">
103 103 <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : (agentInfo.emoji || '🏗️') }}</div>
104 104 <div class="msg-body">
105 105 <div class="msg-header">
... ... @@ -117,24 +117,24 @@
117 117 </div>
118 118 </div>
119 119 </div>
120   - <div v-if="streaming" class="msg-row msg-assistant streaming">
  120 + <div v-if="chatState.streaming" class="msg-row msg-assistant streaming">
121 121 <div class="msg-avatar avatar-assistant">{{ agentInfo.emoji || '🏗️' }}</div>
122 122 <div class="msg-body">
123 123 <div class="msg-header"><span class="msg-role">{{ agentInfo.name }}</span><span class="typing-indicator"><span></span><span></span><span></span></span></div>
124   - <div class="msg-content" v-html="renderMarkdown(streamContent)"></div>
  124 + <div class="msg-content" v-html="renderMarkdown(chatState.streamContent)"></div>
125 125 </div>
126 126 </div>
127   - <div v-if="sending && thinkingContent" class="thinking-block">
128   - <div class="thinking-header" @click="showThinking = !showThinking">
  127 + <div v-if="chatState.sending && chatState.thinkingContent" class="thinking-block">
  128 + <div class="thinking-header" @click="chatState.showThinking = !chatState.showThinking">
129 129 <div class="thinking-pulse"></div>
130   - <span class="thinking-label">{{ streaming ? '💭 思考完成' : '💭 思考中...' }}</span>
131   - <span class="thinking-toggle">{{ showThinking ? '▼' : '▶' }}</span>
  130 + <span class="thinking-label">{{ chatState.streaming ? '💭 思考完成' : '💭 思考中...' }}</span>
  131 + <span class="thinking-toggle">{{ chatState.showThinking ? '▼' : '▶' }}</span>
132 132 </div>
133   - <div v-if="showThinking" class="thinking-content">{{ thinkingContent }}</div>
  133 + <div v-if="chatState.showThinking" class="thinking-content">{{ chatState.thinkingContent }}</div>
134 134 </div>
135   - <div v-if="sending && !streaming && !thinkingContent" class="thinking-row">
  135 + <div v-if="chatState.sending && !chatState.streaming && !chatState.thinkingContent" class="thinking-row">
136 136 <div class="thinking-pulse"></div>
137   - <span class="thinking-text">{{ thinkingText }}</span>
  137 + <span class="thinking-text">{{ chatState.thinkingText }}</span>
138 138 </div>
139 139 </div>
140 140 <div class="chat-input-area" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleDrop">
... ... @@ -160,28 +160,28 @@
160 160 <div class="input-box" :class="{ focused: inputFocused }">
161 161 <button class="attach-btn" @click="triggerFileInput" title="上传文件">📎</button>
162 162 <input type="file" ref="fileInputRef" multiple style="display:none" @change="handleFileSelect" />
163   - <textarea ref="inputRef" v-model="inputText" :disabled="sending" :placeholder="sending ? '等待回复中...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea>
164   - <button v-if="sending" class="send-btn stop-btn" @click="handleStop">⏹</button>
165   - <button v-else class="send-btn" :class="{ ready: (inputText.trim() || pendingFiles.length) && !sending }" :disabled="!inputText.trim() && !pendingFiles.length" @click="handleSend">➤</button>
  163 + <textarea ref="inputRef" v-model="inputText" :disabled="chatState.sending" :placeholder="chatState.sending ? '等待回复中...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea>
  164 + <button v-if="chatState.sending" class="send-btn stop-btn" @click="handleStop">⏹</button>
  165 + <button v-else class="send-btn" :class="{ ready: (inputText.trim() || pendingFiles.length) && !chatState.sending }" :disabled="!inputText.trim() && !pendingFiles.length" @click="handleSend">➤</button>
166 166 </div>
167   - <div class="input-hint">Shift+Enter 换行 · {{ messages.length }} 条消息<template v-if="pendingFiles.length"> · {{ pendingFiles.length }} 个待上传文件</template><template v-if="uploadedFiles.length"> · 📎 {{ uploadedFiles.length }} 个附件</template></div>
  167 + <div class="input-hint">Shift+Enter 换行 · {{ chatState.messages.length }} 条消息<template v-if="pendingFiles.length"> · {{ pendingFiles.length }} 个待上传文件</template><template v-if="uploadedFiles.length"> · 📎 {{ uploadedFiles.length }} 个附件</template></div>
168 168 </div>
169 169 </section>
170 170  
171 171 <!-- 第4列: ACP 事件 -->
172 172 <aside class="acp-panel">
173   - <div class="col-header"><span>⚡ ACP Events</span><span class="acp-count">{{ acpLog.length }}</span></div>
  173 + <div class="col-header"><span>⚡ ACP Events</span><span class="acp-count">{{ chatState.acpLog.length }}</span></div>
174 174 <div class="acp-list">
175   - <div v-if="acpLog.length > 0" class="acp-card">
176   - <div class="acp-card-header"><span>⚡ Event Stream</span><span v-if="sending" class="badge-live">LIVE</span></div>
  175 + <div v-if="chatState.acpLog.length > 0" class="acp-card">
  176 + <div class="acp-card-header"><span>⚡ Event Stream</span><span v-if="chatState.sending" class="badge-live">LIVE</span></div>
177 177 <div class="acp-log-stream">
178   - <div v-for="(evt, i) in acpLog" :key="i" class="acp-event-line" :class="'acp-' + evt.type">
  178 + <div v-for="(evt, i) in chatState.acpLog" :key="i" class="acp-event-line" :class="'acp-' + evt.type">
179 179 <span class="acp-icon">{{ evt.icon }}</span>
180 180 <span class="acp-text">{{ evt.text }}</span>
181 181 </div>
182 182 </div>
183 183 </div>
184   - <div v-for="(sub, callId) in subAgents" :key="'live-'+callId" class="sub-log-card" :class="sub.status">
  184 + <div v-for="(sub, callId) in chatState.subAgents" :key="'live-'+callId" class="sub-log-card" :class="sub.status">
185 185 <div class="sub-log-card-header" @click="sub.collapsed = !sub.collapsed">
186 186 <span class="sub-log-icon">{{ sub.status === 'running' ? '🚀' : (sub.status === 'success' ? '✅' : '❌') }}</span>
187 187 <span class="sub-log-agent">{{ sub.agent }}</span>
... ... @@ -190,7 +190,7 @@
190 190 </div>
191 191 <div v-if="!sub.collapsed" class="sub-log-content terminal">{{ sub.content || '等待输出...' }}</div>
192 192 </div>
193   - <div v-for="log in subLogList" :key="log.file" class="sub-log-card" :class="log.status">
  193 + <div v-for="log in chatState.subLogList" :key="log.file" class="sub-log-card" :class="log.status">
194 194 <div class="sub-log-card-header" @click="log.collapsed = !log.collapsed">
195 195 <span class="sub-log-icon">{{ log.status === 'success' ? '✅' : (log.status === 'error' ? '❌' : '⏳') }}</span>
196 196 <span class="sub-log-agent">{{ log.agent }}</span>
... ... @@ -198,7 +198,7 @@
198 198 </div>
199 199 <div v-if="!log.collapsed" class="sub-log-content terminal">{{ log.content }}</div>
200 200 </div>
201   - <div v-if="acpLog.length === 0 && subLogList.length === 0 && Object.keys(subAgents).length === 0" class="acp-empty">
  201 + <div v-if="chatState.acpLog.length === 0 && chatState.subLogList.length === 0 && Object.keys(chatState.subAgents).length === 0" class="acp-empty">
202 202 发送消息后,ACP 事件<br>将在这里实时显示
203 203 </div>
204 204 </div>
... ... @@ -326,7 +326,7 @@
326 326 </template>
327 327  
328 328 <script setup>
329   -import { ref, computed, nextTick, watch, onMounted } from 'vue'
  329 +import { ref, reactive, computed, nextTick, watch, onMounted } from 'vue'
330 330 import { useRoute, useRouter } from 'vue-router'
331 331 import AppNav from '@/components/AppNav.vue'
332 332 import FileNode from '@/components/FileNode.vue'
... ... @@ -356,9 +356,11 @@ const groupedAgents = computed(() =&gt; {
356 356 groups[comp].push(a)
357 357 })
358 358  
359   - // Sort each group by lastActiveAt descending
  359 + // Master agent 置顶,其余按 lastActiveAt 降序
360 360 Object.keys(groups).forEach(k => {
361 361 groups[k].sort((a, b) => {
  362 + if (a.type === 'master') return -1
  363 + if (b.type === 'master') return 1
362 364 const ta = a.lastActiveAt ? new Date(a.lastActiveAt).getTime() : 0
363 365 const tb = b.lastActiveAt ? new Date(b.lastActiveAt).getTime() : 0
364 366 return tb - ta
... ... @@ -378,6 +380,25 @@ const groupedAgents = computed(() =&gt; {
378 380 })
379 381 const activeTab = ref('chat') // 'chat' | 'detail'
380 382  
  383 +// 每个 agent 独立一份聊天态(messages/sending/streaming/思考栏/ACP 面板…),
  384 +// 键是 agentId。切换 agent 只是换了 chatState 指向哪个 slice 来渲染,
  385 +// 后台的 fetch 读流循环(handleSend/handleEvent) 认准发起时捕获的 agentId 写自己那份,
  386 +// 不认"当前选中的 agent 是谁"——这样 A 在流式输出时切去看 B,A 不会被打断,
  387 +// B 也不会被 A 的内容/思考栏/等待态污染,回切 A 能看到它这期间的完整进度。
  388 +const chatStates = reactive(new Map())
  389 +function ensureChatState(id) {
  390 + if (!chatStates.has(id)) {
  391 + chatStates.set(id, {
  392 + messages: [], activeConvId: null,
  393 + sending: false, streaming: false, streamContent: '',
  394 + subAgents: {}, subLogList: [], acpLog: [],
  395 + thinkingText: '🏗️ 启动中...', thinkingContent: '', showThinking: true,
  396 + })
  397 + }
  398 + return chatStates.get(id)
  399 +}
  400 +const chatState = computed(() => ensureChatState(agentId.value))
  401 +
381 402 const agentInfo = ref({ name: 'Agent Creator', emoji: '🏗️', type: 'meta', status: 'ready', greeting: '', bootstrapFiles: [] })
382 403  
383 404 // 状态映射:英文 -> 中文
... ... @@ -429,21 +450,10 @@ const filteredConversations = computed(() =&gt; {
429 450 if (convFilter.value === 'all') return conversations.value
430 451 return conversations.value.filter(c => getConvType(c.conversationId || c.id) === convFilter.value)
431 452 })
432   -const messages = ref([])
433 453 const inputText = ref('')
434 454 const inputFocused = ref(false)
435   -const sending = ref(false)
436   -const streaming = ref(false)
437   -const streamContent = ref('')
438   -const subAgents = ref({})
439   -const subLogList = ref([])
440   -const acpLog = ref([])
441   -const thinkingText = ref('🏗️ 启动中...')
442   -const thinkingContent = ref('')
443   -const showThinking = ref(true)
444 455 const messageListRef = ref(null)
445 456 const inputRef = ref(null)
446   -const activeConvId = ref(null)
447 457  
448 458 // Detail mode state
449 459 const fileTree = ref(null)
... ... @@ -516,16 +526,16 @@ async function uploadPendingFiles() {
516 526 for (const pf of pendingFiles.value) {
517 527 formData.append('files', pf.file)
518 528 }
519   - if (activeConvId.value) {
520   - formData.append('conversationId', activeConvId.value)
  529 + if (chatState.value.activeConvId) {
  530 + formData.append('conversationId', chatState.value.activeConvId)
521 531 }
522 532 try {
523 533 const r = await fetch(`/api/agent/${agentId.value}/upload`, { method: 'POST', body: formData })
524 534 if (r.ok) {
525 535 const data = await r.json()
526 536 // Auto-set convId if server created one
527   - if (data.conversationId && !activeConvId.value) {
528   - activeConvId.value = data.conversationId
  537 + if (data.conversationId && !chatState.value.activeConvId) {
  538 + chatState.value.activeConvId = data.conversationId
529 539 }
530 540 uploadedFiles.value.push(...(data.uploaded || []))
531 541 pendingFiles.value = []
... ... @@ -536,9 +546,9 @@ async function uploadPendingFiles() {
536 546 }
537 547  
538 548 async function loadUploadedFiles() {
539   - if (!agentId.value || !activeConvId.value) { uploadedFiles.value = []; return }
  549 + if (!agentId.value || !chatState.value.activeConvId) { uploadedFiles.value = []; return }
540 550 try {
541   - const r = await fetch(`/api/agent/${agentId.value}/uploads/${activeConvId.value}`)
  551 + const r = await fetch(`/api/agent/${agentId.value}/uploads/${chatState.value.activeConvId}`)
542 552 if (r.ok) uploadedFiles.value = await r.json()
543 553 else uploadedFiles.value = []
544 554 } catch { uploadedFiles.value = [] }
... ... @@ -652,16 +662,17 @@ async function loadConversations() {
652 662  
653 663 async function loadConversation(convId) {
654 664 if (!agentId.value) return
  665 + const st = chatState.value
655 666 try {
656 667 const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`)
657 668 if (r.ok) {
658 669 const conv = await r.json()
659   - messages.value = conv.messages || []
  670 + st.messages = conv.messages || []
660 671 if (conv.subLogs && conv.subLogs.length > 0) {
661   - subLogList.value = conv.subLogs.map(l => ({ ...l, collapsed: true }))
662   - } else { subLogList.value = [] }
  672 + st.subLogList = conv.subLogs.map(l => ({ ...l, collapsed: true }))
  673 + } else { st.subLogList = [] }
663 674 }
664   - } catch { messages.value = []; subLogList.value = [] }
  675 + } catch { st.messages = []; st.subLogList = [] }
665 676 }
666 677  
667 678 async function loadFileTree() {
... ... @@ -724,15 +735,16 @@ async function deleteFile(filePath) {
724 735 }
725 736  
726 737 function newConversation() {
727   - activeConvId.value = null
728   - messages.value = []
729   - streamContent.value = ''
730   - subAgents.value = {}
731   - subLogList.value = []
732   - acpLog.value = []
  738 + const st = chatState.value
  739 + st.activeConvId = null
  740 + st.messages = []
  741 + st.streamContent = ''
  742 + st.subAgents = {}
  743 + st.subLogList = []
  744 + st.acpLog = []
733 745 pendingFiles.value = []
734 746 uploadedFiles.value = []
735   -
  747 +
736 748 if (route.params.id) {
737 749 router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: undefined }})
738 750 } else {
... ... @@ -740,22 +752,34 @@ function newConversation() {
740 752 }
741 753 }
742 754  
  755 +// 切 agent:不清空目标 agent 已经攒下的后台状态(可能正在生成中,或者刚刚生成完毕
  756 +// 还没被看到)——只有它是空闲态时才去定位/刷新它的会话,正在 sending 就原样展示缓存。
743 757 async function switchAgent() {
744   - newConversation()
745 758 selectedFile.value = null
746 759 previewContent.value = null
747 760 fileTree.value = null
  761 + const st = chatState.value
  762 + if (route.params.id) {
  763 + router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: st.activeConvId || undefined }})
  764 + } else {
  765 + router.replace({ params: { agentId: agentId.value, convId: st.activeConvId || undefined }})
  766 + }
748 767 await loadAgentInfo()
749 768 await loadConversations()
750   - if (filteredConversations.value.length > 0) {
751   - const firstId = filteredConversations.value[0].conversationId || filteredConversations.value[0].id
752   - await switchConversation(firstId)
  769 + if (!st.sending) {
  770 + if (st.activeConvId) {
  771 + await loadConversation(st.activeConvId)
  772 + } else if (filteredConversations.value.length > 0) {
  773 + const firstId = filteredConversations.value[0].conversationId || filteredConversations.value[0].id
  774 + await switchConversation(firstId)
  775 + }
753 776 }
754 777 await loadFileTree()
  778 + scrollToBottom()
755 779 }
756 780  
757 781 async function switchConversation(convId) {
758   - activeConvId.value = convId
  782 + chatState.value.activeConvId = convId
759 783 if (route.params.id) {
760 784 router.replace({ params: { id: route.params.id, agentId: agentId.value, convId } })
761 785 } else {
... ... @@ -774,13 +798,14 @@ async function deleteConversation(convId) {
774 798 const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`, { method: 'DELETE' })
775 799 if (r.ok) {
776 800 conversations.value = conversations.value.filter(c => (c.conversationId || c.id) !== convId)
777   - if (activeConvId.value === convId) {
778   - activeConvId.value = null
779   - messages.value = []
780   - streamContent.value = ''
781   - subAgents.value = {}
782   - subLogList.value = []
783   - acpLog.value = []
  801 + const st = chatState.value
  802 + if (st.activeConvId === convId) {
  803 + st.activeConvId = null
  804 + st.messages = []
  805 + st.streamContent = ''
  806 + st.subAgents = {}
  807 + st.subLogList = []
  808 + st.acpLog = []
784 809 }
785 810 }
786 811 } catch (e) { console.error('Delete conv error:', e) }
... ... @@ -810,9 +835,14 @@ async function copyText(text) {
810 835  
811 836 // ─── Chat (SSE) ───
812 837  
  838 +// requestAgentId 是发起这次发送时的 agent——从这里往下,全部认这个快照,不认
  839 +// "用户现在选中的是谁"。这样切走了也不会打断/污染,切回来能看到完整的后台进度。
813 840 async function handleSend() {
814 841 const text = inputText.value.trim()
815   - if ((!text && pendingFiles.value.length === 0) || sending.value || !agentId.value) return
  842 + const requestAgentId = agentId.value
  843 + const st = ensureChatState(requestAgentId)
  844 + if ((!text && pendingFiles.value.length === 0) || st.sending || !requestAgentId) return
  845 + const isCurrent = () => agentId.value === requestAgentId
816 846  
817 847 // Upload pending files first
818 848 if (pendingFiles.value.length > 0) {
... ... @@ -821,27 +851,26 @@ async function handleSend() {
821 851  
822 852 const finalText = text || '请处理我上传的文件。'
823 853 const attachments = uploadedFiles.value.length > 0 ? [...uploadedFiles.value] : []
824   - messages.value.push({ role: 'user', content: finalText, timestamp: new Date().toISOString(), attachments })
  854 + st.messages.push({ role: 'user', content: finalText, timestamp: new Date().toISOString(), attachments })
825 855 inputText.value = ''
826 856 if (inputRef.value) inputRef.value.style.height = 'auto'
827   - sending.value = true; streaming.value = false; streamContent.value = ''
828   - subAgents.value = {}; acpLog.value = []; thinkingContent.value = ''
829   - showThinking.value = true
830   - thinkingText.value = `${agentInfo.value.emoji} ${agentInfo.value.name} 思考中...`
831   - activeTab.value = 'chat' // ensure we're in chat mode
832   - scrollToBottom()
833   -
834   - if (activeConvId.value) {
835   - const existsInList = conversations.value.some(c => (c.conversationId || c.id) === activeConvId.value)
  857 + st.sending = true; st.streaming = false; st.streamContent = ''
  858 + st.subAgents = {}; st.acpLog = []; st.thinkingContent = ''
  859 + st.showThinking = true
  860 + st.thinkingText = `${agentInfo.value.emoji} ${agentInfo.value.name} 思考中...`
  861 + if (isCurrent()) { activeTab.value = 'chat'; scrollToBottom() }
  862 +
  863 + if (st.activeConvId && isCurrent()) {
  864 + const existsInList = conversations.value.some(c => (c.conversationId || c.id) === st.activeConvId)
836 865 if (!existsInList) {
837   - conversations.value.unshift({ conversationId: activeConvId.value, title: finalText.slice(0, 50), updatedAt: new Date().toISOString(), messageCount: messages.value.length, status: 'running' })
  866 + conversations.value.unshift({ conversationId: st.activeConvId, title: finalText.slice(0, 50), updatedAt: new Date().toISOString(), messageCount: st.messages.length, status: 'running' })
838 867 }
839 868 }
840 869  
841 870 try {
842   - const r = await fetch(`/api/agent/${agentId.value}/chat`, {
  871 + const r = await fetch(`/api/agent/${requestAgentId}/chat`, {
843 872 method: 'POST', headers: { 'Content-Type': 'application/json' },
844   - body: JSON.stringify({ message: finalText, conversationId: activeConvId.value }),
  873 + body: JSON.stringify({ message: finalText, conversationId: st.activeConvId }),
845 874 })
846 875 const reader = r.body.getReader()
847 876 const decoder = new TextDecoder()
... ... @@ -855,86 +884,93 @@ async function handleSend() {
855 884 for (const line of lines) {
856 885 if (line.startsWith('event: ')) lastEventType = line.slice(7)
857 886 else if (line.startsWith('data: ')) {
858   - try { handleEvent(lastEventType, JSON.parse(line.slice(6))) } catch {}
  887 + try { handleEvent(st, requestAgentId, lastEventType, JSON.parse(line.slice(6))) } catch {}
859 888 }
860 889 }
861 890 }
862   - if (streamContent.value) {
863   - messages.value.push({ role: 'assistant', content: streamContent.value, timestamp: new Date().toISOString() })
  891 + if (st.streamContent) {
  892 + st.messages.push({ role: 'assistant', content: st.streamContent, timestamp: new Date().toISOString() })
864 893 }
865   - await loadConversations()
866   - if (activeConvId.value) await loadSubLogs()
  894 + if (isCurrent()) await loadConversations()
  895 + if (st.activeConvId) await loadSubLogsFor(requestAgentId, st)
867 896 } catch (err) {
868   - messages.value.push({ role: 'assistant', content: `❌ 错误: ${err.message}`, timestamp: new Date().toISOString() })
  897 + st.messages.push({ role: 'assistant', content: `❌ 错误: ${err.message}`, timestamp: new Date().toISOString() })
869 898 } finally {
870   - sending.value = false; streaming.value = false; streamContent.value = ''; thinkingContent.value = ''
  899 + st.sending = false; st.streaming = false; st.streamContent = ''; st.thinkingContent = ''
871 900 }
872 901 }
873 902  
874   -async function loadSubLogs() {
  903 +async function loadSubLogsFor(id, st) {
875 904 try {
876   - const r = await fetch(`/api/agent/${agentId.value}/conversations/${activeConvId.value}/sub-logs`)
877   - if (r.ok) { const logs = await r.json(); subLogList.value = logs.map(l => ({ ...l, collapsed: true })) }
  905 + const r = await fetch(`/api/agent/${id}/conversations/${st.activeConvId}/sub-logs`)
  906 + if (r.ok) { const logs = await r.json(); st.subLogList = logs.map(l => ({ ...l, collapsed: true })) }
878 907 } catch {}
879 908 }
880 909  
881 910 async function handleStop() {
882   - if (!activeConvId.value) return
883   - try { await fetch(`/api/agent/${agentId.value}/chat/${activeConvId.value}/stop`, { method: 'POST' }) } catch(e) { console.error('Stop error:', e) }
884   - sending.value = false; streaming.value = false
885   - messages.value.push({ role: 'assistant', content: `*[已终止]*`, timestamp: new Date().toISOString() })
  911 + const st = chatState.value
  912 + if (!st.activeConvId) return
  913 + try { await fetch(`/api/agent/${agentId.value}/chat/${st.activeConvId}/stop`, { method: 'POST' }) } catch(e) { console.error('Stop error:', e) }
  914 + st.sending = false; st.streaming = false
  915 + st.messages.push({ role: 'assistant', content: `*[已终止]*`, timestamp: new Date().toISOString() })
886 916 scrollToBottom()
887 917 }
888 918  
889   -function handleEvent(type, data) {
  919 +// st/requestAgentId 是 handleSend 传下来的快照;只有 requestAgentId 仍是当前选中 agent 时
  920 +// 才动 DOM(滚动)/URL/全局对话列表——避免把某个后台 agent 的进度"带"到当前正看的页面上。
  921 +function handleEvent(st, requestAgentId, type, data) {
  922 + const isCurrent = agentId.value === requestAgentId
890 923 switch (type) {
891 924 case 'session':
892   - if (data.conversationId && !activeConvId.value) {
893   - activeConvId.value = data.conversationId
894   - if (route.params.id) {
895   - router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: data.conversationId } })
896   - } else {
897   - router.replace({ params: { agentId: agentId.value, convId: data.conversationId } })
898   - }
899   - const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId)
900   - if (!existsInList) {
901   - conversations.value.unshift({ conversationId: data.conversationId, title: messages.value.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' })
  925 + if (data.conversationId && !st.activeConvId) {
  926 + st.activeConvId = data.conversationId
  927 + if (isCurrent) {
  928 + if (route.params.id) {
  929 + router.replace({ params: { id: route.params.id, agentId: requestAgentId, convId: data.conversationId } })
  930 + } else {
  931 + router.replace({ params: { agentId: requestAgentId, convId: data.conversationId } })
  932 + }
  933 + const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId)
  934 + if (!existsInList) {
  935 + conversations.value.unshift({ conversationId: data.conversationId, title: st.messages.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' })
  936 + }
902 937 }
903 938 }
904 939 break
905 940 case 'thinking':
906   - if (data.message && !data.message.startsWith('🏗️')) { thinkingContent.value += data.message; scrollToBottom() }
907   - else { thinkingText.value = data.message || '思考中...' }
  941 + if (data.message && !data.message.startsWith('🏗️')) { st.thinkingContent += data.message; if (isCurrent) scrollToBottom() }
  942 + else { st.thinkingText = data.message || '思考中...' }
908 943 break
909 944 case 'agent_output':
910   - streaming.value = true; streamContent.value += (data.message || ''); scrollToBottom(); break
  945 + st.streaming = true; st.streamContent += (data.message || ''); if (isCurrent) scrollToBottom(); break
911 946 case 'tool_call':
912   - thinkingText.value = data.message || `🔧 ${data.tool}`
913   - acpLog.value.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); scrollAcpLog(); break
  947 + st.thinkingText = data.message || `🔧 ${data.tool}`
  948 + st.acpLog.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
914 949 case 'tool_input':
915   - acpLog.value.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); scrollAcpLog(); break
  950 + st.acpLog.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
916 951 case 'tool_result':
917   - acpLog.value.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); scrollAcpLog(); break
  952 + st.acpLog.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
918 953 case 'session_cost':
919   - acpLog.value.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration||0)/1000).toFixed(1)}s)`, ts: Date.now() }); break
  954 + st.acpLog.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration||0)/1000).toFixed(1)}s)`, ts: Date.now() }); break
920 955 case 'acp_raw':
921 956 if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) {
922   - acpLog.value.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); scrollAcpLog()
  957 + st.acpLog.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); if (isCurrent) scrollAcpLog()
923 958 }
924 959 break
925 960 case 'sub_agent':
926   - if (!subAgents.value[data.callId]) subAgents.value[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false }
  961 + if (!st.subAgents[data.callId]) st.subAgents[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false }
927 962 if (data.event === 'chunk' && data.chunk) {
928   - subAgents.value[data.callId].content += data.chunk
929   - setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50)
930   - } else if (data.event === 'done') subAgents.value[data.callId].status = data.status
931   - else if (data.event === 'start') subAgents.value[data.callId].status = 'running'
932   - scrollToBottom(); break
  963 + st.subAgents[data.callId].content += data.chunk
  964 + if (isCurrent) setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50)
  965 + } else if (data.event === 'done') st.subAgents[data.callId].status = data.status
  966 + else if (data.event === 'start') st.subAgents[data.callId].status = 'running'
  967 + if (isCurrent) scrollToBottom(); break
933 968 case 'error':
934   - messages.value.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() })
935   - acpLog.value.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() })
936   - thinkingText.value = ''; thinkingContent.value = ''
937   - scrollToBottom(); scrollAcpLog(); break
  969 + st.messages.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() })
  970 + st.acpLog.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() })
  971 + st.thinkingText = ''; st.thinkingContent = ''
  972 + if (isCurrent) { scrollToBottom(); scrollAcpLog() }
  973 + break
938 974 }
939 975 }
940 976  
... ... @@ -957,7 +993,7 @@ function renderMarkdown(text) {
957 993 catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }
958 994 }
959 995  
960   -watch([messages, streamContent], () => scrollToBottom(), { deep: true })
  996 +watch(chatState, () => scrollToBottom(), { deep: true })
961 997 // Load file tree when switching to detail tab
962 998 watch(activeTab, async (tab) => { if (tab === 'detail' && !fileTree.value) await loadFileTree() })
963 999  
... ... @@ -975,7 +1011,7 @@ onMounted(async () =&gt; {
975 1011 await loadAgentInfo()
976 1012 await loadConversations()
977 1013 if (route.params.convId) {
978   - activeConvId.value = route.params.convId
  1014 + chatState.value.activeConvId = route.params.convId
979 1015 await loadConversation(route.params.convId)
980 1016 scrollToBottom()
981 1017 } else if (filteredConversations.value.length > 0) {
... ...