From b7aa64279de7b1dcb62b18583822524f8f19d2bb Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 8 Jun 2026 15:36:48 +0800 Subject: [PATCH] fix(web): support parallel sessions; fix lost/duplicate in-flight replies --- agent/evolution/executor.py | 4 +- agent/evolution/prompts.py | 9 +- bridge/agent_bridge.py | 57 ++++++++++++- channel/web/chat.html | 2 +- channel/web/static/js/console.js | 137 ++++++++++++++++++++++++++---- channel/web/web_channel.py | 7 ++ docs/docs.json | 1 + docs/zh/memory/self-evolution.mdx | 20 ++--- docs/zh/releases/overview.mdx | 1 + docs/zh/releases/v2.1.1.mdx | 61 +++++++++++++ 10 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 docs/zh/releases/v2.1.1.mdx diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py index 4c0c19d6..8734d263 100644 --- a/agent/evolution/executor.py +++ b/agent/evolution/executor.py @@ -269,7 +269,9 @@ def run_evolution_for_session( new_messages = all_messages[done:] transcript = _build_transcript(new_messages) if not transcript.strip(): - logger.info(f"[Evolution] session={session_id}: no new messages, skip") + # Routine no-op: the per-minute scan hits every idle session, so keep + # this at debug to avoid spamming the log. + logger.debug(f"[Evolution] session={session_id}: no new messages, skip") # Advance the cursor anyway so we don't re-scan the same tail. agent._evo_done_msg_count = total_msgs return False diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py index 9733b595..dd71d82a 100644 --- a/agent/evolution/prompts.py +++ b/agent/evolution/prompts.py @@ -120,11 +120,12 @@ them. When their signal is clear, act; do not be shy here. - Nothing worth evolving -> output exactly `[SILENT]` and nothing else. - Otherwise, after performing the edits, output a short user-facing summary in - the SAME LANGUAGE the user used in the conversation. Tell the user, briefly: + the SAME LANGUAGE the user used. Write it for an ordinary user, in plain + everyday words — NOT a developer report. No need to expose internal details + (file names/paths, system mechanics, etc.). Tell the user, briefly: 1) that you just did a self-learning pass, - 2) what you learned and what you changed (in plain terms — no need to cite - exact file paths; "remembered X" / "improved the weekly-report skill" is - enough). + 2) what you learned and what you changed in THIS pass ("remembered X" / + "improved the skill" / "finished "). Keep it to 1-3 lines. Generic shape (do not copy domain words): "I just did a self-learning pass. - Learned: diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index d7809192..fc8c2756 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -515,6 +515,15 @@ class AgentBridge: ) self._trim_in_memory_to_turns(agent, scheduler_keep_turns) + # Eagerly persist the user message BEFORE running the agent so the + # session and the user's bubble are immediately visible — even if + # the user switches away or refreshes before the reply finishes. + # The reply (assistant/tool messages) is appended once the run + # completes; the final persist skips this already-stored user turn. + pre_persisted = self._pre_persist_user_message( + session_id, query, context, clear_history + ) + try: # Use agent's run_stream method with event handler response = agent.run_stream( @@ -541,7 +550,11 @@ class AgentBridge: # Persist new messages generated during this run if session_id: channel_type = (context.get("channel_type") or "") if context else "" - new_messages = getattr(agent, '_last_run_new_messages', []) + new_messages = list(getattr(agent, '_last_run_new_messages', [])) + # The leading user turn was already persisted eagerly above; + # drop it here so it isn't stored twice. + if pre_persisted and new_messages and new_messages[0].get("role") == "user": + new_messages = new_messages[1:] if new_messages: self._persist_messages(session_id, list(new_messages), channel_type) else: @@ -757,6 +770,48 @@ class AgentBridge: except Exception as e: logger.warning(f"[AgentBridge] Failed to sync API keys: {e}") + def _pre_persist_user_message( + self, session_id: str, query: str, context: Context, clear_history: bool + ) -> bool: + """Persist the user's message before the agent runs. + + This makes a brand-new session (and the user's bubble) visible even if + the reply hasn't finished — switching away or refreshing no longer + loses the in-flight session. Returns True when the user turn was + stored, so the caller can skip it in the post-run persist. + + Best-effort: any failure is swallowed and reported as not-persisted. + """ + if not session_id or not query: + return False + # Only real user turns: skip scheduler-injected / scheduled-task runs. + if session_id.startswith("scheduler_") or ( + context and context.get("is_scheduled_task") + ): + return False + try: + from config import conf + if not conf().get("conversation_persistence", True): + return False + from agent.memory import get_conversation_store + store = get_conversation_store() + # clear_history starts a fresh transcript: wipe the store first so + # the eager user turn becomes seq 0, matching in-memory state. + if clear_history: + store.clear_session(session_id) + channel_type = (context.get("channel_type") or "") if context else "" + user_msg = { + "role": "user", + "content": [{"type": "text", "text": query}], + } + store.append_messages(session_id, [user_msg], channel_type=channel_type) + return True + except Exception as e: + logger.warning( + f"[AgentBridge] Failed to pre-persist user message for session={session_id}: {e}" + ) + return False + def _persist_messages( self, session_id: str, new_messages: list, channel_type: str = "" ) -> None: diff --git a/channel/web/chat.html b/channel/web/chat.html index 3a56f8c1..5bb4c7ae 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -772,7 +772,7 @@ diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 0e7f33ef..fef64b76 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -123,7 +123,7 @@ const I18N = { config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理', config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数', config_enable_thinking: '深度思考', config_enable_thinking_hint: '是否启用深度思考模式', - config_self_evolution: '自主进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', + config_self_evolution: '自我进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', evolution_badge: '自主学习', config_channel_type: '通道类型', config_provider: '模型厂商', config_model_name: '模型', @@ -142,7 +142,7 @@ const I18N = { skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skill_toggle_error: '操作失败,请稍后再试', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', - memory_tab_files: '记忆文件', memory_tab_dreams: '自主进化', + memory_tab_files: '记忆文件', memory_tab_dreams: '自我进化', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_back: '返回列表', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', @@ -883,6 +883,7 @@ let isPolling = false; let pollGeneration = 0; // incremented on each restart to cancel stale poll loops let loadingContainers = {}; let activeStreams = {}; // request_id -> EventSource +let sessionActiveRequest = {}; // session_id -> request_id (in-flight stream per session) let isComposing = false; let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} }; @@ -2242,6 +2243,27 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { let reasoningStartTime = 0; let done = false; + // The session this stream belongs to. Sessions run in parallel: the user + // may switch to another session while this one is still streaming. When + // that happens the stream keeps running in the background (so the reply + // still completes and persists) but must NOT touch the now-foreign view. + // On return, the completed reply is loaded from the DB via loadHistory. + const ownerSession = sessionId; + // Once the user navigates away, this stream is permanently "detached": + // its bubble was wiped from the DOM by the switch, so it must never render + // again — even if the user returns to this session. It keeps running to + // finish + persist the reply; the completed reply is then surfaced via + // history reload / polling. This avoids re-creating a mangled bubble that + // starts mid-stream. + let detached = false; + const isActive = () => ownerSession === sessionId && !detached; + sessionActiveRequest[ownerSession] = requestId; + const clearOwnerRequest = () => { + if (sessionActiveRequest[ownerSession] === requestId) { + delete sessionActiveRequest[ownerSession]; + } + }; + const MAX_RECONNECTS = 10; const RECONNECT_BASE_MS = 1000; let reconnectCount = 0; @@ -2294,6 +2316,23 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { // Successful data received, reset reconnect counter reconnectCount = 0; + // Background session: keep the stream alive so the reply finishes + // and persists, but skip all UI rendering for the foreign view. + // Once a stream has rendered into a view that later got cleared + // (the user switched away), mark it detached for good so it never + // tries to render into a rebuilt/foreign view — switching back + // shows the finished reply via history reload / polling instead. + if (ownerSession !== sessionId || detached) { + detached = true; + if (item.type === 'done' || item.type === 'error' || item.type === 'voice_attach') { + done = true; + es.close(); + delete activeStreams[requestId]; + clearOwnerRequest(); + } + return; + } + if (item.type === 'reasoning') { ensureBotEl(); reasoningText += item.content; @@ -2507,6 +2546,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { // TTS audio (`voice_attach`). It will close the stream on // its own via onerror once the tail expires. done = true; + clearOwnerRequest(); resetSendBtnSendMode(); const finalTextRaw = item.content || accumulatedText; @@ -2564,11 +2604,13 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { } es.close(); delete activeStreams[requestId]; + clearOwnerRequest(); } else if (item.type === 'error') { done = true; es.close(); delete activeStreams[requestId]; + clearOwnerRequest(); if (loadingEl) { loadingEl.remove(); loadingEl = null; } addBotMessage(t('error_send'), new Date()); resetSendBtnSendMode(); @@ -2598,7 +2640,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { return; } - // Exhausted retries, show whatever we have + // Exhausted retries. Only surface the failure in the owning view — + // a background session must not mutate the currently shown chat. + clearOwnerRequest(); + if (!isActive()) return; if (loadingEl) { loadingEl.remove(); loadingEl = null; } if (!botEl) { addBotMessage(t('error_send'), new Date()); @@ -2641,10 +2686,19 @@ function startPolling() { loadingContainers[rid].remove(); delete loadingContainers[rid]; } - const welcomeScreen = document.getElementById('welcome-screen'); - if (welcomeScreen) welcomeScreen.remove(); - addBotMessage(data.content, new Date(data.timestamp * 1000), rid); - scrollChatToBottom(); + // Skip if this reply is already on screen. Happens when a reply + // arrives via both the SSE stream and the poll queue (e.g. the + // user switched away mid-run, leaving the queued reply to be + // re-fetched on return) — render it only once. + const already = rid && messagesDiv.querySelector( + `[data-request-id="${rid}"]` + ); + if (!already) { + const welcomeScreen = document.getElementById('welcome-screen'); + if (welcomeScreen) welcomeScreen.remove(); + addBotMessage(data.content, new Date(data.timestamp * 1000), rid); + scrollChatToBottom(); + } } const delay = (data.status === 'success' && data.has_content) ? 5000 : 10000; setTimeout(poll, delay); @@ -2889,8 +2943,11 @@ function createBotMessageEl(content, timestamp, requestId, msg) { } // Self-evolution bubbles get a small badge so the user can feel the agent - // learned something on its own (text itself stays clean). - const evolutionBadge = (msg && msg.kind === 'evolution') + // learned something on its own (text itself stays clean). History replay + // carries msg.kind; live pushes are identified by the evolution_ request id. + const isEvolution = (msg && msg.kind === 'evolution') + || (typeof requestId === 'string' && requestId.startsWith('evolution_')); + const evolutionBadge = isEvolution ? `
${t('evolution_badge')} @@ -3205,14 +3262,17 @@ function addLoadingIndicator() { } function newChat() { - // Close all active SSE connections for the current session - Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} }); - activeStreams = {}; + // Do NOT close active streams: other sessions keep streaming in the + // background (each stream self-guards against the foreign view) and their + // replies still complete and persist. + + // Stop any pending-reply poller from the previous session. + if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } // Generate a fresh session and persist it so the next page load also starts clean sessionId = generateSessionId(); localStorage.setItem(SESSION_ID_KEY, sessionId); - loadingContainers = {}; + resetSendBtnSendMode(); // fresh session has no in-flight reply startPolling(); // bump generation so old loop self-cancels, new loop uses fresh sessionId messagesDiv.innerHTML = ''; const ws = document.createElement('div'); @@ -3547,15 +3607,48 @@ function _onSessionListScroll() { } })(); +// When returning to a session whose reply is still streaming in the +// background, the reply isn't in history yet. Show a loading indicator and +// reload history until the reply has persisted (stream cleared) or the user +// navigates away. Stable bubbles are de-duplicated by request_id on reload. +let _pendingReplyTimer = null; +function _waitForPendingReply(sid) { + if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } + // The user turn is already in history (persisted eagerly); show a loading + // hint beneath it while the reply streams in the background. + addLoadingIndicator(); + + function tick() { + // User switched away — abandon; the other session manages its own view. + if (sid !== sessionId) return; + if (sessionActiveRequest[sid]) { + // Still streaming — keep waiting without touching the view. + _pendingReplyTimer = setTimeout(tick, 1500); + return; + } + // Reply finished and persisted: rebuild the view from history once. + // loadHistory only prepends, so clear first to avoid duplicates. + historyPage = 0; + historyLoading = false; + messagesDiv.innerHTML = ''; + loadHistory(1); + } + _pendingReplyTimer = setTimeout(tick, 1500); +} + function switchSession(newSessionId) { if (newSessionId === sessionId) { if (currentView !== 'chat') navigateTo('chat'); return; } - Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} }); - activeStreams = {}; - loadingContainers = {}; + // Stop any pending-reply poller from the session we're leaving. + if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } + + // Do NOT close active streams here: sessions run in parallel, so any + // in-flight reply for another session must keep streaming in the + // background (it self-guards against rendering into the foreign view). + // The reply completes and persists; switching back loads it from history. sessionId = newSessionId; localStorage.setItem(SESSION_ID_KEY, sessionId); @@ -3568,6 +3661,18 @@ function switchSession(newSessionId) { loadHistory(1); startPolling(); + // Restore the send button to match this session's stream state: if it has + // an in-flight reply, show Cancel; otherwise show Send. A reply that is + // still streaming in the background won't be in history yet, so poll the + // history until it lands. + const pendingReq = sessionActiveRequest[sessionId]; + if (pendingReq) { + setSendBtnCancelMode(pendingReq); + _waitForPendingReply(sessionId); + } else { + resetSendBtnSendMode(); + } + document.querySelectorAll('.session-item').forEach(el => { el.classList.toggle('active', el.dataset.sessionId === sessionId); }); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index eaa20f2b..e706ab41 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -360,6 +360,13 @@ class WebChannel(ChatChannel): ): logger.debug(f"Polling skipped duplicate file reply for session {session_id}") return + # SSE-enabled requests already stream the text reply to the + # client. Do NOT also enqueue it for polling: if the user + # switched away mid-run, the queued copy would resurface as a + # duplicate bubble when they return and poll the session. + if reply.type == ReplyType.TEXT and context.get("on_event") is not None: + logger.debug(f"Polling skipped SSE text reply for session {session_id}") + return response_data = { "type": str(reply.type), "content": content, diff --git a/docs/docs.json b/docs/docs.json index 0d18e321..8f848823 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -456,6 +456,7 @@ "group": "发布记录", "pages": [ "zh/releases/overview", + "zh/releases/v2.1.1", "zh/releases/v2.1.0", "zh/releases/v2.0.9", "zh/releases/v2.0.8", diff --git a/docs/zh/memory/self-evolution.mdx b/docs/zh/memory/self-evolution.mdx index 1d04b28f..bf18029b 100644 --- a/docs/zh/memory/self-evolution.mdx +++ b/docs/zh/memory/self-evolution.mdx @@ -1,5 +1,5 @@ --- -title: 自主进化 +title: 自我进化 description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项 --- @@ -7,13 +7,13 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 简介 -自主进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把值得记住的沉淀为长期记忆、把使用中暴露的问题修进技能、把没做完的事情接着推进。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。 +自我进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把值得记住的沉淀为长期记忆、把使用中暴露的问题修进技能、把没做完的事情接着推进。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。 -> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 +> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自我进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 ### 三个目标 -自主进化围绕三件事工作: +自我进化围绕三件事工作: | 目标 | 说明 | | --- | --- | @@ -27,7 +27,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 触发时机 -自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: +自我进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: - **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟) - **对话有足够内容** — 自上次进化以来累积了足够轮次,或上下文已接近容量上限 @@ -36,11 +36,11 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 相关配置 -自主进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整: +自我进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整: | 参数 | 说明 | 默认值 | | --- | --- | --- | -| `self_evolution_enabled` | 是否启用自主进化 | `false` | +| `self_evolution_enabled` | 是否启用自我进化 | `false` | | `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` | | `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` | @@ -50,7 +50,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 进化记录 -每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自主进化」tab 中查看。该 tab 同时汇总了自主进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 +每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自我进化」tab 中查看。该 tab 同时汇总了自我进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 ### 如何回滚 @@ -58,7 +58,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ## 实现设计 -自主进化复用了系统已有的能力,保持轻量: +自我进化复用了系统已有的能力,保持轻量: - **隔离执行**:每次复盘都启动一个独立的、临时的复盘任务,使用与主对话相同的模型,但拥有受限的工具集(只能读上下文、改记忆与技能文件)。它不会污染主对话的上下文,也不会影响主对话的性能。 - **基于备份的撤销**:进化前对相关文件做快照备份,撤销时按备份还原,因此每一次改动都可追溯、可逆。 @@ -66,7 +66,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 克制与安全 -自主进化的设计原则是"必要时执行,减少打扰": +自我进化的设计原则是"必要时执行,减少打扰": | 机制 | 说明 | | --- | --- | diff --git a/docs/zh/releases/overview.mdx b/docs/zh/releases/overview.mdx index 35409fee..84013906 100644 --- a/docs/zh/releases/overview.mdx +++ b/docs/zh/releases/overview.mdx @@ -5,6 +5,7 @@ description: CowAgent 版本更新历史 | 版本 | 日期 | 说明 | | --- | --- | --- | +| [2.1.1](/zh/releases/v2.1.1) | 2026.06.08 | 自进化能力、Web 控制台消息管理升级、MCP 跨平台增强与并发调用、新模型接入(MiniMax-M3、qwen3.7-plus 等)、多项优化 | | [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 | | [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 | | [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 | diff --git a/docs/zh/releases/v2.1.1.mdx b/docs/zh/releases/v2.1.1.mdx new file mode 100644 index 00000000..bafbdeec --- /dev/null +++ b/docs/zh/releases/v2.1.1.mdx @@ -0,0 +1,61 @@ +--- +title: v2.1.1 +description: CowAgent 2.1.1 - 自进化能力、Web 控制台消息管理升级、MCP 跨平台增强、多模型接入与优化 +--- + +🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1) + +## 🧬 自进化能力 + +CowAgent 新增**自进化(Self-Evolution)**能力,让 Agent 不止于完成单次任务,而是在与你的日常协作中持续成长: + +- **自动复盘成长**:一段对话自然结束并进入空闲后,Agent 会在后台悄悄回顾刚刚的对话,沉淀值得记住的偏好和事实、修复技能中暴露的问题、并补做未完成的任务 +- **越用越懂你**:随着使用,Agent 会逐渐记住你的习惯、减少重复犯错,在收尾上做得更好 +- **不打扰原则**:只有在确实做出了改动时,才用一句话告诉你它学到了什么、调整了什么,没有变化时全程静默 +- **安全可回退**:每次复盘前都会自动备份,对结果不满意时只需在对话中让它撤销即可恢复;内置技能受保护、所有改动均限制在工作空间内 + +默认关闭,可在 Web 控制台 **设置 → Agent 配置** 中一键开启。 + +相关文档:[自进化](https://docs.cowagent.ai/zh/memory/self-evolution) + +## 💬 Web 控制台消息管理升级 + +Web 控制台的聊天体验进一步增强,操作更接近主流聊天产品: + +- **消息编辑与重发**:用户与机器人的消息均支持编辑、删除、重新生成 +- **代码块更易用**:代码块新增语言标签和一键复制按钮 +- **拖拽体验优化**:支持将文件拖拽到整个聊天区域 + +Thanks @core-power (#2865) + +## 🧩 MCP 跨平台增强 + +- **Windows 兼容修复**:修复 MCP 在 Windows 下 `stdio` 通信不可用的问题,并支持通过 `mcp.json` 配置服务超时时间 +- **并发调用支持**:`sse` 与 `streamable-http` 传输支持跨会话并发调用,多工具响应更快 + +Thanks @xliu123321 (#2859) + +相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp) + +## 🤖 模型新增与优化 + +- **MiniMax-M3**:新增并设为默认模型,保留 M2.7 系列作为可选项。Thanks @octo-patch (#2855) +- **通义千问 qwen3.7-plus**:支持多模态对话 +- **语音识别模型可选**:Web 控制台支持选择 ASR(语音识别)模型并持久化保存。Thanks @nightwhite (#2857) +- **安装菜单简化**:一键安装脚本精简模型选择菜单,新增小米 MiMo 选项 + +相关文档:[模型概览](https://docs.cowagent.ai/zh/models) + +## 🛠 体验优化与修复 + +- **国际化体验**:通道列表按界面语言排序展示;优化 `auto` 模式下的语言自动回退逻辑 +- **任务取消更可靠**:修复部分场景下流式回复无法中断的问题 +- **CLI 增强**:`cow status` 新增显示当前项目路径 +- **部署安全加固**:凭证文件拦截范围收敛至 `~/.cow/.env`,不再误拦其他目录(Thanks @orbisai0security #2863);微信公众号在 `wechatmp_token` 为空时拒绝 Webhook 请求 +- **群任务看板插件**:内置群任务看板插件源。Thanks @Wyh-max-star (#2853) + +## 📦 升级方式 + +源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。 + +**发布日期**:2026.06.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1)