Compare commits

..

12 Commits

Author SHA1 Message Date
zhayujie
81d5ddcd5f fix(relay): add request timeout in HTTP relay 2026-07-22 11:58:16 +08:00
zhayujie
ca64362405 release: update web console version 2026-07-22 09:45:09 +08:00
zhayujie
a67cae69ba Merge branch 'feat-theme-system' 2026-07-21 18:13:29 +08:00
zhayujie
b64733d64a feat(desktop): add client extension points 2026-07-21 18:13:14 +08:00
zhayujie
76d6186bd3 docs: update 2.1.4 release overview 2026-07-20 18:00:46 +08:00
zhayujie
a78fb711ad docs: add 2.1.4 release notes 2026-07-20 15:39:29 +08:00
zhayujie
b755d18e63 fix(browser): drive system Chrome via spawn+CDP 2026-07-20 12:15:12 +08:00
zhayujie
3cdc13d69d docs(feishu): document message recall event and log recall on websocket 2026-07-20 11:41:56 +08:00
zhayujie
ea0c90f4ea Merge pull request #2978 from AaronZ345/agent/feishu-message-recall
feat(feishu): cancel recalled messages
2026-07-20 11:35:13 +08:00
zhayujie
f9e7c07af8 fix(steer): keep steer button clickable during active task and log injected instruction 2026-07-20 11:22:14 +08:00
AaronZ345
c4e5d11da9 feat(agent): add explicit active-turn steering 2026-07-19 18:22:05 +08:00
AaronZ345
177f494207 feat(feishu): cancel recalled messages 2026-07-19 18:14:31 +08:00
57 changed files with 1887 additions and 76 deletions

View File

@@ -202,6 +202,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
## 🏷 Changelog
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — Desktop experience improvements, MCP OAuth authorization, Lark channel enhancements, scheduler improvements and data backup, new models.
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.

View File

@@ -183,9 +183,15 @@ class ChatService:
# Register a cancel token so /cancel can abort this in-flight run.
# IM channels key on session_id (no per-turn request_id here).
from agent.protocol import get_cancel_registry
from agent.protocol import get_cancel_registry, get_steer_registry
registry = get_cancel_registry()
steer_registry = get_steer_registry()
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
steer_inbox = (
steer_registry.register(session_id)
if session_id
else None
)
executor = AgentStreamExecutor(
agent=agent,
@@ -197,6 +203,7 @@ class ChatService:
messages=messages_copy,
max_context_turns=max_context_turns,
cancel_event=cancel_event,
steer_inbox=steer_inbox,
)
try:
@@ -217,6 +224,8 @@ class ChatService:
registry.unregister(session_id)
except Exception:
pass
if session_id and steer_inbox is not None:
steer_registry.unregister(session_id, steer_inbox)
# Sync executor messages back to agent (thread-safe).
# The executor may have trimmed context, making its list shorter than

View File

