mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-22 14:48:41 +08:00
Compare commits
12 Commits
feat-theme
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d5ddcd5f | ||
|
|
ca64362405 | ||
|
|
a67cae69ba | ||
|
|
b64733d64a | ||
|
|
76d6186bd3 | ||
|
|
a78fb711ad | ||
|
|
b755d18e63 | ||
|
|
3cdc13d69d | ||
|
|
ea0c90f4ea | ||
|
|
f9e7c07af8 | ||
|
|
c4e5d11da9 | ||
|
|
177f494207 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
127
agent/protocol/steer.py
Normal 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
|
||||
@@ -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}]"
|
||||
|
||||
|
||||
174
agent/tools/browser/chrome_launcher.py
Normal file
174
agent/tools/browser/chrome_launcher.py
Normal 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}")
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. 查看日志中的错误信息
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.1.3
|
||||
2.1.4
|
||||
|
||||
115
desktop/src/main/http-relay.ts
Normal file
115
desktop/src/main/http-relay.ts
Normal 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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,10 +249,15 @@ 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. */}
|
||||
{/* 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')}
|
||||
@@ -265,8 +273,12 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
||||
<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,7 +347,10 @@ 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) */}
|
||||
{/* 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())} />
|
||||
@@ -344,8 +359,9 @@ const FooterMenu: React.FC<{
|
||||
label={t('menu_feedback')}
|
||||
onClick={() => onOpenLink(FEEDBACK_URL)}
|
||||
/>
|
||||
|
||||
<div className="my-1 border-t border-subtle" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* App actions below: update, theme, language, logs */}
|
||||
<MenuItem
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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,11 +237,21 @@ 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">
|
||||
{!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} />
|
||||
{CustomModelPicker ? (
|
||||
<CustomModelPicker value={model} onChange={setModel} />
|
||||
) : (
|
||||
<>
|
||||
<Dropdown
|
||||
value={showCustom ? '__custom__' : model}
|
||||
options={modelOptions}
|
||||
onChange={handleModelChange}
|
||||
/>
|
||||
{showCustom && (
|
||||
<TextInput
|
||||
className="mt-2 font-mono"
|
||||
@@ -213,8 +260,37 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
6
desktop/src/renderer/src/product/default/index.ts
Normal file
6
desktop/src/renderer/src/product/default/index.ts
Normal 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 = {}
|
||||
77
desktop/src/renderer/src/product/types.ts
Normal file
77
desktop/src/renderer/src/product/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)、セキュリティ強化と改善。
|
||||
|
||||
@@ -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 クライアントで承認:
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ description: Telegram Bot API 経由で CowAgent を接続
|
||||
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) |
|
||||
| `/config` | 現在の設定を表示 |
|
||||
| `/cancel` | 実行中の Agent タスクを中断 |
|
||||
| `/steer` | 実行中の Agent タスクを方向修正(`/steer <指示>`) |
|
||||
| `/logs` | 最近のログを表示 |
|
||||
| `/version` | バージョンを表示 |
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
|
||||
/cancel
|
||||
```
|
||||
|
||||
## steer
|
||||
|
||||
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
|
||||
|
||||
```text
|
||||
/steer 失敗しているテストを先に確認して
|
||||
```
|
||||
|
||||
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
|
||||
|
||||
## config
|
||||
|
||||
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
||||
|
||||
@@ -58,6 +58,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す
|
||||
| `/help` | コマンドヘルプを表示 |
|
||||
| `/status` | サービスの状態と設定を表示 |
|
||||
| `/cancel` | 実行中の Agent タスクを中止 |
|
||||
| `/steer <指示>` | 新しいターンをキューに追加せず、実行中の Agent タスクを方向修正 |
|
||||
| `/config` | 実行時設定の表示・変更 |
|
||||
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
|
||||
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) |
|
||||
|
||||
@@ -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 など)、各種改善 |
|
||||
|
||||
78
docs/ja/releases/v2.1.4.mdx
Normal file
78
docs/ja/releases/v2.1.4.mdx
Normal 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)
|
||||
@@ -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
78
docs/releases/v2.1.4.mdx
Normal 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)
|
||||
@@ -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)、安全加固和體驗最佳化
|
||||
|
||||
@@ -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)、安全加固和体验优化
|
||||
|
||||
@@ -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. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ description: 将 CowAgent 接入 Telegram Bot
|
||||
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) |
|
||||
| `/config` | 查看当前配置 |
|
||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||
| `/steer` | 引导当前正在运行的 Agent 任务(`/steer <指令>`) |
|
||||
| `/logs` | 查看最近日志 |
|
||||
| `/version` | 查看版本 |
|
||||
|
||||
|
||||
@@ -47,6 +47,16 @@ Session: 12 messages | 8 skills loaded
|
||||
/cancel
|
||||
```
|
||||
|
||||
## steer
|
||||
|
||||
在不中止任务的情况下,引导当前会话里正在运行的 Agent。指令会在下一个安全检查点注入:已经开始的工具可以执行完,尚未开始的工具会跳过。若当前没有运行中的任务,`/steer` 不会新建任务,也不会进入队列。所有聊天渠道均可使用。
|
||||
|
||||
```text
|
||||
/steer 先处理失败的测试
|
||||
```
|
||||
|
||||
Web 控制台在回复进行中会显示“引导当前任务”按钮。普通消息仍按原有方式进入会话队列。
|
||||
|
||||
## config
|
||||
|
||||
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
||||
|
||||
@@ -62,6 +62,7 @@ Others:
|
||||
| `/help` | 显示命令帮助 |
|
||||
| `/status` | 查看服务状态和配置 |
|
||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||
| `/steer <指令>` | 引导当前正在运行的 Agent 任务,不新建排队回合 |
|
||||
| `/config` | 查看或修改运行时配置 |
|
||||
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
|
||||
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) |
|
||||
|
||||
@@ -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 等)、其他多项优化和修复 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 📦 升级方式
|
||||
|
||||
229
tests/test_agent_steering.py
Normal file
229
tests/test_agent_steering.py
Normal 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."
|
||||
136
tests/test_feishu_message_recall.py
Normal file
136
tests/test_feishu_message_recall.py
Normal 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)
|
||||
Reference in New Issue
Block a user