@@ -8,6 +8,13 @@ from .cancel import (
CancelTokenRegistry,
get_cancel_registry,
)
from .steer import (
SteerInbox,
SteerRegistry,
SteerResult,
SteerStatus,
get_steer_registry,
)
__all__ = [
'Agent',
@@ -25,4 +32,9 @@ __all__ = [
'AgentCancelledError',
'CancelTokenRegistry',
'get_cancel_registry',
'SteerInbox',
'SteerRegistry',
'SteerResult',
'SteerStatus',
'get_steer_registry',
]

View File

@@ -381,7 +381,7 @@ class Agent:
return action
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
skill_filter=None, cancel_event=None) -> str:
skill_filter=None, cancel_event=None, steer_inbox=None) -> str:
"""
Execute single agent task with streaming (based on tool-call)
@@ -391,6 +391,7 @@ class Agent:
- Event callbacks
- Persistent conversation history across calls
- User-initiated cancellation via ``cancel_event``
- Explicit active-turn guidance via ``steer_inbox``
Args:
user_message: User message
@@ -403,6 +404,8 @@ class Agent:
"[Interrupted by user]" assistant note, and returns the
partial response. ``messages`` stays in a valid state
(tool_use/tool_result pairs preserved).
steer_inbox: Optional SteerInbox drained at safe checkpoints. New
instructions guide this run without entering the normal queue.
Returns:
Final response text
@@ -448,6 +451,7 @@ class Agent:
messages=messages_copy, # Pass copied message history
max_context_turns=max_context_turns,
cancel_event=cancel_event,
steer_inbox=steer_inbox,
)
# Execute
@@ -484,4 +488,4 @@ class Agent:
def clear_history(self):
"""Clear conversation history and captured actions"""
self.messages = []
self.captured_actions = []
self.captured_actions = []

View File

@@ -99,6 +99,7 @@ class AgentStreamExecutor:
messages: Optional[List[Dict]] = None,
max_context_turns: int = 30,
cancel_event=None,
steer_inbox=None,
):
"""
Initialize stream executor
@@ -116,6 +117,8 @@ class AgentStreamExecutor:
Checked at every safe point (turn boundary, before tool execution,
during LLM streaming). When set, raises AgentCancelledError which
run_stream catches to gracefully wind down.
steer_inbox: Optional SteerInbox for explicit instructions sent to
this active run. Drained only at message-safe checkpoints.
"""
self.agent = agent
self.model = model
@@ -126,6 +129,7 @@ class AgentStreamExecutor:
self.on_event = on_event
self.max_context_turns = max_context_turns
self.cancel_event = cancel_event
self.steer_inbox = steer_inbox
# Message history - use provided messages or create new list
self.messages = messages if messages is not None else []
@@ -145,6 +149,72 @@ class AgentStreamExecutor:
if self.cancel_event is not None and self.cancel_event.is_set():
raise AgentCancelledError("agent cancelled by user")
def _drain_steering(self) -> List[str]:
if self.steer_inbox is None:
return []
return self.steer_inbox.drain()
@staticmethod
def _steering_text(updates: List[str]) -> str:
if len(updates) == 1:
body = updates[0]
else:
body = "\n".join(f"{idx}. {text}" for idx, text in enumerate(updates, 1))
return (
"[Steering update for the active task]\n"
"Use this new instruction for the current task before continuing.\n\n"
f"{body}"
)
def _append_steering(
self,
updates: List[str],
pending_tool_calls: Optional[List[Dict]] = None,
content_blocks: Optional[List[Dict]] = None,
) -> None:
"""Append guidance, closing any tool_use blocks that will be skipped."""
if not updates:
return
blocks = content_blocks if content_blocks is not None else []
for tool_call in pending_tool_calls or []:
blocks.append({
"type": "tool_result",
"tool_use_id": tool_call["id"],
"content": "Skipped because the user redirected the active task.",
"is_error": True,
})
blocks.append({"type": "text", "text": self._steering_text(updates)})
if content_blocks is None:
self.messages.append({"role": "user", "content": blocks})
self._emit_event("agent_steered", {"count": len(updates)})
logger.info(f"[Agent] Applied {len(updates)} steering update(s)")
def _close_or_apply_final_steering(self) -> bool:
"""Return True only when the run can finish without losing a steer."""
updates = self._drain_steering()
if updates:
self._append_steering(updates)
return False
if self.steer_inbox is None:
return True
if self.steer_inbox.close_if_empty():
return True
updates = self._drain_steering()
if updates:
self._append_steering(updates)
return False
def _drain_and_close_steering(self) -> None:
"""Preserve any final guidance before the max-step summary call."""
if self.steer_inbox is None:
return
while True:
updates = self._drain_steering()
if updates:
self._append_steering(updates)
if self.steer_inbox.close_if_empty():
return
def _handle_cancelled(self, partial_response: str) -> None:
"""Wind down ``self.messages`` after a user-initiated cancel.
@@ -395,6 +465,10 @@ class AgentStreamExecutor:
# between turns short-circuits cleanly.
self._check_cancelled()
steering_updates = self._drain_steering()
if steering_updates:
self._append_steering(steering_updates)
turn += 1
logger.info(f"[Agent] Turn {turn}")
self._emit_event("turn_start", {"turn": turn})
@@ -403,6 +477,24 @@ class AgentStreamExecutor:
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
final_response = assistant_msg
# A steer that arrived while the model was streaming takes
# precedence over its proposed continuation. Tool calls have
# already been written to history, so close every one with a
# synthetic result before asking the model to reconsider.
steering_updates = self._drain_steering()
if steering_updates:
self._append_steering(
steering_updates,
pending_tool_calls=tool_calls,
)
self._emit_event("turn_end", {
"turn": turn,
"has_tool_calls": bool(tool_calls),
"tool_count": len(tool_calls),
"steered": True,
})
continue
# No tool calls, end loop
if not tool_calls:
# 检查是否返回了空响应
@@ -467,6 +559,22 @@ class AgentStreamExecutor:
# If the explicit-response retry produced tool_calls, skip the break
# and continue down to the tool execution branch in this same iteration.
if not tool_calls:
steering_updates = self._drain_steering()
if steering_updates:
self._append_steering(steering_updates)
self._emit_event("turn_end", {
"turn": turn,
"has_tool_calls": False,
"steered": True,
})
continue
if not self._close_or_apply_final_steering():
self._emit_event("turn_end", {
"turn": turn,
"has_tool_calls": False,
"steered": True,
})
continue
logger.debug(f"✅ Done (no tool calls)")
self._emit_event("turn_end", {
"turn": turn,
@@ -499,9 +607,17 @@ class AgentStreamExecutor:
tool_result_blocks = []
try:
for tool_call in tool_calls:
for tool_index, tool_call in enumerate(tool_calls):
# Honour cancel between tool invocations within the same turn
self._check_cancelled()
steering_updates = self._drain_steering()
if steering_updates:
self._append_steering(
steering_updates,
pending_tool_calls=tool_calls[tool_index:],
content_blocks=tool_result_blocks,
)
break
result = self._execute_tool(tool_call)
tool_results.append(result)
@@ -641,6 +757,7 @@ class AgentStreamExecutor:
if turn >= self.max_turns:
logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}")
self._drain_and_close_steering()
# Force model to summarize without tool calls
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
@@ -699,6 +816,8 @@ class AgentStreamExecutor:
raise
finally:
if self.steer_inbox is not None:
self.steer_inbox.close()
final_response = final_response.strip() if final_response else final_response
if cancelled:
# Emit before agent_end so channels can mark UI as cancelled

127
agent/protocol/steer.py Normal file
View File

@@ -0,0 +1,127 @@
"""Thread-safe active-run steering primitives.
Steering is deliberately separate from the normal per-session message queue.
An instruction is accepted only while exactly one run for the scoped session
is active; idle sessions never start a new run as a side effect.
"""
from __future__ import annotations
import threading
from collections import deque
from dataclasses import dataclass
from enum import Enum
from typing import Deque, Dict, List, Optional, Set
class SteerStatus(str, Enum):
ACCEPTED = "accepted"
INACTIVE = "inactive"
AMBIGUOUS = "ambiguous"
INVALID = "invalid"
FULL = "full"
CLOSING = "closing"
@dataclass(frozen=True)
class SteerResult:
status: SteerStatus
@property
def accepted(self) -> bool:
return self.status == SteerStatus.ACCEPTED
class SteerInbox:
"""Bounded inbox owned by one active agent run."""
def __init__(self, max_pending: int = 16, max_chars: int = 8000):
self.max_pending = max(1, int(max_pending))
self.max_chars = max(1, int(max_chars))
self._lock = threading.Lock()
self._pending: Deque[str] = deque()
self._accepting = True
def submit(self, instruction: str) -> SteerResult:
text = (instruction or "").strip()
if not text or len(text) > self.max_chars:
return SteerResult(SteerStatus.INVALID)
with self._lock:
if not self._accepting:
return SteerResult(SteerStatus.CLOSING)
if len(self._pending) >= self.max_pending:
return SteerResult(SteerStatus.FULL)
self._pending.append(text)
return SteerResult(SteerStatus.ACCEPTED)
def drain(self) -> List[str]:
with self._lock:
items = list(self._pending)
self._pending.clear()
return items
def close_if_empty(self) -> bool:
"""Atomically stop accepting when no instruction is pending.
This closes the race between a final empty drain and an agent run
returning: after this method succeeds, submitters receive CLOSING.
"""
with self._lock:
if self._pending:
return False
self._accepting = False
return True
def close(self) -> None:
with self._lock:
self._accepting = False
class SteerRegistry:
"""Map a scoped agent/session key to its active run inboxes."""
def __init__(self):
self._lock = threading.Lock()
self._by_session: Dict[str, Set[SteerInbox]] = {}
def register(self, session_id: str, inbox: Optional[SteerInbox] = None) -> SteerInbox:
inbox = inbox or SteerInbox()
if not session_id:
return inbox
with self._lock:
self._by_session.setdefault(session_id, set()).add(inbox)
return inbox
def unregister(self, session_id: str, inbox: Optional[SteerInbox]) -> None:
if not session_id or inbox is None:
return
inbox.close()
with self._lock:
bucket = self._by_session.get(session_id)
if bucket is None:
return
bucket.discard(inbox)
if not bucket:
self._by_session.pop(session_id, None)
def submit(self, session_id: str, instruction: str) -> SteerResult:
if not (instruction or "").strip():
return SteerResult(SteerStatus.INVALID)
with self._lock:
inboxes = list(self._by_session.get(session_id, ()))
if not inboxes:
return SteerResult(SteerStatus.INACTIVE)
if len(inboxes) != 1:
return SteerResult(SteerStatus.AMBIGUOUS)
return inboxes[0].submit(instruction)
def active_count(self, session_id: str) -> int:
with self._lock:
return len(self._by_session.get(session_id, ()))
_registry = SteerRegistry()
def get_steer_registry() -> SteerRegistry:
return _registry

View File

@@ -9,6 +9,7 @@ period of inactivity to free resources.
import os
import sys
import json
import uuid
import queue
import threading
@@ -215,6 +216,14 @@ _SNAPSHOT_JS = """
str(list(_INTERACTIVE_TAGS)),
)
# Returning the snapshot as ONE JSON string instead of a nested object is a big
# win in the frozen desktop build: Playwright serializes a nested return value
# node-by-node over many driver<->python protocol round trips, and each round
# trip carries fixed overhead that is dramatically amplified in the frozen
# bundle (a ~300-node tree can take 20s+). JSON.stringify in-page collapses it
# to a single string transfer; Python then json.loads it. Behaviour identical.
_SNAPSHOT_JS_STR = "() => JSON.stringify((%s)())" % _SNAPSHOT_JS.strip()
_BROWSER_DEAD_HINTS = (
"has been closed",
@@ -321,6 +330,14 @@ class BrowserService:
self._context = None
self._page = None
# When we drive a system Chrome/Edge, we spawn it ourselves with a
# debugging port and attach over CDP (see chrome_launcher). This avoids
# the macOS Automation prompt + multi-second stall that
# chromium.launch(channel=...) incurs. Holds the child process owner.
self._chrome_launcher = None
# Path to the system browser executable when using system-chrome mode.
self._system_exe: Optional[str] = None
# Launch mode: one of "fresh" | "persistent" | "cdp".
# - cdp: connect to an externally launched Chrome via CDP endpoint.
# - persistent: launch with launch_persistent_context using a user_data_dir
@@ -352,13 +369,29 @@ class BrowserService:
# Resolve which browser engine to drive (system Chrome vs downloaded
# Chromium). Deferred detection failures are surfaced at launch time.
#
# For a system Chrome/Edge we DON'T use chromium.launch(channel=...):
# that "takes over" another app and triggers the macOS Automation
# prompt + a long stall. Instead we spawn the browser ourselves with a
# debugging port and attach over CDP (self._launch_mode = "system-cdp").
# `self._system_exe` is the browser executable; the persistent
# user_data_dir keeps login state across sessions.
if self._launch_mode != "cdp":
try:
from agent.tools.browser.browser_env import resolve_engine
engine = resolve_engine(self._config)
if engine["mode"] == "system-chrome":
self._channel = engine["channel"]
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
self._system_exe = engine.get("path")
# Only switch to spawn+CDP when we actually know the exe
# path (macOS/Windows/Linux detection returns it). Persist
# login state in a dedicated profile dir.
if self._system_exe:
self._launch_mode = "system-cdp"
if not self._user_data_dir:
self._user_data_dir = expand_path(_DEFAULT_USER_DATA_DIR)
logger.info(f"[Browser] Engine resolved: {engine['reason']} "
f"(spawn+CDP={bool(self._system_exe)})")
elif engine["mode"] == "playwright-chromium":
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
else:
@@ -509,6 +542,8 @@ class BrowserService:
if self._launch_mode == "cdp":
self._connect_cdp(viewport)
elif self._launch_mode == "system-cdp":
self._launch_system_cdp(launch_args, viewport)
elif self._launch_mode == "persistent":
self._launch_persistent(launch_args, viewport, user_agent)
else:
@@ -577,6 +612,41 @@ class BrowserService:
self._page = pages[0] if pages else self._context.new_page()
self._wire_close_listeners()
def _launch_system_cdp(self, launch_args: List[str], viewport: Dict[str, int]):
"""Spawn the user's system Chrome/Edge with a debugging port, attach via CDP.
This is the default for system browsers. Unlike launch(channel=...), it
does not "take over" the browser app, so it avoids the macOS Automation
prompt / long stall. Login state persists in the isolated user_data_dir.
"""
from agent.tools.browser.chrome_launcher import ChromeLauncher
os.makedirs(self._user_data_dir, exist_ok=True)
logger.info(
f"[Browser] Launching system:{self._channel} via spawn+CDP "
f"(headless={self._headless}, profile={self._user_data_dir})"
)
self._chrome_launcher = ChromeLauncher(
executable=self._system_exe,
user_data_dir=self._user_data_dir,
extra_args=launch_args,
headless=self._headless,
)
endpoint = self._chrome_launcher.launch()
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
# The spawned Chrome opens its own default context (backed by
# user_data_dir); reuse it so cookies / logins persist.
contexts = self._browser.contexts
self._context = contexts[0] if contexts else self._browser.new_context(viewport=viewport)
pages = self._context.pages
self._page = pages[0] if pages else self._context.new_page()
try:
self._page.set_viewport_size(viewport)
except Exception:
pass
self._wire_close_listeners()
def _connect_cdp(self, viewport: Dict[str, int]):
"""Attach to an existing Chrome started with --remote-debugging-port."""
endpoint = self._cdp_endpoint
@@ -631,13 +701,27 @@ class BrowserService:
self._cancel_idle_timer()
if self._launch_mode == "cdp":
# For CDP, browser.close() only detaches the Playwright client;
# the user's Chrome process and its tabs stay alive.
# For external CDP, browser.close() only detaches the Playwright
# client; the user's Chrome process and its tabs stay alive.
try:
if self._browser:
self._browser.close()
except Exception as e:
logger.debug(f"[Browser] cdp disconnect error: {e}")
elif self._launch_mode == "system-cdp":
# We own the spawned Chrome: detach the CDP client, then kill the
# process we started so it doesn't linger.
try:
if self._browser:
self._browser.close()
except Exception as e:
logger.debug(f"[Browser] system-cdp disconnect error: {e}")
try:
if self._chrome_launcher:
self._chrome_launcher.close()
except Exception as e:
logger.debug(f"[Browser] chrome launcher close error: {e}")
self._chrome_launcher = None
else:
for obj, label in [
(self._context, "context"),
@@ -771,7 +855,11 @@ class BrowserService:
def _do_snapshot(self, selector: Optional[str] = None) -> str:
page = self._page
try:
result = page.evaluate(_SNAPSHOT_JS)
# Return a single JSON string (not a nested object) to avoid
# Playwright's per-node serialization round trips, which are slow
# in the frozen build. See _SNAPSHOT_JS_STR.
raw = page.evaluate(_SNAPSHOT_JS_STR)
result = json.loads(raw) if isinstance(raw, str) else raw
except Exception as e:
return f"[Snapshot error: {e}]"

View File

@@ -0,0 +1,174 @@
"""Spawn a system Chrome/Edge with a DevTools debugging port for CDP control.
Why this exists: driving a system browser via Playwright's
``chromium.launch(channel="chrome")`` makes the app *take over* another app's
process, which on macOS triggers a TCC "Automation" permission prompt and a
multi-second (sometimes 100s+) stall on first use. Launching Chrome ourselves
with ``--remote-debugging-port`` and attaching via ``connect_over_cdp`` avoids
that entirely — from the OS's view it's just a process listening on a local
port — and matches how Codex / Claude Code drive the user's real browser.
The launched process uses an isolated ``--user-data-dir`` so it never fights
the user's day-to-day browser profile, while still persisting login state
across sessions inside that dir.
"""
import os
import sys
import time
import socket
import subprocess
import urllib.request
from typing import Optional, List
from common.log import logger
class ChromeLauncher:
"""Own the lifecycle of a debugging-enabled Chrome/Edge child process."""
def __init__(self, executable: str, user_data_dir: str,
extra_args: Optional[List[str]] = None,
headless: bool = False):
self._executable = executable
self._user_data_dir = user_data_dir
self._extra_args = extra_args or []
self._headless = headless
self._proc: Optional[subprocess.Popen] = None
self._port: Optional[int] = None
@property
def endpoint(self) -> str:
"""CDP HTTP endpoint (only valid after a successful launch())."""
return f"http://127.0.0.1:{self._port}" if self._port else ""
@staticmethod
def _free_port() -> int:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
finally:
s.close()
def _clear_stale_singleton_locks(self):
"""Remove leftover Chrome Singleton* locks from a crashed/killed run.
Chrome allows only one instance per user_data_dir and enforces it with
SingletonLock / SingletonSocket / SingletonCookie. On a clean exit these
are removed, but a crash or force-quit leaves them behind — the next
spawn then hands off to the (dead) "existing" instance and exits without
opening the debug port, so CDP never comes up (a permanent, non
self-healing failure). This profile is private to us, so clearing stale
locks before launch is safe: if our own browser were truly alive, the
service would still be connected and we wouldn't be re-launching.
"""
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
p = os.path.join(self._user_data_dir, name)
try:
# These are symlinks; use lexists so a dangling link is caught.
if os.path.lexists(p):
os.remove(p)
logger.info(f"[Browser] cleared stale Chrome lock: {name}")
except OSError as e:
logger.debug(f"[Browser] could not remove {name}: {e}")
def launch(self, ready_timeout: float = 25.0) -> str:
"""Spawn Chrome and block until its CDP endpoint answers.
Returns the CDP endpoint URL. Raises RuntimeError if the endpoint never
comes up (the child process is killed in that case).
"""
os.makedirs(self._user_data_dir, exist_ok=True)
self._clear_stale_singleton_locks()
self._port = self._free_port()
args = [
self._executable,
f"--remote-debugging-port={self._port}",
f"--user-data-dir={self._user_data_dir}",
# Trim first-run overhead and background chatter for faster starts.
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,OptimizationHints",
# A blank first tab keeps startup cheap and predictable.
"about:blank",
]
if self._headless:
args.insert(1, "--headless=new")
args[1:1] = self._extra_args
popen_kwargs = {}
if sys.platform == "win32":
# Detach from any console and never flash a window on Windows.
popen_kwargs["creationflags"] = (
getattr(subprocess, "CREATE_NO_WINDOW", 0)
| getattr(subprocess, "DETACHED_PROCESS", 0)
)
else:
# New session so the child isn't tied to the parent's controlling
# terminal / process group (clean teardown, no signal bleed).
popen_kwargs["start_new_session"] = True
logger.info(f"[Browser] Spawning {os.path.basename(self._executable)} "
f"on CDP port {self._port} (profile={self._user_data_dir})")
self._proc = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**popen_kwargs,
)
if not self._wait_ready(ready_timeout):
# Capture the port before close() clears it, so the error is useful.
port = self._port
self.close()
raise RuntimeError(
f"Chrome did not expose a CDP endpoint on port {port} "
f"within {ready_timeout:.0f}s"
)
return self.endpoint
def _wait_ready(self, timeout: float) -> bool:
"""Poll DevTools /json/version until Chrome is listening (or times out)."""
deadline = time.time() + timeout
url = f"http://127.0.0.1:{self._port}/json/version"
while time.time() < deadline:
# Bail out early if the process died on startup.
if self._proc and self._proc.poll() is not None:
logger.error(
f"[Browser] Chrome exited early (code={self._proc.returncode}) "
"before opening the CDP port"
)
return False
try:
with urllib.request.urlopen(url, timeout=1) as r:
if r.status == 200:
return True
except Exception:
time.sleep(0.15)
return False
def is_alive(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def close(self):
"""Terminate the spawned Chrome process (idempotent)."""
proc = self._proc
self._proc = None
self._port = None
if proc is None:
return
if proc.poll() is not None:
return
try:
proc.terminate()
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
except Exception as e:
logger.debug(f"[Browser] error terminating Chrome process: {e}")

View File

@@ -5,7 +5,13 @@ Agent Bridge - Integrates Agent system with existing COW bridge
import os
from typing import Optional, List
from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry
from agent.protocol import (
Agent,
LLMModel,
LLMRequest,
get_cancel_registry,
get_steer_registry,
)
from bridge.agent_event_handler import AgentEventHandler
from bridge.agent_initializer import AgentInitializer
from bridge.bridge import Bridge
@@ -360,6 +366,11 @@ class AgentBridge:
return agent
def steer_session(self, session_id: str, instruction: str):
"""Inject an explicit instruction into one active session."""
logger.info(f"[AgentBridge] steer new instruction: session={session_id}, content={instruction}")
return get_steer_registry().submit(session_id, instruction)
def get_agent(self, session_id: str = None) -> Optional[Agent]:
"""
Get agent instance for the given session
@@ -452,6 +463,8 @@ class AgentBridge:
agent = None
request_id = None
cancel_event = None
token_key = None
steer_inbox = None
try:
# Extract session_id from context for user isolation
if context:
@@ -534,12 +547,15 @@ class AgentBridge:
pass
try:
if session_id:
steer_inbox = get_steer_registry().register(session_id)
# Use agent's run_stream method with event handler
response = agent.run_stream(
user_message=query,
on_event=event_handler.handle_event,
clear_history=clear_history,
cancel_event=cancel_event,
steer_inbox=steer_inbox,
)
finally:
# Clear the mid-run flag so idle scans can review this session.
@@ -562,6 +578,8 @@ class AgentBridge:
registry.unregister(token_key)
except Exception:
pass
if session_id and steer_inbox is not None:
get_steer_registry().unregister(session_id, steer_inbox)
# Persist new messages generated during this run
if session_id:
@@ -643,6 +661,11 @@ class AgentBridge:
get_cancel_registry().unregister(request_id or session_id)
except Exception:
pass
if session_id and steer_inbox is not None:
try:
get_steer_registry().unregister(session_id, steer_inbox)
except Exception:
pass
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
def _schedule_mcp_hot_reload(self, agent):

View File

@@ -453,6 +453,10 @@ class ChatChannel(Channel):
if stripped in self._BYPASS_QUEUE_COMMANDS:
self._handle_cancel_command(context, session_id)
return
if re.match(r"^/steer(?:\s|$)", stripped):
instruction = context.content.strip()[len("/steer"):].strip()
self._handle_steer_command(context, session_id, instruction)
return
with self.lock:
if session_id not in self.sessions:
@@ -488,6 +492,49 @@ class ChatChannel(Channel):
except Exception as e:
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
def _handle_steer_command(
self,
context: Context,
session_id: str,
instruction: str,
) -> None:
"""Send explicit guidance to the active run without queueing it."""
try:
from agent.protocol import SteerStatus
from bridge.bridge import Bridge
result = Bridge().get_agent_bridge().steer_session(session_id, instruction)
messages = {
SteerStatus.ACCEPTED: _t(
"↪️ 已引导当前任务。", "↪️ Active task redirected."
),
SteerStatus.INACTIVE: _t(
"当前没有可引导的任务。", "No active task to steer."
),
SteerStatus.CLOSING: _t(
"当前任务已结束,无法再引导。", "The active task is already finishing."
),
SteerStatus.AMBIGUOUS: _t(
"当前会话有多个任务在运行,无法确定引导目标。",
"Multiple tasks are active in this session; the steering target is ambiguous.",
),
SteerStatus.FULL: _t(
"引导指令过多,请等待当前任务处理后再试。",
"Too many steering updates are pending; try again after the agent processes them.",
),
SteerStatus.INVALID: _t(
"用法:/steer <引导指令>", "Usage: /steer <instruction>"
),
}
text = messages[result.status]
logger.info(
f"[chat_channel] /steer fast-path: session={session_id}, "
f"status={result.status.value}"
)
self._send_reply(context, Reply(ReplyType.TEXT, text))
except Exception as e:
logger.warning(f"[chat_channel] /steer fast-path failed: {e}")
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
def consume(self):
while True:
@@ -515,6 +562,42 @@ class ChatChannel(Channel):
semaphore.release()
time.sleep(0.2)
def cancel_message(self, session_id: str, message_id: str):
"""Cancel one channel message without disturbing later queued work.
Queued contexts are matched by their original channel message ID. An
in-flight agent run is cancelled through the per-request token that the
channel placed on the context before dispatch.
"""
removed = 0
with self.lock:
session = self.sessions.get(session_id)
if session is not None:
context_queue = session[0]
kept = []
for _ in range(context_queue.qsize()):
context = context_queue.get_nowait()
context_queue.task_done()
message = context.get("msg") if context is not None else None
if getattr(message, "msg_id", None) == message_id:
removed += 1
else:
kept.append(context)
for context in kept:
context_queue.put(context)
from agent.protocol import get_cancel_registry
active = get_cancel_registry().cancel_request(message_id)
logger.info(
"[chat_channel] message recall: session=%s, message=%s, queued=%s, active=%s",
session_id,
message_id,
removed,
active,
)
return removed, active
# 取消session_id对应的所有任务只能取消排队的消息和已提交线程池但未执行的任务
def cancel_session(self, session_id):
with self.lock:

View File

@@ -63,7 +63,7 @@ python3 app.py
2. 进入应用详情 -> 事件订阅
3. 选择 **将事件发送至开发者服务器**
4. 填写请求地址: `http://your-domain:9891/`
5. 添加事件: `im.message.receive_v1` (接收消息v2.0)
5. 添加事件: `im.message.receive_v1` (接收消息v2.0)`im.message.recalled_v1` (消息撤回)
6. 保存配置
### 4. 注意事项
@@ -101,7 +101,7 @@ python3 app.py
1. 登录[飞书开放平台](https://open.feishu.cn/)
2. 进入应用详情 -> 事件订阅
3. 选择 **使用长连接接收事件**
4. 添加事件: `im.message.receive_v1` (接收消息v2.0)
4. 添加事件: `im.message.receive_v1` (接收消息v2.0)`im.message.recalled_v1` (消息撤回)
5. 保存配置
### 5. 注意事项
@@ -168,7 +168,7 @@ Address already in use
### 收不到消息
1. 检查飞书应用的事件订阅配置
2. 确认已添加 `im.message.receive_v1` 事件
2. 确认已添加 `im.message.receive_v1``im.message.recalled_v1` 事件
3. 检查应用权限: 需要 `im:message` 权限
4. 查看日志中的错误信息

View File

@@ -251,6 +251,8 @@ class FeiShuChanel(ChatChannel):
super().__init__()
# 历史消息id暂存用于幂等控制
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
# Route recall events back to the session that accepted the message.
self._message_sessions = ExpiredDict(60 * 60 * 7.1)
self._http_server = None
self._ws_client = None
self._ws_thread = None
@@ -387,6 +389,20 @@ class FeiShuChanel(ChatChannel):
except Exception as e:
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
def handle_message_recalled_event(
data: lark.im.v1.P2ImMessageRecalledV1,
) -> None:
"""Cancel only the task created by the recalled Feishu message."""
try:
logger.info("[FeiShu] websocket received message recall event")
event_dict = json.loads(lark.JSON.marshal(data))
self._handle_message_recalled_event(event_dict.get("event", {}))
except Exception as e:
logger.error(
f"[FeiShu] websocket handle message recall error: {e}",
exc_info=True,
)
def handle_card_action(data):
"""Handle Card 2.0 button callbacks and update the card in place."""
try:
@@ -403,6 +419,7 @@ class FeiShuChanel(ChatChannel):
event_handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(handle_message_event)
.register_p2_im_message_recalled_v1(handle_message_recalled_event)
.register_p2_card_action_trigger(handle_card_action)
.build()
)
@@ -591,6 +608,29 @@ class FeiShuChanel(ChatChannel):
)
return response
def _handle_message_recalled_event(self, event: dict):
"""Cancel one recalled message while preserving later queued messages."""
message_id = event.get("message_id")
if not message_id:
logger.warning(f"[FeiShu] invalid message recall event: {event}")
return 0, False
session_id = self._message_sessions.get(message_id)
if not session_id:
logger.info(
f"[FeiShu] ignored recall for unknown message, message_id={message_id}"
)
return 0, False
result = self.cancel_message(session_id, message_id)
self._message_sessions.pop(message_id, None)
logger.info(
"[FeiShu] recalled message cancelled, "
f"message_id={message_id}, session_id={session_id}, "
f"queued={result[0]}, active={result[1]}"
)
return result
def _handle_message_event(self, event: dict):
"""
处理消息事件的核心逻辑
@@ -724,6 +764,10 @@ class FeiShuChanel(ChatChannel):
no_need_at=True
)
if context:
# Feishu recall events only include message_id/chat_id. Keep the
# accepted route and use message_id as the agent cancellation key.
context["request_id"] = msg_id
self._message_sessions[msg_id] = context["session_id"]
# 流式回复模式:向 context 注入 on_event 回调agent 每产出一段文字时会调用它。
# 回调内部先发送一条占位消息获取 message_id之后通过 PATCH 接口原地更新内容,
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True
@@ -2095,6 +2139,7 @@ class FeishuController:
FAILED_MSG = '{"success": false}'
SUCCESS_MSG = '{"success": true}'
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
MESSAGE_RECALLED_TYPE = "im.message.recalled_v1"
CARD_ACTION_TYPE = "card.action.trigger"
def GET(self):
@@ -2134,6 +2179,8 @@ class FeishuController:
# 3. Handle message events.
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
channel._handle_message_event(event)
elif event_type == self.MESSAGE_RECALLED_TYPE and event:
channel._handle_message_recalled_event(event)
return self.SUCCESS_MSG

View File

@@ -42,6 +42,7 @@ TELEGRAM_BOT_COMMANDS = [
("knowledge", "Manage knowledge base (list/on/off)"),
("config", "Show current config"),
("cancel", "Cancel running agent task"),
("steer", "Guide the running agent task"),
("logs", "Show recent logs"),
("version", "Show version"),
]

View File

@@ -485,6 +485,20 @@
<i class="fas fa-microphone text-sm"></i>
</button>
</div>
<button id="steer-btn"
class="hidden flex-shrink-0 w-10 h-10 items-center justify-center rounded-lg
border border-primary-300 dark:border-primary-700
text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
disabled:text-slate-300 dark:disabled:text-slate-600
disabled:border-slate-200 dark:disabled:border-slate-700
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
type="button"
data-i18n-title="steer_active"
data-i18n-aria-label="steer_active"
aria-label="引导当前任务"
title="引导当前任务">
<i class="fas fa-arrow-turn-up text-sm"></i>
</button>
<button id="send-btn"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
bg-primary-400 text-white hover:bg-primary-500

View File

@@ -123,6 +123,8 @@ const I18N = {
slash_knowledge_off: '关闭知识库',
slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_steer: '向当前正在运行的 Agent 任务注入引导指令',
steer_active: '引导当前任务',
slash_logs: '查看最近日志',
slash_version: '查看版本',
input_placeholder: '输入消息,或输入 / 使用指令',
@@ -375,6 +377,8 @@ const I18N = {
slash_knowledge_off: '關閉知識庫',
slash_config: '檢視當前設定',
slash_cancel: '中止當前正在執行的 Agent 任務',
slash_steer: '向當前正在執行的 Agent 任務注入引導指令',
steer_active: '引導當前任務',
slash_logs: '檢視最近日誌',
slash_version: '檢視版本',
input_placeholder: '輸入訊息,或輸入 / 使用指令',
@@ -626,6 +630,8 @@ const I18N = {
slash_knowledge_off: 'Disable knowledge base',
slash_config: 'Show current config',
slash_cancel: 'Abort the running Agent task',
slash_steer: 'Inject guidance into the running Agent task',
steer_active: 'Steer active task',
slash_logs: 'Show recent logs',
slash_version: 'Show version',
input_placeholder: 'Type a message, or press / for commands',
@@ -816,6 +822,9 @@ function applyI18n() {
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset['i18nTitle']);
});
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
el.setAttribute('aria-label', t(el.dataset['i18nAriaLabel']));
});
document.querySelectorAll('[data-tip-key]').forEach(el => {
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
});
@@ -1415,6 +1424,7 @@ startPolling();
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const steerBtn = document.getElementById('steer-btn');
const messagesDiv = document.getElementById('chat-messages');
const fileInput = document.getElementById('file-input');
const folderInput = document.getElementById('folder-input');
@@ -1749,6 +1759,7 @@ function setSendBtnCancelMode(requestId) {
sendBtn.classList.add('send-btn-cancel');
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
updateSteerBtnState();
}
function resetSendBtnSendMode() {
@@ -1757,9 +1768,64 @@ function resetSendBtnSendMode() {
sendBtn.classList.remove('send-btn-cancel');
sendBtn.title = '';
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
steerBtn.classList.add('hidden');
steerBtn.classList.remove('flex');
steerBtn.disabled = true;
updateSendBtnState();
}
function updateSteerBtnState() {
// Keep the steer button enabled whenever a task is running so users can
// fire successive guidance. Empty-input is guarded in steerActiveTask,
// avoiding a jarring disabled/not-allowed state right after each steer.
const active = sendBtnMode === 'cancel' && !!activeRequestId;
steerBtn.classList.toggle('hidden', !active);
steerBtn.classList.toggle('flex', active);
steerBtn.disabled = !active || uploadingCount > 0;
}
function steerActiveTask() {
const instruction = chatInput.value.trim();
if (!instruction || sendBtnMode !== 'cancel' || !activeRequestId) return;
inputHistory.push(instruction);
historyIdx = -1;
historySavedDraft = '';
addUserMessage(`${instruction}`, new Date());
chatInput.value = '';
chatInput.style.height = '42px';
chatInput.style.overflowY = 'hidden';
updateSteerBtnState();
fetch('/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
message: instruction,
steer: true,
stream: false,
lang: currentLang,
}),
})
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.inline_reply) {
addBotMessage(data.inline_reply, new Date());
} else {
addBotMessage(t('error_send'), new Date());
}
})
.catch(err => {
console.warn('[steer] request failed', err);
addBotMessage(t('error_send'), new Date());
})
.finally(updateSteerBtnState);
}
steerBtn.addEventListener('click', steerActiveTask);
function requestCancel() {
const reqId = activeRequestId;
if (!reqId) return;
@@ -1795,10 +1861,12 @@ function updateSendBtnState() {
resetSendBtnSendMode();
} else {
// Don't downgrade a genuinely active Cancel button on input edits.
updateSteerBtnState();
return;
}
}
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
updateSteerBtnState();
}
function renderAttachmentPreview() {
@@ -2117,6 +2185,7 @@ const SLASH_COMMANDS = [
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
{ cmd: '/config', desc: 'slash_config' },
{ cmd: '/cancel', desc: 'slash_cancel' },
{ cmd: '/steer ', desc: 'slash_steer' },
{ cmd: '/logs', desc: 'slash_logs' },
{ cmd: '/version', desc: 'slash_version' },
];

View File

@@ -6,6 +6,7 @@ import logging
import mimetypes
import os
import random
import re
import shutil
import threading
import time
@@ -134,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
return "Nothing to cancel." if en else "当前没有可中止的任务。"
def _steer_reply_text(status, lang: str) -> str:
from agent.protocol import SteerStatus
en = (lang or "").lower().startswith("en")
messages = {
SteerStatus.ACCEPTED: (
"↪️ Active task redirected.", "↪️ 已引导当前任务。"
),
SteerStatus.INACTIVE: (
"No active task to steer.", "当前没有可引导的任务。"
),
SteerStatus.CLOSING: (
"The active task is already finishing.", "当前任务已结束,无法再引导。"
),
SteerStatus.AMBIGUOUS: (
"Multiple tasks are active in this session; the steering target is ambiguous.",
"当前会话有多个任务在运行,无法确定引导目标。",
),
SteerStatus.FULL: (
"Too many steering updates are pending; try again after the agent processes them.",
"引导指令过多,请等待当前任务处理后再试。",
),
SteerStatus.INVALID: (
"Usage: /steer <instruction>", "用法:/steer <引导指令>"
),
}
english, chinese = messages[status]
return english if en else chinese
def _get_upload_dir() -> str:
from common.utils import expand_path
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
@@ -912,6 +943,37 @@ class WebChannel(ChatChannel):
"inline_reply": msg_text,
})
# Explicit steering also bypasses the normal session queue. The
# Web button sends ``steer: true`` with raw input; typed /steer
# commands use the same endpoint and semantics as IM channels.
steer_requested = bool(json_data.get("steer", False))
is_steer_command = (
re.match(r"^/steer(?:\s|$)", stripped_prompt) is not None
)
if steer_requested or is_steer_command:
instruction = (
(prompt or "").strip()[len("/steer"):].strip()
if is_steer_command
else (prompt or "").strip()
)
from bridge.bridge import Bridge
result = Bridge().get_agent_bridge().steer_session(
session_id, instruction
)
lang = (json_data.get("lang") or "zh").lower()
msg_text = _steer_reply_text(result.status, lang)
logger.info(
f"[WebChannel] steer fast-path: session={session_id}, "
f"status={result.status.value}, lang={lang}"
)
return json.dumps({
"status": "success",
"request_id": "",
"stream": False,
"steered": result.accepted,
"inline_reply": msg_text,
}, ensure_ascii=False)
# Append file references to the prompt (same format as QQ channel)
if attachments:
file_refs = []

View File

@@ -1 +1 @@
2.1.3
2.1.4

View File

@@ -0,0 +1,115 @@
import { ipcMain, net } from 'electron'
// A small HTTP relay so the renderer can reach external HTTPS endpoints from
// the main process (the file:// renderer origin is otherwise blocked by CORS).
// It's deliberately generic and carries no product-specific knowledge; any
// optional extension can use it. Requests are limited to https to avoid it
// becoming an open local proxy.
export interface RelayRequest {
url: string
method?: string
headers?: Record<string, string>
// Stringified body (callers serialize JSON/form themselves).
body?: string
}
export interface RelayResponse {
ok: boolean
status: number
headers: Record<string, string>
body: string
}
const MAX_BODY_BYTES = 8 * 1024 * 1024
// Relay callers are all lightweight JSON endpoints (login, codes, balances,
// model lists); a 10s cap is generous while still preventing a stalled request
// from hanging forever (and, for pollers, piling up across ticks).
const REQUEST_TIMEOUT_MS = 10 * 1000
function relay(req: RelayRequest): Promise<RelayResponse> {
return new Promise((resolve, reject) => {
let parsed: URL
try {
parsed = new URL(req.url)
} catch {
reject(new Error('invalid url'))
return
}
if (parsed.protocol !== 'https:') {
reject(new Error('only https is allowed'))
return
}
const request = net.request({
method: req.method || 'GET',
url: req.url,
})
if (req.headers) {
for (const [k, v] of Object.entries(req.headers)) request.setHeader(k, v)
}
// Cap the whole request (connect + response) so a stalled endpoint can't
// hang forever. On timeout we abort, which surfaces as an 'error' event.
// `done` guards against settling twice once the timer has fired.
let done = false
const timer = setTimeout(() => {
if (done) return
request.abort()
reject(new Error('request timeout'))
}, REQUEST_TIMEOUT_MS)
const settle = (fn: () => void) => {
if (done) return
done = true
clearTimeout(timer)
fn()
}
request.on('response', (response) => {
const chunks: Buffer[] = []
let size = 0
let aborted = false
response.on('data', (chunk: Buffer) => {
if (aborted || done) return
size += chunk.length
if (size > MAX_BODY_BYTES) {
aborted = true
request.abort()
settle(() => reject(new Error('response too large')))
return
}
chunks.push(chunk)
})
response.on('end', () => {
if (aborted) return
const headers: Record<string, string> = {}
for (const [k, v] of Object.entries(response.headers)) {
headers[k] = Array.isArray(v) ? v.join(', ') : String(v)
}
const status = response.statusCode || 0
settle(() =>
resolve({
ok: status >= 200 && status < 300,
status,
headers,
body: Buffer.concat(chunks).toString('utf8'),
}),
)
})
})
request.on('error', (err) => settle(() => reject(err)))
if (req.body != null) request.write(req.body)
request.end()
})
}
export function setupHttpRelayIPC() {
ipcMain.handle('http-relay', async (_event, req: RelayRequest) => {
try {
return await relay(req)
} catch (e) {
return { ok: false, status: 0, headers: {}, body: String((e as Error).message) }
}
})
}

View File

@@ -6,11 +6,13 @@ import { PythonBackend } from './python-manager'
import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
import { setupThemeIPC } from './themes'
import { setupThemeIPC, loadAppConfig } from './themes'
import { setupHttpRelayIPC } from './http-relay'
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
// where the default Electron binary would otherwise report "Electron".
app.setName('CowAgent')
// Force the product name so the Dock/menu shows the app name even in dev mode,
// where the default Electron binary would otherwise report "Electron". The name
// can be overridden by the bundled app-config (appName); defaults to CowAgent.
app.setName(loadAppConfig()?.appName || 'CowAgent')
let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null
@@ -308,6 +310,7 @@ app.whenReady().then(async () => {
setupIPC()
setupThemeIPC()
setupHttpRelayIPC()
createWindow()
buildAppMenu(() => mainWindow)
// No menu-bar tray on macOS — the Dock + window controls are enough there.

View File

@@ -51,6 +51,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAppConfig: () =>
ipcRenderer.invoke('app-config-get') as Promise<{ defaultTheme?: string; appName?: string } | null>,
// Generic HTTPS relay via the main process (bypasses the renderer's CORS
// restrictions for external endpoints). Optional extensions may use it.
httpRelay: (req: {
url: string
method?: string
headers?: Record<string, string>
body?: string
}) =>
ipcRenderer.invoke('http-relay', req) as Promise<{
ok: boolean
status: number
headers: Record<string, string>
body: string
}>,
// Auto-update: trigger checks/download/install and subscribe to status. The
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),

View File

@@ -59,6 +59,9 @@ function bundledThemesDir(): string | null {
export interface AppConfig {
defaultTheme?: string
appName?: string
// Optional override for the auto-update feed base URL. When set, the updater
// uses it as-is instead of the default build's feed.
updateFeedUrl?: string
}
function appConfigPath(): string {

View File

@@ -7,6 +7,7 @@ import path from 'path'
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
// whereas `import pkg from 'electron-updater'` yields undefined.
import { autoUpdater } from 'electron-updater'
import { loadAppConfig } from './themes'
// Status payloads pushed to the renderer over the 'update-status' channel.
// The renderer drives the NavRail badge + update panel from these.
@@ -33,6 +34,11 @@ function isLegacyWindows(): boolean {
return Number.isFinite(major) && major < 10
}
// A bundled app-config may point the updater at a different feed origin. When
// set, that single URL is used as-is (no China/R2 dual-origin switching, which
// is specific to the default build's infrastructure). Absent -> default feed.
const CONFIGURED_FEED = (loadAppConfig()?.updateFeedUrl || '').trim()
// The update feed. Both entries hit the same Pages Function
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
// downloads to the China CDN mirror instead of R2. The feed metadata is
@@ -40,7 +46,10 @@ function isLegacyWindows(): boolean {
// to fall back from one download origin to the other. Legacy Windows appends a
// /legacy/ segment so it gets the win-legacy release instead of the standard.
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
const feedUrlFor = (china: boolean) => {
if (CONFIGURED_FEED) return CONFIGURED_FEED
return china ? `${FEED_BASE}?lang=zh` : FEED_BASE
}
// Which origin the current session prefers, derived from the app UI language
// (zh -> China mirror). Downloads that fail on the preferred origin retry once

View File

@@ -23,6 +23,7 @@ import MemoryPage from './pages/MemoryPage'
import ChannelsPage from './pages/ChannelsPage'
import TasksPage from './pages/TasksPage'
import LogsPage from './pages/LogsPage'
import { product } from '@product'
const App: React.FC = () => {
const backend = useBackend()
@@ -37,6 +38,11 @@ const App: React.FC = () => {
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
// the main UI render.
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
const [productAuthed, setProductAuthed] = useState(false)
// Optional gate provided by '@product'. `product.auth` is constant for the
// whole build, so calling its hook conditionally is stable across renders.
// eslint-disable-next-line react-hooks/rules-of-hooks
const productRequiresAuth = product.auth ? product.auth.useRequiresAuth() : false
useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
@@ -72,6 +78,8 @@ const App: React.FC = () => {
// configured (and not dismissed earlier this session); no persisted flag.
useEffect(() => {
if (backend.status !== 'ready' || authState !== 'ok') return
// An extension may opt out of the built-in setup wizard.
if (product.onboarding?.enabled === false) return
let cancelled = false
apiClient
.getModels()
@@ -130,8 +138,14 @@ const App: React.FC = () => {
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
}
// Optional gate from '@product', shown after the local auth check passes.
// Rendered inside the layout (nav rail stays visible) so the app's features
// are on display while the login card sits in the content area.
const ProductGate = product.auth?.Gate
const showProductGate = !!(ProductGate && productRequiresAuth && !productAuthed)
const isChat = location.pathname === '/'
const showSessions = isChat && !sessionsCollapsed
const showSessions = isChat && !sessionsCollapsed && !showProductGate
return (
<div className="flex h-screen overflow-hidden bg-base text-content">
@@ -156,11 +170,19 @@ const App: React.FC = () => {
</button>
)}
<div className="flex-1 min-w-0" />
{product.slots?.HeaderRight && (
<div className="titlebar-no-drag flex items-center">
<product.slots.HeaderRight />
</div>
)}
{isWin && <WindowControls />}
</header>
{/* Content */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
{showProductGate && ProductGate ? (
<ProductGate onAuthenticated={() => setProductAuthed(true)} />
) : (
<Routes>
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
<Route path="/knowledge" element={<KnowledgePage baseUrl={backend.baseUrl} />} />
@@ -172,7 +194,11 @@ const App: React.FC = () => {
{/* Legacy /models route now lives as a tab inside settings */}
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
{product.routes?.map((r) => (
<Route key={r.path} path={r.path} element={r.element} />
))}
</Routes>
)}
</div>
</div>
</div>

View File

@@ -34,6 +34,7 @@ import { useTheme } from '../hooks/useTheme'
import { usePlatform } from '../hooks/usePlatform'
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
import UpdateBanner from '../components/UpdateBanner'
import { product } from '@product'
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
// in sync with desktop/package.json "version"; the packaged app overrides this
@@ -220,7 +221,9 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
</div>
{/* Footer actions: a single "more" entry (with version + update dot) that
opens an upward popover, plus the always-visible collapse toggle. */}
opens an upward popover, plus the always-visible collapse toggle. An
optional '@product' slot sits on the left (e.g. an account avatar),
taking the spot the "more" entry would otherwise occupy. */}
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
{menuOpen && (
<FooterMenu
@@ -246,27 +249,36 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
)}
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
{/* Single clickable entry: version label (left) + the three dots
(right) form one button; the whole block opens the popover. The
version is the packaged app version, also what auto-update
compares against. Collapsed: dots only, version hidden. */}
<button
onClick={() => setMenuOpen((o) => !o)}
title={t('menu_more')}
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
>
{!collapsed && version && (
<span className="text-[12px] truncate">{`v${version}`}</span>
)}
<MoreHorizontal size={17} className="flex-shrink-0" />
{pendingUpdate && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
)}
</button>
{/* Left side: either the built-in "more" entry (version + dots) or,
when an extension provides one and hides the built-in menu, its
footer slot (e.g. an account avatar). */}
{product.slots?.NavRailFooter && product.nav?.hideFooterMenu ? (
<div className={collapsed ? '' : 'flex-1 min-w-0'}>
<product.slots.NavRailFooter />
</div>
) : (
!product.nav?.hideFooterMenu && (
<button
onClick={() => setMenuOpen((o) => !o)}
title={t('menu_more')}
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
>
{!collapsed && version && (
<span className="text-[12px] truncate">{`v${version}`}</span>
)}
<MoreHorizontal size={17} className="flex-shrink-0" />
{pendingUpdate && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
)}
</button>
)
)}
{!collapsed && <div className="flex-1" />}
{!collapsed && !(product.slots?.NavRailFooter && product.nav?.hideFooterMenu) && (
<div className="flex-1" />
)}
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
@@ -335,17 +347,21 @@ const FooterMenu: React.FC<{
: t('update_check')
return (
<div className="absolute bottom-full left-2 right-2 mb-2 z-50 rounded-lg border border-default bg-elevated shadow-lg py-1">
{/* External destinations first (skill hub, docs, website) */}
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
<MenuItem
icon={<MessageSquareWarning size={16} />}
label={t('menu_feedback')}
onClick={() => onOpenLink(FEEDBACK_URL)}
/>
<div className="my-1 border-t border-subtle" />
{/* External destinations (skill hub, docs, website, feedback). An
extension may hide this group to keep the menu to app actions only. */}
{!product.nav?.hideExternalLinks && (
<>
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
<MenuItem
icon={<MessageSquareWarning size={16} />}
label={t('menu_feedback')}
onClick={() => onOpenLink(FEEDBACK_URL)}
/>
<div className="my-1 border-t border-subtle" />
</>
)}
{/* App actions below: update, theme, language, logs */}
<MenuItem

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { useLocation } from 'react-router-dom'
import { t } from '../i18n'
import { product } from '@product'
import BasicSettings from './settings/BasicSettings'
import ModelsTab from './settings/ModelsTab'
@@ -13,13 +14,15 @@ type Tab = 'basic' | 'models'
const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) => {
const location = useLocation()
const modelsTabHidden = product.models?.hideModelsTab === true
// Allow deep-linking to the models tab via /settings?tab=models.
const initial: Tab = new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic'
const initial: Tab =
!modelsTabHidden && new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic'
const [tab, setTab] = useState<Tab>(initial)
const tabs: { key: Tab; label: string }[] = [
{ key: 'basic', label: t('settings_tab_basic') },
{ key: 'models', label: t('settings_tab_models') },
...(modelsTabHidden ? [] : [{ key: 'models' as Tab, label: t('settings_tab_models') }]),
]
return (
@@ -47,8 +50,12 @@ const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) =>
))}
</div>
{tab === 'basic' ? (
<BasicSettings baseUrl={baseUrl} onLangChange={onLangChange} onOpenModels={() => setTab('models')} />
{tab === 'basic' || modelsTabHidden ? (
<BasicSettings
baseUrl={baseUrl}
onLangChange={onLangChange}
onOpenModels={modelsTabHidden ? undefined : () => setTab('models')}
/>
) : (
<ModelsTab baseUrl={baseUrl} />
)}

View File

@@ -2,9 +2,14 @@ import React, { useState, useEffect } from 'react'
import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'
import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n'
import apiClient from '../../api/client'
import { product } from '@product'
import type { ConfigData, ProviderMeta } from '../../types'
import { Card, Field, Dropdown, Toggle, TextInput, SaveRow, MASK_RE } from './primitives'
const CustomModelPicker = product.models?.ModelPicker
const hideProviderSelect = product.models?.hideProviderSelect === true
const showManagedApiKey = product.models?.showManagedApiKey === true
interface BasicSettingsProps {
baseUrl: string
onLangChange?: () => void
@@ -22,6 +27,11 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
const [showCustom, setShowCustom] = useState(false)
const [modelStatus, setModelStatus] = useState('')
// managed API key (shown only when the standalone models tab is hidden)
const [apiKey, setApiKey] = useState('')
const [apiKeyDirty, setApiKeyDirty] = useState(false)
const [apiKeyVisible, setApiKeyVisible] = useState(false)
// agent card
const [maxTokens, setMaxTokens] = useState(100000)
const [maxTurns, setMaxTurns] = useState(20)
@@ -64,6 +74,10 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
const current = data.use_linkai ? 'linkai' : data.bot_type || ids[0] || ''
setProvider(current)
const meta = data.providers?.[current] as ProviderMeta | undefined
// Managed key: show the masked value for the current provider's key field.
const keyField = meta?.api_key_field
setApiKey((keyField && data.api_keys?.[keyField]) || '')
setApiKeyDirty(false)
const presets = meta?.models || []
if (data.model && presets.length && !presets.includes(data.model)) {
setShowCustom(true)
@@ -99,8 +113,10 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
}
const saveModelConfig = async () => {
const finalModel = showCustom ? customModel.trim() : model
const isLinkai = provider === 'linkai'
const finalModel = CustomModelPicker ? model : showCustom ? customModel.trim() : model
// With a managed model source the provider selector is hidden; route through
// the managed provider so credentials resolve consistently.
const isLinkai = CustomModelPicker ? true : provider === 'linkai'
try {
await apiClient.updateConfig({
model: finalModel,
@@ -116,6 +132,27 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
setTimeout(() => setModelStatus(''), 2000)
}
const currentKeyField = (config?.providers?.[provider] as ProviderMeta | undefined)?.api_key_field
const saveApiKey = async () => {
if (!apiKeyDirty || !currentKeyField) return
// Never save a masked value back as the real key.
if (MASK_RE.test(apiKey)) return
try {
await apiClient.updateConfig({ [currentKeyField]: apiKey })
setModelStatus(t('config_saved'))
setApiKeyDirty(false)
const fresh = await apiClient.getConfig()
setConfig(fresh)
const meta = fresh.providers?.[provider] as ProviderMeta | undefined
const keyField = meta?.api_key_field
setApiKey((keyField && fresh.api_keys?.[keyField]) || '')
} catch {
setModelStatus(t('config_save_error'))
}
setTimeout(() => setModelStatus(''), 2000)
}
const saveAgentConfig = async () => {
try {
await apiClient.updateConfig({
@@ -200,21 +237,60 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
{/* Model — provider/model selection only; credentials live in Models tab */}
<Card icon={<Cpu size={16} />} title={t('config_model')}>
<div className="space-y-4">
<Field label={t('config_provider')}>
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
</Field>
{!hideProviderSelect && (
<Field label={t('config_provider')}>
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
</Field>
)}
<Field label={t('config_model_name')}>
<Dropdown value={showCustom ? '__custom__' : model} options={modelOptions} onChange={handleModelChange} />
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
{CustomModelPicker ? (
<CustomModelPicker value={model} onChange={setModel} />
) : (
<>
<Dropdown
value={showCustom ? '__custom__' : model}
options={modelOptions}
onChange={handleModelChange}
/>
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
)}
</>
)}
</Field>
{/* Managed API key: hidden by default, click the eye to reveal the
partially-masked value (e.g. sk-1****9aL7). Editable in place; if
left untouched (still contains a mask char) it is not overwritten. */}
{showManagedApiKey && currentKeyField && (
<Field label={t('onboarding_apikey')}>
<div className="relative">
<TextInput
type={apiKeyVisible ? 'text' : 'password'}
className="pr-10 font-mono"
value={apiKey}
placeholder="sk-..."
onChange={(e) => {
setApiKey(e.target.value.trim())
setApiKeyDirty(true)
}}
/>
<button
type="button"
onClick={() => setApiKeyVisible((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
>
{apiKeyVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</Field>
)}
{/* Guide users to the Models tab for API key / base config.
When the selected provider has no credentials, surface a warning. */}
{onOpenModels && (
@@ -240,7 +316,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
</button>
)}
<SaveRow status={modelStatus} onSave={saveModelConfig} />
<SaveRow
status={modelStatus}
onSave={async () => {
await saveModelConfig()
if (showManagedApiKey && apiKeyDirty) await saveApiKey()
}}
/>
</div>
</Card>

View File

@@ -20,6 +20,10 @@ import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
import CapabilityCard from './CapabilityCard'
import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers'
import { product } from '@product'
// Whether the "add custom provider" entry is available. Defaults to true.
const allowCustomProviders = product.models?.allowCustomProviders !== false
interface ModelsTabProps {
baseUrl: string
@@ -330,7 +334,9 @@ const VendorModal: React.FC<{
label: localizedLabel(p.label),
hint: p.configured ? t('models_configured') : undefined,
})),
{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') },
...(allowCustomProviders
? [{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') }]
: []),
]
const onPick = (val: string) => {

View File

@@ -0,0 +1,6 @@
import type { ProductExtension } from '../types'
// Default: no extensions. All behavior stays as-is. An alternate build can
// override the '@product' alias to point at its own module (see
// vite.config.ts) exporting a populated ProductExtension.
export const product: ProductExtension = {}

View File

@@ -0,0 +1,77 @@
// ============================================================
// Optional extension contract.
//
// The core imports a single `product` object from '@product'. By default
// that alias resolves to product/default (an empty object → no change).
// An alternate build can point the alias at another module (see
// vite.config.ts COW_PRODUCT_DIR) and fill in the fields below. Every
// field is optional; absent means "keep the default behavior". The core
// must degrade gracefully when a field is missing.
// ============================================================
import type React from 'react'
// Optional gate rendered before the main UI. When present, the core shows
// <Gate/> until the extension reports the session no longer needs it.
export interface ProductAuth {
Gate: React.FC<{ onAuthenticated: () => void }>
// Whether the gate is currently required. Implementations may use their
// own hooks/state internally.
useRequiresAuth: () => boolean
}
// Optional UI mount points the core renders if provided.
export interface ProductSlots {
// Rendered at the bottom of the nav rail.
NavRailFooter?: React.FC
// Rendered on the right side of the top titlebar strip.
HeaderRight?: React.FC
}
// Extra routes appended to the core <Routes>. Path is a HashRouter path.
export interface ProductRoute {
path: string
element: React.ReactNode
}
export interface ProductOnboarding {
// Set false to disable the built-in setup wizard. Defaults to enabled.
enabled?: boolean
}
export interface ProductModels {
// Set false to hide the "add custom provider" entry. Defaults to allowed.
allowCustomProviders?: boolean
// Set true to hide the standalone "models" settings tab. Defaults to shown.
hideModelsTab?: boolean
// Set true to hide the provider dropdown in basic settings (e.g. when the
// model list comes from a single managed source). Defaults to shown.
hideProviderSelect?: boolean
// Optional replacement for the model selection control in basic settings.
// Controlled: receives the current model id and reports changes. When set,
// the core renders this instead of its built-in model dropdown.
ModelPicker?: React.FC<{ value: string; onChange: (model: string) => void }>
// Set true to show a masked+editable API key field for the current provider
// inside basic settings, useful when the standalone models tab is hidden.
showManagedApiKey?: boolean
}
// Optional nav-rail customization. Lets a build tailor the footer menu's
// external destinations without touching core code.
export interface ProductNav {
// Set true to hide the built-in external links group (skill hub, docs,
// website, feedback). Defaults to shown.
hideExternalLinks?: boolean
// Set true to hide the built-in footer "more" entry (version label + menu),
// e.g. when an extension provides its own footer menu. The collapse toggle
// stays. Defaults to shown.
hideFooterMenu?: boolean
}
export interface ProductExtension {
auth?: ProductAuth
slots?: ProductSlots
routes?: ProductRoute[]
onboarding?: ProductOnboarding
models?: ProductModels
nav?: ProductNav
}

View File

@@ -27,6 +27,13 @@ export interface ElectronAPI {
// Optional app config: first-run default theme + display name. Null when
// the build ships no app config (standard build).
getAppConfig?: () => Promise<{ defaultTheme?: string; appName?: string } | null>
// Generic HTTPS relay via the main process (bypasses renderer CORS).
httpRelay?: (req: {
url: string
method?: string
headers?: Record<string, string>
body?: string
}) => Promise<{ ok: boolean; status: number; headers: Record<string, string>; body: string }>
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
checkForUpdate?: (lang?: string) => Promise<void>
downloadUpdate?: (lang?: string) => Promise<void>

View File

@@ -1,6 +1,14 @@
const path = require('path')
// When '@product' points outside this project (COW_PRODUCT_DIR), scan that
// directory too so classes used only there still get generated.
const productContent = process.env.COW_PRODUCT_DIR
? [path.join(process.env.COW_PRODUCT_DIR, '**/*.{tsx,ts}')]
: []
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/renderer/**/*.{html,tsx,ts}'],
content: ['./src/renderer/**/*.{html,tsx,ts}', ...productContent],
darkMode: 'class',
theme: {
extend: {

View File

@@ -16,7 +16,12 @@
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"esModuleInterop": true
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/renderer/src/*"],
"@product": ["src/renderer/src/product/default"]
}
},
"include": ["src/renderer"]
}

View File

@@ -2,6 +2,25 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// '@product' resolves to the built-in default (empty). An alternate build
// can set COW_PRODUCT_DIR to point at another module instead.
const productDir =
process.env.COW_PRODUCT_DIR || path.resolve(__dirname, 'src/renderer/src/product/default')
// When '@product' points outside this project, its files can't resolve shared
// deps from their own tree. Alias the shared runtime deps to this project's
// node_modules so an out-of-tree product module imports the same instances.
const nodeModules = path.resolve(__dirname, 'node_modules')
const sharedDepAliases = process.env.COW_PRODUCT_DIR
? {
react: path.join(nodeModules, 'react'),
'react-dom': path.join(nodeModules, 'react-dom'),
'react/jsx-runtime': path.join(nodeModules, 'react/jsx-runtime'),
'react-router-dom': path.join(nodeModules, 'react-router-dom'),
'lucide-react': path.join(nodeModules, 'lucide-react'),
}
: {}
export default defineConfig({
plugins: [react()],
root: path.resolve(__dirname, 'src/renderer'),
@@ -17,6 +36,8 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer/src'),
'@product': productDir,
...sharedDepAliases,
},
},
})

View File

@@ -85,7 +85,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
3. (Optional) Under **Callbacks**, add **Card Action Trigger** (`card.action.trigger`) to use the `/tasks` controls. Only needed for `/tasks` scheduler management; skip it otherwise.
3. (Optional) Under **Callbacks**, add **Card Action Trigger** (`card.action.trigger`) to enable `/tasks` scheduler commands; add the **Message Recalled** (`im.message.recalled_v1`) event to cancel a task by recalling its message.
4. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:

View File

@@ -94,6 +94,7 @@ On startup, the channel registers a command menu with BotFather. Typing `/` in T
| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) |
| `/config` | View current config |
| `/cancel` | Cancel the running Agent task |
| `/steer` | Guide the running Agent task (`/steer <instruction>`) |
| `/logs` | View recent logs |
| `/version` | Show version |

View File

@@ -33,6 +33,16 @@ Abort the agent task currently running in this session. When the agent is busy w
/cancel
```
## steer
Redirect the Agent task currently running in this session without cancelling it. The instruction is injected at the next safe checkpoint; a tool that is already running may finish, while tools that have not started are skipped. If no task is active, `/steer` does not start or queue a new one. Available across all chat channels.
```text
/steer focus on the failing tests first
```
In the Web console, enter an instruction while a reply is running and click **Steer active task**. Sending an ordinary message still uses the session queue.
## config
View or modify runtime configuration. Changes take effect immediately without restarting.

View File

@@ -62,6 +62,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
| `/help` | Show command help |
| `/status` | View service status and configuration |
| `/cancel` | Abort the currently running agent task |
| `/steer <instruction>` | Guide the currently running agent task without queueing a new turn |
| `/config` | View or modify runtime configuration |
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |

View File

@@ -259,6 +259,7 @@
"group": "Release Notes",
"pages": [
"releases/overview",
"releases/v2.1.4",
"releases/v2.1.3",
"releases/v2.1.2",
"releases/v2.1.1",
@@ -487,6 +488,7 @@
"group": "发布记录",
"pages": [
"zh/releases/overview",
"zh/releases/v2.1.4",
"zh/releases/v2.1.3",
"zh/releases/v2.1.2",
"zh/releases/v2.1.1",
@@ -714,6 +716,7 @@
"group": "リリースノート",
"pages": [
"ja/releases/overview",
"ja/releases/v2.1.4",
"ja/releases/v2.1.3",
"ja/releases/v2.1.2",
"ja/releases/v2.1.1",

View File

@@ -202,6 +202,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
## 🏷 更新履歴
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — デスクトップの体験改善、MCP の OAuth 認可対応、Feishu チャネルの機能向上、定期タスクとデータバックアップ、新モデル追加。
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [デスクトップクライアント](https://cowagent.ai/download/)macOS / Windows、ナレッジベースのドキュメント管理、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加。
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー、自己進化の改善、新モデルkimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。

View File

@@ -82,7 +82,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
3. (任意)**コールバック** で **カードアクショントリガー**`card.action.trigger`)を追加すると `/tasks` の操作ボタンを使用できます。`/tasks` のスケジューラー管理を使う場合のみ必要で、それ以外はスキップできます。
3. (任意)**コールバック** で **カードアクショントリガー**`card.action.trigger`)を追加すると `/tasks` のスケジューラー管理コマンドを使用できます。**メッセージ撤回**`im.message.recalled_v1`)イベントを追加すると、メッセージの撤回でタスクをキャンセルできます。
4. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:

View File

@@ -94,6 +94,7 @@ description: Telegram Bot API 経由で CowAgent を接続
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off` |
| `/config` | 現在の設定を表示 |
| `/cancel` | 実行中の Agent タスクを中断 |
| `/steer` | 実行中の Agent タスクを方向修正(`/steer <指示>` |
| `/logs` | 最近のログを表示 |
| `/version` | バージョンを表示 |

View File

@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
/cancel
```
## steer
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
```text
/steer 失敗しているテストを先に確認して
```
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
## config
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。

View File

@@ -58,6 +58,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す
| `/help` | コマンドヘルプを表示 |
| `/status` | サービスの状態と設定を表示 |
| `/cancel` | 実行中の Agent タスクを中止 |
| `/steer <指示>` | 新しいターンをキューに追加せず、実行中の Agent タスクを方向修正 |
| `/config` | 実行時設定の表示・変更 |
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30 |

View File

@@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴
| バージョン | 日付 | 説明 |
| --- | --- | --- |
| [2.1.4](/ja/releases/v2.1.4) | 2026.07.20 | デスクトップクライアント改善ブラウザツール、Windows 署名、Win7/8 対応、MCP OAuth 認証、定期タスク・Feishu チャネル強化、データバックアップと復元、新モデル追加kimi-k3、gpt-5.6 |
| [2.1.3](/ja/releases/v2.1.3) | 2026.07.08 | デスクトップクライアント正式リリースmacOS / Windows、ナレッジベースのドキュメント管理強化、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加claude-sonnet-5、doubao-seed-2.1 など)、セキュリティ強化と改善 |
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー、自己進化の改善、新モデル追加kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加MiniMax-M3、qwen3.7-plus など)、各種改善 |

View File

@@ -0,0 +1,78 @@
---
title: v2.1.4
description: "CowAgent 2.1.4デスクトップの体験改善、MCP の OAuth 認可対応、Feishu チャネルの機能向上に加え、定期タスク・データバックアップ・新モデルなど多数の更新"
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
## 🖥 デスクトップクライアント
前バージョンでデスクトップクライアントを正式リリースしたのに続き、今回はブラウザ機能、システム互換性、使い勝手をさらに向上させました:
- **ブラウザツール対応**:クライアントにブラウザ機能を内蔵し、システムにインストール済みの Chrome / Edge を優先的に再利用します。ブラウザの起動とアクセス性能も最適化しました。
- **UI のブラッシュアップ**:チャット画面、メッセージバブル、ツール呼び出しステップ、チャネルページの見た目と操作性を改善しました。
- **Windows コード署名**Windows インストーラーにコード署名を追加し、インストール時のセキュリティ警告を軽減しました。
- **Windows 7/8 対応**Windows 7/8 などの旧システム向けクライアントに対応し、より多くの環境をカバーします。
- **ナレッジベースのリンク修正**:デスクトップ版ナレッジベースのドキュメント内リンクが正しく遷移しない問題を修正しました。
- **パスワードログイン**:デスクトップクライアントでログインパスワードの設定に対応し、パスワード設定後にウィンドウが読み込めない問題を修正しました。
ダウンロード:[CowAgent デスクトップ](https://cowagent.ai/download/)
ドキュメント:[デスクトップクライアント](https://docs.cowagent.ai/guide/desktop)
## 🔌 MCP リモートサービスの OAuth 認可
リモート MCP サービスが **OAuth 認可** に対応しました。ログイン認可が必要なサードパーティ MCP サービスに接続する際、標準の OAuth フローで認証を完了できるため、トークンを手動で設定・管理する必要がなくなります。
ドキュメント:[MCP ツール](https://docs.cowagent.ai/tools/mcp)
## ⏰ 定期タスク
- **サイレントモード**:バックグラウンドで静かに実行され、メッセージを能動的に送信しない定期タスクを作成できます。データ整理や定期アーカイブなど、通知が不要なシーンに最適です。(#2954)
- **手動実行**:次回のスケジュールを待たずに、コンソール画面から既存タスクを手動で即時実行できます。(#2958)
- **編集時の設定保持**Web コンソールでタスクを編集する際、モードタイプなどの隠しフィールドが失われることがある問題を修正しました。(#2959)
- **クロスチャネルのタスクコマンド**`/tasks` 管理コマンドを追加し、複数チャネルに対応しました。(#2965)
Thanks @AaronZ345
ドキュメント:[定期タスク](https://docs.cowagent.ai/tools/scheduler)
## 💬 Feishu チャネルの機能向上
Feishu チャネルにメッセージ表示と操作性の強化を一連で追加し、使い勝手を向上させました。
- **ストリーミングカードの改善**:ストリーミングカードに折りたたみパネルを追加し、思考過程、ツール呼び出し、実行時間などを表示します。(#2963)
- **Markdown 形式**非ストリーミング応答と定期配信について、Markdown 構文を含む場合はカードとしてレンダリングし、より見やすく表示します。(#2962)
- **定期タスクカード**`/tasks` コマンドをカード形式で表示し、カード上でタスクの有効化・無効化ができます。(#2961)
- **メッセージ引用への対応**:ユーザーがメッセージを引用した際、引用元の内容を自動的にコンテキストへ追加して Agent に送信します。(#2966)
- **リモート画像のレンダリング**Feishu カード内でリモート画像リンクをレンダリングできるようになりました。(#2967)
- **メッセージ取り消しでタスクをキャンセル**Feishu メッセージを取り消すと、対応する実行中またはキュー中のタスクを自動的にキャンセルします。(#2978)
Thanks @AaronZ345
ドキュメント:[Feishu](https://docs.cowagent.ai/channels/feishu)
## 💾 データのバックアップと復元
`cow backup` と `cow restore` コマンドを追加しました。設定、ナレッジベース、メモリなどのローカルデータをワンコマンドでエクスポート・復元でき、移行やバックアップが容易になります。Thanks @AaronZ345 (#2957)
ドキュメント:[データバックアップ](https://docs.cowagent.ai/cli/backup)
## 🤖 新モデル
- **kimi-k3** に対応
- **gpt-5.6-luna**、**gpt-5.6-terra**、**gpt-5.6-sol** に対応
ドキュメント:[モデル一覧](https://docs.cowagent.ai/models)
## 🛠 改善と修正
- **アクティブタスクのステアリング**`/steer` コマンドを追加し、タスクの実行中に新しい指示を挿入して、実行中のタスクをその場で誘導・軌道修正できます。Thanks @AaronZ345 (#2977)
- **ファイル編集**ファイル編集時にあいまい一致が誤った位置を特定することがある問題を修正しました。Thanks @weijun-xia (#2945)
## 📦 アップグレード方法
- **ソースデプロイ**`cow update` でワンクリックアップグレード、または最新コードを取得して再起動してください。詳しくは [アップグレードガイド](https://docs.cowagent.ai/guide/upgrade) を参照してください。
- **デスクトップクライアント**:クライアント内で更新を確認しワンクリックで更新するか、[ダウンロードページ](https://cowagent.ai/download/) から最新版を入手してください。
**リリース日**2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)

View File

@@ -5,6 +5,7 @@ description: CowAgent version history
| Version | Date | Description |
| --- | --- | --- |
| [2.1.4](/releases/v2.1.4) | 2026.07.20 | Desktop client improvements (browser tool, Windows signing, Win7/8 support), MCP OAuth, scheduled tasks and Feishu channel enhancements, data backup & restore, new models (kimi-k3, gpt-5.6) |
| [2.1.3](/releases/v2.1.3) | 2026.07.08 | Desktop client released (macOS / Windows), knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models (claude-sonnet-5, doubao-seed-2.1, etc.), security hardening and refinements |
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | Web Console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements |
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |

78
docs/releases/v2.1.4.mdx Normal file
View File

@@ -0,0 +1,78 @@
---
title: v2.1.4
description: "CowAgent 2.1.4: Desktop experience improvements, MCP OAuth authorization, Feishu channel enhancements, plus scheduler, data backup, and new models"
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
## 🖥 Desktop Client
Following the desktop client launched in the previous release, this version further improves browser capabilities, system compatibility, and overall experience:
- **Browser tool support**: the client bundles browser capabilities and prefers reusing the system's installed Chrome / Edge, with optimized browser startup and access performance.
- **UI polish**: refined visuals and interactions for the chat view, message bubbles, tool-call steps, and channel pages.
- **Windows code signing**: Windows installers are now code-signed, reducing security warnings during installation.
- **Windows 7/8 support**: added client support for legacy systems such as Windows 7/8, covering more environments.
- **Knowledge base link fix**: fixed in-document links in the desktop knowledge base that failed to navigate.
- **Password login**: the desktop client supports setting a login password, and fixes an issue where the window failed to load after a password was set.
Download: [CowAgent Desktop](https://cowagent.ai/download/)
Docs: [Desktop Client](https://docs.cowagent.ai/guide/desktop)
## 🔌 MCP Remote Server OAuth Authorization
Remote MCP servers now support **OAuth authorization**. When connecting to third-party MCP servers that require login, authentication can be completed via the standard OAuth flow — no more manually configuring and maintaining tokens.
Docs: [MCP Tools](https://docs.cowagent.ai/tools/mcp)
## ⏰ Scheduled Tasks
- **Silent mode**: create silently-running scheduled tasks that execute in the background without pushing messages — ideal for undisturbed scenarios like data organization or periodic archiving. (#2954)
- **Manual run**: manually trigger an existing task to run immediately from the console, without waiting for the next schedule. (#2958)
- **Preserve config on edit**: fixed an issue where editing a task in the Web console could drop hidden fields such as the mode type. (#2959)
- **Cross-channel task command**: added the `/tasks` management command, compatible across channels. (#2965)
Thanks @AaronZ345
Docs: [Scheduled Tasks](https://docs.cowagent.ai/tools/scheduler)
## 💬 Feishu Channel Improvements
The Feishu channel gains a series of message-display and interaction enhancements for a better experience.
- **Streaming card polish**: streaming cards add collapsible panels showing the thinking process, tool calls, and execution time. (#2963)
- **Markdown formatting**: for non-streaming replies and scheduled pushes, messages containing Markdown are rendered as cards for clearer display. (#2962)
- **Scheduler cards**: the `/tasks` command is presented as cards, with the ability to enable or disable tasks right from the card. (#2961)
- **Quoted message context**: when a user quotes a message, the quoted content is automatically added to the context sent to the Agent. (#2966)
- **Remote image rendering**: remote image links can now be rendered inside Feishu cards. (#2967)
- **Cancel on message recall**: recalling a Feishu message automatically cancels its corresponding running or queued task. (#2978)
Thanks @AaronZ345
Docs: [Feishu](https://docs.cowagent.ai/channels/feishu)
## 💾 Data Backup & Restore
Added the `cow backup` and `cow restore` commands to export and restore local data — configuration, knowledge base, memory, and more — with one command, making migration and backup easy. Thanks @AaronZ345 (#2957)
Docs: [Data Backup](https://docs.cowagent.ai/cli/backup)
## 🤖 New Models
- Added support for **kimi-k3**
- Added support for **gpt-5.6-luna**, **gpt-5.6-terra**, and **gpt-5.6-sol**
Docs: [Models](https://docs.cowagent.ai/models)
## 🛠 Improvements & Fixes
- **Active-task steering**: added the `/steer` command to inject new instructions during task execution, guiding or redirecting the running task on the fly. Thanks @AaronZ345 (#2977)
- **File editing**: fixed an issue where fuzzy matching could locate the wrong position when editing files. Thanks @weijun-xia (#2945)
## 📦 How to Upgrade
- **Source deployment**: run `cow update` for a one-click upgrade, or pull the latest code and restart. See the [upgrade guide](https://docs.cowagent.ai/guide/upgrade).
- **Desktop client**: check for updates and update with one click inside the client, or get the latest version from the [download page](https://cowagent.ai/download/).
**Release date**: 2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)

View File

@@ -203,6 +203,8 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
## 🏷 更新日誌
> **2026.07.20** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — 桌面客戶端體驗最佳化、MCP 支援 OAuth 授權、飛書通道能力增強、定時任務與資料備份、新模型接入
> **2026.07.08** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客戶端](https://cowagent.ai/zh/download/)正式發布macOS / Windows、知識庫文件管理增強、MCP 工具智能檢索、繁體中文支援、新模型接入
> **2026.06.18** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升級定時任務管理、知識庫分類、多模型自定義廠商、自主進化最佳化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和體驗最佳化

View File

@@ -203,6 +203,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
## 🏷 更新日志
> **2026.07.20** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — 桌面客户端体验优化、MCP 支持 OAuth 授权、飞书通道能力增强、定时任务优化与数据备份、新模型接入
> **2026.07.08** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客户端](https://cowagent.ai/zh/download/)正式发布macOS / Windows、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入
> **2026.06.18** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级定时任务管理、知识库分类、多模型自定义厂商、自主进化优化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和体验优化

View File

@@ -86,7 +86,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
3. (可选)在 **回调** 中添加 **卡片回传交互**`card.action.trigger`),以启用 `/tasks` 操作按钮。仅使用 `/tasks` 定时任务管理时需要,否则可跳过
3. (可选)在 **回调** 中添加 **卡片回传交互**`card.action.trigger`),以启用 `/tasks` 定时任务管理命令;添加 **撤回消息**`im.message.recalled_v1`)事件,以支持撤回消息取消任务功能
4. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:

View File

@@ -95,6 +95,7 @@ description: 将 CowAgent 接入 Telegram Bot
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off` |
| `/config` | 查看当前配置 |
| `/cancel` | 中止当前正在运行的 Agent 任务 |
| `/steer` | 引导当前正在运行的 Agent 任务(`/steer <指令>` |
| `/logs` | 查看最近日志 |
| `/version` | 查看版本 |

View File

@@ -47,6 +47,16 @@ Session: 12 messages | 8 skills loaded
/cancel
```
## steer
在不中止任务的情况下,引导当前会话里正在运行的 Agent。指令会在下一个安全检查点注入已经开始的工具可以执行完尚未开始的工具会跳过。若当前没有运行中的任务`/steer` 不会新建任务,也不会进入队列。所有聊天渠道均可使用。
```text
/steer 先处理失败的测试
```
Web 控制台在回复进行中会显示“引导当前任务”按钮。普通消息仍按原有方式进入会话队列。
## config
查看或修改运行时配置。修改后立即生效,无需重启服务。

View File

@@ -62,6 +62,7 @@ Others:
| `/help` | 显示命令帮助 |
| `/status` | 查看服务状态和配置 |
| `/cancel` | 中止当前正在运行的 Agent 任务 |
| `/steer <指令>` | 引导当前正在运行的 Agent 任务,不新建排队回合 |
| `/config` | 查看或修改运行时配置 |
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30 |

View File

@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
| [2.1.4](/zh/releases/v2.1.4) | 2026.07.20 | 桌面客户端优化浏览器工具、Windows 签名、Win7/8 支持、MCP OAuth 授权、定时任务与飞书通道增强、数据备份恢复、新模型接入kimi-k3、gpt-5.6 |
| [2.1.3](/zh/releases/v2.1.3) | 2026.07.08 | 桌面客户端正式发布macOS / Windows、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入claude-sonnet-5、doubao-seed-2.1 等)、安全加固与体验优化 |
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级定时任务管理、知识库分类、多模型自定义厂商、自主进化优化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |

View File

@@ -1,6 +1,6 @@
---
title: v2.1.4
description: CowAgent 2.1.4桌面客户端体验优化MCP 支持 OAuth 授权,定时任务工具优化,飞书通道能力增强,新增数据备份与多个模型
description: CowAgent 2.1.4桌面客户端体验优化MCP 支持 OAuth 授权,飞书通道能力增强,定时任务与数据备份等多项更新
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
@@ -46,6 +46,7 @@ Thanks @AaronZ345
- **定时任务卡片**:定时任务 `/tasks` 命令以卡片形式呈现,并支持在卡片中启用和关闭任务。(#2961)
- **支持消息引用**:用户引用消息时,自动将被引用消息加入上下文发送给 Agent。(#2966)
- **远程图片渲染**:支持在飞书卡片中渲染远程图片链接。(#2967)
- **撤回消息取消任务**:撤回某条飞书消息时,自动取消其对应正在执行或排队中的任务。(#2978)
Thanks @AaronZ345
@@ -66,6 +67,7 @@ Thanks @AaronZ345
## 🛠 体验优化与修复
- **任务实时引导**:新增 `/steer` 命令可在任务执行过程中插入新指令来引导或纠偏当前正在执行的任务。Thanks @AaronZ345 (#2977)
- **文件编辑**修复编辑文件时模糊匹配可能定位到错误位置的问题。Thanks @weijun-xia (#2945)
## 📦 升级方式

View File

@@ -0,0 +1,229 @@
import json
import threading
from types import SimpleNamespace
from unittest.mock import Mock
from agent.protocol.agent_stream import AgentStreamExecutor
from agent.protocol.steer import (
SteerInbox,
SteerRegistry,
SteerResult,
SteerStatus,
)
from bridge.context import Context, ContextType
from channel.chat_channel import ChatChannel
class _ScriptedExecutor(AgentStreamExecutor):
def __init__(self, responses, inbox, steer_after_tool=None):
super().__init__(
agent=SimpleNamespace(),
model=SimpleNamespace(model="test-model"),
system_prompt="",
tools=[],
max_turns=8,
messages=[],
steer_inbox=inbox,
)
self.responses = list(responses)
self.executed = []
self.steer_after_tool = steer_after_tool
def _is_thinking_enabled(self):
return False
def _trim_messages(self):
return None
def _validate_and_fix_messages(self):
return None
def _call_llm_stream(self, retry_on_empty=True):
text, tool_calls, callback = self.responses.pop(0)
content = []
if text:
content.append({"type": "text", "text": text})
content.extend({
"type": "tool_use",
"id": call["id"],
"name": call["name"],
"input": call.get("arguments", {}),
} for call in tool_calls)
self.messages.append({"role": "assistant", "content": content})
if callback:
callback()
return text, tool_calls
def _execute_tool(self, tool_call):
self.executed.append(tool_call["name"])
if self.steer_after_tool == tool_call["name"]:
self.steer_inbox.submit("use the new target")
return {
"status": "success",
"result": f"finished {tool_call['name']}",
"execution_time": 0.01,
}
def _tool(name):
return {"id": f"call-{name}", "name": name, "arguments": {}}
def _blocks(messages, block_type):
return [
block
for message in messages
for block in (message.get("content") or [])
if isinstance(block, dict) and block.get("type") == block_type
]
def test_registry_accepts_only_one_active_run_and_preserves_order():
registry = SteerRegistry()
inbox = registry.register("research::session")
assert registry.submit("other::session", "ignored").status == SteerStatus.INACTIVE
assert registry.submit("research::session", "first").accepted
assert registry.submit("research::session", "second").accepted
assert inbox.drain() == ["first", "second"]
second = registry.register("research::session")
assert registry.submit("research::session", "ambiguous").status == SteerStatus.AMBIGUOUS
registry.unregister("research::session", second)
registry.unregister("research::session", inbox)
assert registry.submit("research::session", "late").status == SteerStatus.INACTIVE
def test_inbox_bounds_and_atomic_close_gate():
inbox = SteerInbox(max_pending=1, max_chars=5)
assert inbox.submit("").status == SteerStatus.INVALID
assert inbox.submit("123456").status == SteerStatus.INVALID
assert inbox.submit("first").accepted
assert inbox.submit("again").status == SteerStatus.FULL
assert not inbox.close_if_empty()
assert inbox.drain() == ["first"]
assert inbox.close_if_empty()
assert inbox.submit("late").status == SteerStatus.CLOSING
def test_steer_arriving_during_model_skips_all_proposed_tools():
inbox = SteerInbox()
executor = _ScriptedExecutor([
("old plan", [_tool("one"), _tool("two")], lambda: inbox.submit("change course")),
("new answer", [], None),
], inbox)
assert executor.run_stream("start") == "new answer"
assert executor.executed == []
results = _blocks(executor.messages, "tool_result")
assert {block["tool_use_id"] for block in results} == {"call-one", "call-two"}
assert all(block.get("is_error") for block in results)
assert "change course" in "\n".join(
block["text"] for block in _blocks(executor.messages, "text")
)
def test_steer_between_tools_keeps_completed_result_and_skips_remaining_tool():
inbox = SteerInbox()
executor = _ScriptedExecutor([
("", [_tool("one"), _tool("two")], None),
("redirected answer", [], None),
], inbox, steer_after_tool="one")
assert executor.run_stream("start") == "redirected answer"
assert executor.executed == ["one"]
results = {block["tool_use_id"]: block for block in _blocks(executor.messages, "tool_result")}
assert not results["call-one"].get("is_error", False)
assert results["call-two"]["is_error"] is True
def _fake_agent_bridge(result):
bridge = SimpleNamespace(
steer_session=Mock(return_value=result),
)
return bridge, SimpleNamespace(get_agent_bridge=lambda: bridge)
def test_chat_steer_command_bypasses_the_normal_queue(monkeypatch):
bridge, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
channel = object.__new__(ChatChannel)
channel.sessions = {}
channel.lock = threading.Lock()
channel._send_reply = Mock()
context = Context(ContextType.TEXT, "/steer focus on tests", {
"session_id": "session",
})
ChatChannel.produce(channel, context)
assert channel.sessions == {}
bridge.steer_session.assert_called_once_with("session", "focus on tests")
reply_text = channel._send_reply.call_args.args[1].content
assert "redirect" in reply_text.lower() or "已引导" in reply_text
def test_ordinary_chat_message_keeps_using_the_session_queue(monkeypatch):
_, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
monkeypatch.setattr("channel.chat_channel.conf", lambda: {
"concurrency_in_session": 1,
})
channel = object.__new__(ChatChannel)
channel.sessions = {}
channel.lock = threading.Lock()
context = Context(ContextType.TEXT, "ordinary message", {
"session_id": "session",
})
ChatChannel.produce(channel, context)
assert list(channel.sessions) == ["session"]
assert channel.sessions["session"][0].get() is context
def test_web_steer_button_payload_is_handled_inline(monkeypatch):
from channel.web import web_channel
bridge, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
monkeypatch.setattr(web_channel.web, "data", lambda: json.dumps({
"session_id": "session",
"message": "focus on tests",
"steer": True,
"lang": "en",
}).encode())
raw_class = web_channel.WebChannel.__closure__[0].cell_contents
instance = object.__new__(raw_class)
response = json.loads(raw_class.post_message(instance))
assert response == {
"status": "success",
"request_id": "",
"stream": False,
"steered": True,
"inline_reply": "↪️ Active task redirected.",
}
bridge.steer_session.assert_called_once_with("session", "focus on tests")
def test_web_steer_does_not_start_a_run_when_session_is_idle(monkeypatch):
from channel.web import web_channel
_, factory = _fake_agent_bridge(SteerResult(SteerStatus.INACTIVE))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
monkeypatch.setattr(web_channel.web, "data", lambda: json.dumps({
"session_id": "idle",
"message": "/steer change course",
"stream": True,
"lang": "en",
}).encode())
raw_class = web_channel.WebChannel.__closure__[0].cell_contents
instance = object.__new__(raw_class)
response = json.loads(raw_class.post_message(instance))
assert response["stream"] is False
assert response["steered"] is False
assert response["inline_reply"] == "No active task to steer."

View File

@@ -0,0 +1,136 @@
import json
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
from bridge.context import Context, ContextType
from channel.chat_channel import ChatChannel
from channel.feishu import feishu_channel
from channel.feishu.feishu_channel import FeishuController, FeiShuChanel
from common.dequeue import Dequeue
from common.expired_dict import ExpiredDict
def _context(message_id: str) -> Context:
return Context(
ContextType.TEXT,
message_id,
{
"session_id": "session-1",
"msg": SimpleNamespace(msg_id=message_id),
},
)
def _bare_chat_channel(*contexts: Context) -> ChatChannel:
channel = ChatChannel.__new__(ChatChannel)
channel.lock = threading.RLock()
channel.futures = {}
queue = Dequeue()
for context in contexts:
queue.put(context)
channel.sessions = {"session-1": [queue, MagicMock()]}
return channel
def test_cancel_message_removes_only_recalled_queued_context(monkeypatch):
channel = _bare_chat_channel(_context("m1"), _context("m2"), _context("m3"))
registry = MagicMock()
registry.cancel_request.return_value = False
monkeypatch.setattr("agent.protocol.get_cancel_registry", lambda: registry)
queued, active = channel.cancel_message("session-1", "m2")
assert (queued, active) == (1, False)
remaining = channel.sessions["session-1"][0]
assert [remaining.get_nowait().get("msg").msg_id for _ in range(2)] == ["m1", "m3"]
registry.cancel_request.assert_called_once_with("m2")
def test_cancel_message_targets_active_request_without_clearing_queue(monkeypatch):
channel = _bare_chat_channel(_context("later"))
registry = MagicMock()
registry.cancel_request.return_value = True
monkeypatch.setattr("agent.protocol.get_cancel_registry", lambda: registry)
queued, active = channel.cancel_message("session-1", "active")
assert (queued, active) == (0, True)
remaining = channel.sessions["session-1"][0]
assert remaining.get_nowait().get("msg").msg_id == "later"
def test_feishu_message_uses_message_id_for_precise_recall(monkeypatch):
channel = FeiShuChanel()
channel.receivedMsgs = ExpiredDict(60)
channel._message_sessions = ExpiredDict(60)
monkeypatch.setattr(channel, "fetch_access_token", lambda: "tenant-token")
monkeypatch.setattr(channel, "_make_feishu_stream_callback", lambda *_: MagicMock())
produced = []
monkeypatch.setattr(channel, "produce", produced.append)
channel._handle_message_event(
{
"app_id": "cli_bot",
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_recall_me",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": str(int(time.time() * 1000)),
"content": json.dumps({"text": "long task"}),
},
}
)
assert len(produced) == 1
assert produced[0]["request_id"] == "om_recall_me"
assert channel._message_sessions.get("om_recall_me") == "ou_user"
def test_feishu_recall_cancels_only_the_original_message(monkeypatch):
channel = FeiShuChanel()
channel._message_sessions = ExpiredDict(60)
channel._message_sessions["om_recalled"] = "session-1"
cancel_message = MagicMock(return_value=(0, True))
monkeypatch.setattr(channel, "cancel_message", cancel_message)
result = channel._handle_message_recalled_event(
{"message_id": "om_recalled", "chat_id": "oc_chat"}
)
assert result == (0, True)
cancel_message.assert_called_once_with("session-1", "om_recalled")
assert channel._message_sessions.get("om_recalled") is None
def test_feishu_recall_ignores_unknown_message():
channel = FeiShuChanel()
channel._message_sessions = ExpiredDict(60)
assert channel._handle_message_recalled_event({"message_id": "unknown"}) == (0, False)
def test_feishu_webhook_routes_message_recall(monkeypatch):
channel = FeiShuChanel()
channel.feishu_token = "verification-token"
handle_recall = MagicMock(return_value=(1, False))
monkeypatch.setattr(channel, "_handle_message_recalled_event", handle_recall)
event = {"message_id": "om_recalled", "chat_id": "oc_chat"}
request = {
"header": {
"event_type": "im.message.recalled_v1",
"token": "verification-token",
},
"event": event,
}
monkeypatch.setattr(
feishu_channel.web,
"data",
lambda: json.dumps(request).encode("utf-8"),
)
assert json.loads(FeishuController().POST()) == {"success": True}
handle_recall.assert_called_once_with(event)