Merge remote-tracking branch 'upstream/master'

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
This commit is contained in:
yangziyu-hhh
2026-06-23 17:18:24 +08:00
61 changed files with 3162 additions and 183 deletions

View File

@@ -108,9 +108,9 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
@@ -199,6 +199,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
## 🏷 Changelog
> **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.
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support.
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo).

View File

@@ -49,6 +49,16 @@ class ChatService:
agent.model.channel_type = channel_type or ""
agent.model.session_id = session_id or ""
# Build a context so context-aware tools (e.g. scheduler) can resolve the
# receiver/session. This streaming path bypasses agent_bridge.agent_reply,
# so the attach step that normally happens there must be done here too.
context = self._build_context(query, session_id, channel_type)
self._attach_context_aware_tools(agent, context)
# Mark this session as mid-run so the self-evolution idle scan does not
# fire concurrently when a single turn runs longer than idle_minutes.
self._mark_run_active(agent, True)
# State shared between the event callback and this method
state = _StreamState()
@@ -199,6 +209,8 @@ class ChatService:
logger.info("[ChatService] Cleared agent message history after executor recovery")
raise
finally:
# Clear the mid-run flag so idle scans can review this session again.
self._mark_run_active(agent, False)
# Release cancel token to keep the registry bounded.
if session_id:
try:
@@ -268,10 +280,68 @@ class ChatService:
# Execute post-process tools
agent._execute_post_process_tools()
# Record this user turn for the self-evolution idle trigger. This
# streaming path bypasses agent_bridge.agent_reply, so the activity must
# be noted here, otherwise idle scans never see any signal to evolve.
self._note_evolution_turn(agent, context)
logger.info(f"[ChatService] Agent run completed: session={session_id}")
@staticmethod
def _build_context(query: str, session_id: str, channel_type: str):
"""Build a Context for tool resolution on the streaming chat path.
receiver falls back to session_id; the scheduler's delivery keys on
session_id as the receiver.
"""
from bridge.context import Context, ContextType
# Pass an explicit kwargs dict: Context's default kwargs is a shared
# mutable default, so omitting it would leak fields across sessions.
ctx = Context(ContextType.TEXT, query, kwargs={})
ctx["session_id"] = session_id
ctx["receiver"] = session_id
ctx["isgroup"] = False
ctx["channel_type"] = channel_type or ""
return ctx
@staticmethod
def _attach_context_aware_tools(agent, context):
"""Attach the current context to tools that need it (scheduler)."""
try:
if not (context and getattr(agent, "tools", None)):
return
for tool in agent.tools:
if tool.name == "scheduler":
from agent.tools.scheduler.integration import attach_scheduler_to_tool
attach_scheduler_to_tool(tool, context)
break
except Exception as e:
logger.warning(f"[ChatService] Failed to attach context to scheduler: {e}")
@staticmethod
def _mark_run_active(agent, active):
"""Toggle the self-evolution mid-run flag for this session's agent."""
try:
from agent.evolution.trigger import mark_run_active
mark_run_active(agent, active)
except Exception:
pass
@staticmethod
def _note_evolution_turn(agent, context):
"""Record a user turn so the self-evolution idle trigger has signal."""
try:
from agent.evolution.trigger import note_user_turn
ch = (context.get("channel_type") or "") if context else ""
rcv = (context.get("receiver") or "") if context else ""
is_group = bool(context.get("isgroup")) if context else False
# Only single chats get a proactive push target; group push is noisy.
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
except Exception:
pass
@staticmethod
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
try:

View File

@@ -424,6 +424,11 @@ def run_evolution_for_session(
enable_skills=True,
runtime_info=getattr(agent, "runtime_info", None),
)
# Mark this as a restricted review agent so runtime MCP reconciliation
# (ToolManager.sync_mcp_into_agent) will NOT silently re-inject MCP tools
# that _select_tools()/_guard_tools() intentionally withheld. Without this
# flag the review boundary would be re-opened on the first LLM turn.
review_agent._evolution_restricted = True
# Reuse the live model so it follows the user's configured model.
review_agent.model = agent.model
# Inject the evolution task brief AFTER the full system prompt: the agent

View File

@@ -130,17 +130,11 @@ them. When their signal is clear, act; do not be shy here.
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
- Otherwise, after performing the edits, output a short user-facing summary in
the SAME LANGUAGE the user speaks in the conversation. Write it for an ordinary user, in plain
the SAME LANGUAGE the user speaks in the conversation transcript. Write it for an ordinary user, in plain
everyday words — NOT a developer report. No need to expose internal details
(file names/paths, system mechanics, etc.). Tell the user, briefly:
1) that you just did a self-learning pass,
2) what you learned and what you changed in THIS pass ("remembered X" /
"improved the <name> skill" / "finished <task>").
Keep it to 1-3 lines. Generic shape (do not copy domain words):
"I just did a self-learning pass.
- Learned: <what you learned>
- Changed: <remembered it / improved the <name> skill / finished <task>>
Reply 'undo the last learning' if this is wrong."
(file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass,
what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let
the user know they can undo it.
"""
@@ -157,6 +151,11 @@ def build_review_user_message(transcript: str, protected_skills: list = None) ->
"\n\nPROTECTED skills (built-in — never edit these): "
f"{names}\n"
)
try:
from common import i18n
lang_name = "中文" if i18n.is_zh() else "English"
except Exception:
lang_name = "中文"
return (
"Here is the conversation transcript that just went idle. Review it per "
"your instructions. Acting is the exception: the main value is fixing or "
@@ -164,6 +163,7 @@ def build_review_user_message(transcript: str, protected_skills: list = None) ->
"rare last resorts — stay [SILENT] unless there is a clear, durable signal "
"not already covered."
f"{protected_note}\n"
f"The summary should preferably be written in: {lang_name}\n"
"<transcript>\n"
f"{transcript}\n"
"</transcript>"

View File

@@ -73,6 +73,20 @@ def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
pass
def mark_run_active(agent, active: bool) -> None:
"""Flag whether the agent is mid-run, so idle scans skip a busy session.
Without this, a single run that lasts longer than idle_minutes would let
the scanner fire an evolution pass concurrently with the live turn.
"""
try:
agent._evo_run_active = bool(active)
if active:
agent._evo_last_active = time.time()
except Exception:
pass
def start_evolution_trigger(agent_bridge) -> None:
"""Start the idle-scan thread once per process (idempotent)."""
if getattr(agent_bridge, "_evolution_trigger_started", False):
@@ -105,6 +119,10 @@ def _scan_once(agent_bridge, cfg) -> None:
sessions = list(getattr(agent_bridge, "agents", {}).items())
for session_id, agent in sessions:
try:
# Skip sessions whose agent is mid-run: a long turn must not be
# reviewed while it is still producing the answer.
if getattr(agent, "_evo_run_active", False):
continue
last_active = getattr(agent, "_evo_last_active", 0)
turns = int(getattr(agent, "_evo_turns", 0))
# Enough signal = enough turns OR enough context pressure.

View File

@@ -7,10 +7,14 @@ Supports multiple OpenAI-compatible embedding vendors:
- dashscope (Aliyun Tongyi text-embedding-v4)
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
- zhipu (ZhipuAI embedding-3)
- custom (any OpenAI-compatible endpoint)
Vendor keys here intentionally match the project's bot_type constants in
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
Custom providers (bot_type "custom" or "custom:<id>") reuse the same
OpenAI-compatible REST client with user-supplied api_key / api_base.
All providers share a single OpenAI-compatible REST client. Vendor-specific
behaviors (truncation, query instruction prefix) are configured via metadata.
"""
@@ -138,6 +142,22 @@ EMBEDDING_VENDORS = {
"query_instruction": "",
"max_batch_size": 64,
},
# Custom provider — any OpenAI-compatible /embeddings endpoint. The
# user must supply api_key + api_base + model via the web console
# (stored in custom_providers list or legacy custom_api_key / custom_api_base).
# Dimensions defaults to 1024 but can be overridden via config's
# embedding_dimensions. No dim-param support assumption — safest
# default for unknown endpoints.
"custom": {
"default_base_url": "",
"default_model": "",
"default_dimensions": 1024,
"supports_dim_param": False,
"needs_client_truncate": False,
"needs_client_normalize": True,
"query_instruction": "",
"max_batch_size": 64,
},
}
@@ -472,10 +492,19 @@ def create_embedding_provider(
)
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
resolved_model = model or meta["default_model"]
resolved_base = api_base or meta["default_base_url"]
# Custom providers require explicit api_base and model — they cannot
# fall back to OpenAI defaults like built-in vendors do.
if provider == "custom":
if not resolved_base:
raise ValueError("Custom embedding provider requires an api_base URL")
if not resolved_model:
raise ValueError("Custom embedding provider requires a model name")
return OpenAIEmbeddingProvider(
model=model or meta["default_model"],
model=resolved_model,
api_key=api_key,
api_base=api_base or meta["default_base_url"],
api_base=resolved_base,
extra_headers=extra_headers,
dimensions=final_dim,
supports_dim_param=meta["supports_dim_param"],

View File

@@ -24,6 +24,8 @@ class Bash(BaseTool):
_IS_WIN = sys.platform == "win32"
_PROGRESS_MAX_BYTES = 4 * 1024
_PROGRESS_INTERVAL = 0.5
# cmd.exe command line limit is ~8191 chars; rewrite python -c above this.
_WIN_CMD_SAFE_LEN = 7000
name: str = "bash"
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
@@ -111,19 +113,35 @@ SAFETY:
else:
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
# Temp script written for long `python -c` commands (Windows only),
# cleaned up after execution.
temp_script_path = None
# On Windows, convert $VAR references to %VAR% for cmd.exe
if self._IS_WIN:
env["PYTHONIOENCODING"] = "utf-8"
command = self._convert_env_vars_for_windows(command, dotenv_vars)
# cmd.exe has an ~8191 char command line limit. Long
# `python -c "..."` commands silently fail, so spill the inline
# code into a temp .py file and run that instead.
if len(command) > self._WIN_CMD_SAFE_LEN:
command, temp_script_path = self._rewrite_long_python_c(command)
if command and not command.strip().lower().startswith("chcp"):
command = f"chcp 65001 >nul 2>&1 && {command}"
result = self._run_streaming(
command,
timeout,
env,
dotenv_vars,
)
try:
result = self._run_streaming(
command,
timeout,
env,
dotenv_vars,
)
finally:
if temp_script_path:
try:
os.remove(temp_script_path)
except OSError:
pass
logger.debug(f"[Bash] Exit code: {result.returncode}")
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
@@ -391,3 +409,43 @@ SAFETY:
return m.group(0)
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
@staticmethod
def _rewrite_long_python_c(command: str):
"""
Rewrite `python -c "<code>"` into `python <tempfile>` to bypass the
cmd.exe command line length limit on Windows.
Returns (new_command, temp_file_path). On any parse failure the original
command and None are returned, so behavior is unchanged when unmatched.
"""
# Match: <python|python3|py> [flags] -c "<code>" (single or double quoted)
m = re.search(
r'^(?P<prefix>.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)'
r'(?P<quote>["\'])(?P<code>.*)(?P=quote)\s*(?P<suffix>.*)$',
command,
re.DOTALL,
)
if not m:
return command, None
quote = m.group("quote")
code = m.group("code")
# Reverse common shell-level escaping of the quote char inside the code.
code = code.replace("\\" + quote, quote)
try:
fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(code)
except OSError:
return command, None
prefix = m.group("prefix")
# Drop the trailing "-c " from the prefix, keep the interpreter + flags.
interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip()
suffix = m.group("suffix").strip()
new_command = f'{interp} "{path}"'
if suffix:
new_command += f' {suffix}'
return new_command, path

View File

@@ -15,15 +15,24 @@ Launch modes (configured under `tools.browser` in config.json):
- fresh: Set `persistent` to false to fall back to a clean context every run.
"""
import ipaddress
import json
import os
import socket
from typing import Dict, Any, Optional
from urllib.parse import urlparse
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.browser.browser_service import BrowserService
from common.log import logger
# Cloud-metadata endpoints worth blocking even though they are not link-local.
# (169.254.169.254 — AWS/GCP/Azure IMDS — is already covered by is_link_local;
# fd00:ec2::254 is the AWS IPv6 IMDS address.)
_CLOUD_METADATA_IPS = frozenset({ipaddress.ip_address("fd00:ec2::254")})
class BrowserTool(BaseTool):
"""Single tool exposing all browser actions via an 'action' parameter."""
@@ -121,6 +130,61 @@ class BrowserTool(BaseTool):
BrowserTool._shared_service = self._service
return self._service
def _allow_private_targets(self) -> bool:
"""Whether the link-local / cloud-metadata guard is disabled.
Defaults to False (guard active). Loopback and RFC1918/LAN targets are
always reachable so local dev servers work out of the box; this opt-out
only lifts the remaining block on link-local / cloud-metadata targets,
for an operator who deliberately needs them, by setting
``allow_private_targets: true`` under ``tools.browser`` in config.json.
"""
return bool(self.config.get("allow_private_targets", False))
@staticmethod
def _validate_url_safe(url: str) -> None:
"""Reject URLs that target link-local / cloud-metadata addresses (SSRF guard).
Resolves the hostname to its IP address(es) and blocks any that are
link-local (169.254.0.0/16 — which includes the 169.254.169.254
cloud-metadata endpoint — and IPv6 fe80::/10) or a known IPv6
cloud-metadata address. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Loopback and RFC1918/LAN targets are intentionally left reachable:
unlike the vision/web_fetch tools, the browser legitimately opens local
pages (a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP), so a
blanket "block all internal" policy would break that core workflow.
Raises:
ValueError: if the URL targets a disallowed address.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
# Resolve all addresses for the hostname.
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, _, _, _, sockaddr in addr_infos:
ip_str = sockaddr[0]
ip = ipaddress.ip_address(ip_str)
# Block only the high-risk targets — link-local (incl. the
# 169.254.169.254 cloud-metadata endpoint) and the IPv6 metadata
# address. Loopback and RFC1918/LAN stay reachable for local dev.
if ip.is_link_local or ip in _CLOUD_METADATA_IPS:
raise ValueError(
f"URL resolves to a link-local / cloud-metadata address "
f"({ip_str}), request blocked for security"
)
def execute(self, args: Dict[str, Any]) -> ToolResult:
action = args.get("action", "").strip().lower()
if not action:
@@ -148,6 +212,16 @@ class BrowserTool(BaseTool):
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
if "://" not in url and not url.startswith(("about:", "data:")):
url = "https://" + url
# SSRF guard: for http(s) targets, reject hosts that resolve to
# link-local / cloud-metadata addresses before the browser navigates
# (and then auto-snapshots the page back to the model). Loopback and
# RFC1918/LAN are allowed so local dev servers work. Non-HTTP schemes
# (about:/data:/file:/chrome:) are not network-egress targets here.
if url.split(":", 1)[0].lower() in ("http", "https") and not self._allow_private_targets():
try:
self._validate_url_safe(url)
except ValueError as e:
return ToolResult.fail(f"Error: {e}")
timeout = args.get("timeout", 30000)
service = self._get_service()
result = service.navigate(url, timeout=timeout)

View File

@@ -4,6 +4,8 @@ Memory get tool
Allows agents to read specific sections from memory files
"""
import os
from agent.tools.base_tool import BaseTool
@@ -87,8 +89,13 @@ class MemoryGetTool(BaseTool):
file_path = (workspace_dir / path).resolve()
workspace_resolved = workspace_dir.resolve()
if not str(file_path).startswith(str(workspace_resolved) + '/') and file_path != workspace_resolved:
# Use os.path.realpath + os.sep for cross-platform path validation.
# str(Path).startswith(str + '/') fails on Windows where Path uses
# backslashes — see MemoryService._resolve_path for the same pattern.
real_file = os.path.realpath(str(file_path))
real_workspace = os.path.realpath(str(workspace_resolved))
if real_file != real_workspace and not real_file.startswith(real_workspace + os.sep):
return ToolResult.fail(f"Error: Access denied: path outside workspace")
if not file_path.exists():

View File

@@ -182,8 +182,15 @@ class TaskStore:
if enabled_only:
task_list = [t for t in task_list if t.get("enabled", True)]
# Sort by next_run_at
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
# Sort by enabled status (enabled first), then by next_run_at
def sort_key(t):
enabled = t.get("enabled", True)
next_run = t.get("next_run_at", "")
# Enabled tasks first (0), disabled tasks second (1)
# Then sort by next_run_at (empty string sorts last)
return (0 if enabled else 1, next_run if next_run else "9999-12-31")
task_list.sort(key=sort_key)
return task_list

View File

@@ -523,6 +523,16 @@ class ToolManager:
if agent is None or not hasattr(agent, "tools"):
return ([], [])
# Never re-inject MCP tools into a restricted Self-Evolution review agent.
# The review agent is created with a deliberately reduced, workspace-guarded
# toolset; silently re-adding configured MCP tools here would bypass that
# policy boundary (see agent/evolution/executor.py). The flag may live on
# the agent itself (Agent) or on the wrapping stream executor's .agent.
if getattr(agent, "_evolution_restricted", False) or getattr(
getattr(agent, "agent", None), "_evolution_restricted", False
):
return ([], [])
from agent.tools.mcp.mcp_tool import McpTool
current = self._mcp_tool_instances
registry_names = set(current.keys())

View File

@@ -20,6 +20,11 @@ from .diff import (
FuzzyMatchResult
)
from .url_safety import (
validate_url_safe,
assert_public_ip
)
__all__ = [
'truncate_head',
'truncate_tail',
@@ -36,5 +41,7 @@ __all__ = [
'normalize_for_fuzzy_match',
'fuzzy_find_text',
'generate_diff_string',
'FuzzyMatchResult'
'FuzzyMatchResult',
'validate_url_safe',
'assert_public_ip'
]

View File

@@ -0,0 +1,66 @@
"""
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
A URL is only considered safe when it uses an http/https scheme, has a
hostname, that hostname resolves, and every resolved address is a public
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved
addresses are rejected, for both IPv4 and IPv6.
"""
import ipaddress
import socket
from urllib.parse import urlparse
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
"""Return True if the address is not safe to connect to (non-public)."""
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
)
def assert_public_ip(ip_str: str) -> None:
"""Raise ValueError if the given literal IP is a non-public address.
Used to re-validate the concrete address a redirect resolved to.
"""
ip = ipaddress.ip_address(ip_str)
if _is_blocked_ip(ip):
raise ValueError(
f"URL resolves to a non-public address ({ip_str}), "
f"request blocked for security"
)
def validate_url_safe(url: str) -> None:
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
Resolves the hostname to its IP address(es) and blocks any that fall
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Raises:
ValueError: if the URL targets a disallowed address.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
# Resolve all addresses for the hostname.
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, _, _, _, sockaddr in addr_infos:
assert_public_ip(sockaddr[0])

View File

@@ -17,18 +17,16 @@ Provider resolution:
"""
import base64
import ipaddress
import os
import socket
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
import requests
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.utils.url_safety import validate_url_safe
from common import const
from common.log import logger
from config import conf
@@ -333,6 +331,12 @@ class Vision(BaseTool):
- None : unknown provider id, or the bot can't be created.
Caller falls through to model-name-based routing.
"""
# Custom OpenAI-compatible providers — read credentials from
# custom_providers list, same pattern as embedding.
if provider_id.startswith("custom:"):
p = self._build_custom_provider(provider_id, user_model)
return [p] if p else None
display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
if not display_name:
return None
@@ -598,6 +602,34 @@ class Vision(BaseTool):
model_override=preferred_model,
)
def _build_custom_provider(self, provider_id: str, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
"""Build a VisionProvider from a custom:<id> entry in custom_providers.
Uses the standard OpenAI /chat/completions endpoint — any
OpenAI-compatible multimodal endpoint works."""
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_id)
if not custom_id:
return None
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if not entry:
logger.warning(f"[Vision] custom provider '{provider_id}' not found in custom_providers")
return None
api_key = (entry.get("api_key") or "").strip()
api_base = (entry.get("api_base") or "").strip()
if not api_key or not api_base:
logger.warning(f"[Vision] custom provider '{provider_id}' missing api_key or api_base")
return None
model = preferred_model or entry.get("model") or ""
if not model:
logger.warning(f"[Vision] custom provider '{provider_id}' has no model configured")
return None
return VisionProvider(
name=entry.get("name") or provider_id,
api_key=api_key,
api_base=self._ensure_v1(api_base.rstrip("/")),
model_override=model,
)
def _call_via_bot(self, model: str, question: str, image_content: dict,
provider: Optional[VisionProvider] = None) -> ToolResult:
"""
@@ -665,31 +697,13 @@ class Vision(BaseTool):
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Delegates to the shared ``agent.tools.utils.url_safety`` helper so the
same guard protects every tool that fetches model-supplied URLs.
Raises:
ValueError: if the URL targets a disallowed address.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
# Resolve all addresses for the hostname.
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, _, _, _, sockaddr in addr_infos:
ip_str = sockaddr[0]
ip = ipaddress.ip_address(ip_str)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError(
f"URL resolves to a non-public address ({ip_str}), "
f"request blocked for security"
)
validate_url_safe(url)
def _build_image_content(self, image: str) -> dict:
"""

View File

@@ -16,11 +16,15 @@ import requests
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.utils.truncate import truncate_head, format_size
from agent.tools.utils.url_safety import validate_url_safe
from common.log import logger
DEFAULT_TIMEOUT = 30
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
# Cap on how many redirects we follow; each hop's target is re-validated
# against the SSRF guard so a public URL cannot bounce us into an internal one.
MAX_REDIRECTS = 10
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
@@ -107,23 +111,65 @@ class WebFetch(BaseTool):
if parsed.scheme not in ("http", "https"):
return ToolResult.fail("Error: Invalid URL (must start with http:// or https://)")
# SSRF guard: reject URLs that resolve to private/loopback/link-local/
# cloud-metadata addresses before any request is issued.
try:
validate_url_safe(url)
except ValueError as e:
return ToolResult.fail(f"Error: {e}")
if _is_document_url(url):
return self._fetch_document(url)
return self._fetch_webpage(url)
# ---- Safe request helper ----
@staticmethod
def _safe_get(url: str, **kwargs) -> requests.Response:
"""Issue a GET request while re-validating every redirect hop (SSRF guard).
Auto-redirect is disabled and each hop is followed manually so the
target of every redirect is re-resolved and checked against the SSRF
guard. This prevents a public URL from 3xx-bouncing into a private,
loopback, link-local or cloud-metadata address. ``kwargs`` are passed
through to ``requests.get`` (e.g. ``stream``).
Raises:
ValueError: if any hop resolves to a non-public address.
"""
kwargs.pop("allow_redirects", None)
current = url
for _ in range(MAX_REDIRECTS + 1):
response = requests.get(
current,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
allow_redirects=False,
**kwargs,
)
if not response.is_redirect and not response.is_permanent_redirect:
return response
location = response.headers.get("Location")
if not location:
return response
# Resolve the redirect target relative to the current URL, then
# re-validate it before following.
current = requests.compat.urljoin(current, location)
validate_url_safe(current)
response.close()
raise ValueError(f"Too many redirects (>{MAX_REDIRECTS})")
# ---- Web page fetching ----
def _fetch_webpage(self, url: str) -> ToolResult:
"""Fetch and extract readable text from an HTML web page."""
parsed = urlparse(url)
try:
response = requests.get(
url,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
allow_redirects=True,
)
response = self._safe_get(url)
response.raise_for_status()
except requests.Timeout:
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
@@ -131,6 +177,8 @@ class WebFetch(BaseTool):
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
except requests.HTTPError as e:
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
except ValueError as e:
return ToolResult.fail(f"Error: {e}")
except Exception as e:
return ToolResult.fail(f"Error: Failed to fetch URL: {e}")
@@ -158,13 +206,7 @@ class WebFetch(BaseTool):
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
try:
response = requests.get(
url,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
stream=True,
allow_redirects=True,
)
response = self._safe_get(url, stream=True)
response.raise_for_status()
content_length = int(response.headers.get("Content-Length", 0))
@@ -191,6 +233,9 @@ class WebFetch(BaseTool):
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
except requests.HTTPError as e:
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
except ValueError as e:
self._cleanup_file(local_path)
return ToolResult.fail(f"Error: {e}")
except Exception as e:
self._cleanup_file(local_path)
return ToolResult.fail(f"Error: Failed to download file: {e}")

View File

@@ -524,6 +524,15 @@ class AgentBridge:
session_id, query, context, clear_history
)
# Mark this session as mid-run so the self-evolution idle scan does
# not fire concurrently when a single turn runs longer than
# idle_minutes.
try:
from agent.evolution.trigger import mark_run_active
mark_run_active(agent, True)
except Exception:
pass
try:
# Use agent's run_stream method with event handler
response = agent.run_stream(
@@ -533,6 +542,13 @@ class AgentBridge:
cancel_event=cancel_event,
)
finally:
# Clear the mid-run flag so idle scans can review this session.
try:
from agent.evolution.trigger import mark_run_active
mark_run_active(agent, False)
except Exception:
pass
# Restore original tools
if context and context.get("is_scheduled_task"):
agent.tools = original_tools

View File

@@ -395,7 +395,13 @@ class AgentInitializer:
from agent.memory.embedding import EMBEDDING_VENDORS
from config import conf
meta = EMBEDDING_VENDORS.get(provider_key)
# Custom providers ("custom:<id>") resolve credentials
# from the custom_providers list.
resolved_provider_key = provider_key
if provider_key.startswith("custom:"):
resolved_provider_key = "custom"
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
if meta is None:
logger.error(
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
@@ -414,7 +420,17 @@ class AgentInitializer:
)
return None
model = (conf().get("embedding_model") or "").strip() or meta["default_model"]
model = (conf().get("embedding_model") or "").strip()
# Custom providers without a model fall back to the provider's default.
if not model and resolved_provider_key == "custom":
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if entry and entry.get("model"):
model = entry["model"]
if not model and resolved_provider_key != "custom":
model = meta["default_model"]
try:
cfg_dim = int(conf().get("embedding_dimensions") or 0)
except (TypeError, ValueError):
@@ -423,7 +439,7 @@ class AgentInitializer:
try:
provider = create_embedding_provider(
provider=provider_key,
provider=resolved_provider_key,
model=model,
api_key=api_key,
api_base=api_base,
@@ -450,6 +466,17 @@ class AgentInitializer:
"""Pick the API key for an explicit embedding provider from config."""
from config import conf
# Custom providers ("custom:<id>") resolve from the custom_providers list.
if provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
providers = get_custom_providers()
entry = _find_provider_by_id(providers, custom_id)
if entry:
return entry.get("api_key", "")
return ""
key_map = {
"openai": "open_ai_api_key",
"linkai": "linkai_api_key",
@@ -470,6 +497,17 @@ class AgentInitializer:
"""Pick the API base for an explicit embedding provider from config."""
from config import conf
# Custom providers ("custom:<id>") resolve from the custom_providers list.
if provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
providers = get_custom_providers()
entry = _find_provider_by_id(providers, custom_id)
if entry and entry.get("api_base"):
return entry["api_base"]
return default_base
base_map = {
"openai": "open_ai_api_base",
"linkai": "linkai_api_base",

View File

@@ -1009,6 +1009,14 @@
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
</div>
<div class="flex items-center gap-2">
<button id="task-refresh-btn" onclick="refreshTasksView()"
class="px-3 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5
text-sm font-medium cursor-pointer transition-colors duration-150">
<i class="fas fa-refresh text-xs"></i>
</button>
</div>
</div>
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
@@ -1290,6 +1298,180 @@
</div>
</div>
<!-- Task Edit Modal -->
<div id="task-edit-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-clock text-primary-500"></i>
</div>
<div class="min-w-0 flex-1">
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base" data-i18n="task_edit_title">编辑定时任务</h3>
<p id="task-edit-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
</div>
</div>
<div class="space-y-4">
<!-- 任务名称和启用状态 -->
<div class="flex gap-4 items-end">
<div class="flex-1">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_name">任务名称</span>
</label>
<input id="task-edit-name" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors"
placeholder="任务名称">
</div>
<div class="flex items-center gap-2 pb-[2px]">
<label class="text-xs font-medium text-slate-600 dark:text-slate-400">
<span data-i18n="task_enabled">启用</span>
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="task-edit-enabled" class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
</label>
</div>
</div>
<!-- 调度类型 + 调度值 -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_schedule_type">调度类型</span>
</label>
<select id="task-edit-schedule-type"
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
appearance-none bg-no-repeat bg-right"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
<option value="cron" data-i18n="task_schedule_cron">Cron 表达式</option>
<option value="interval" data-i18n="task_schedule_interval">固定间隔</option>
<option value="once" data-i18n="task_schedule_once">一次性任务</option>
</select>
</div>
<!-- Cron 表达式 -->
<div id="task-edit-cron-wrap">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_cron_expression">Cron 表达式</span>
</label>
<input id="task-edit-cron-expression" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="0 9 * * *">
</div>
<!-- 固定间隔 -->
<div id="task-edit-interval-wrap" class="hidden">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_interval_seconds">间隔秒数</span>
</label>
<input id="task-edit-interval-seconds" type="number" min="60"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors"
placeholder="3600">
</div>
<!-- 一次性任务时间 -->
<div id="task-edit-once-wrap" class="hidden">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_once_time">执行时间</span>
</label>
<input id="task-edit-once-time" type="datetime-local" step="1"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
cursor-pointer"
onclick="this.showPicker && this.showPicker()">
</div>
</div>
<!-- Cron/Interval 提示 -->
<p id="task-edit-cron-hint" class="text-xs text-slate-400 dark:text-slate-500">
<span data-i18n="task_cron_hint">格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00</span>
</p>
<p id="task-edit-interval-hint" class="text-xs text-slate-400 dark:text-slate-500 hidden">
<span data-i18n="task_interval_hint">最小 60 秒,例如 3600 表示每小时执行一次</span>
</p>
<!-- 动作类型 + 通道类型 -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_action_type">动作类型</span>
</label>
<select id="task-edit-action-type"
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
appearance-none bg-no-repeat bg-right"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
<option value="send_message" data-i18n="task_action_send_message">发送消息</option>
<option value="agent_task" data-i18n="task_action_agent_task">AI 任务</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_channel_type">通道类型</span>
</label>
<select id="task-edit-channel-type"
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
appearance-none bg-no-repeat bg-right
disabled:opacity-100 disabled:text-slate-800 dark:disabled:text-slate-300 disabled:bg-slate-100 dark:disabled:bg-[#252525] disabled:cursor-not-allowed"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
</select>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
<span data-i18n="task_channel_hint">选择定时消息发送的通道</span>
</p>
</div>
</div>
<!-- 隐藏的接收者ID字段自动填充 -->
<input id="task-edit-receiver" type="hidden" value="">
<!-- 消息内容/任务描述 -->
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span id="task-edit-content-label" data-i18n="task_message_content">消息内容</span>
</label>
<textarea id="task-edit-content" rows="3"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors resize-none"
placeholder="输入消息内容或任务描述"></textarea>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<button id="task-edit-modal-delete"
class="px-4 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
cursor-pointer transition-colors duration-150 hidden"
data-i18n="task_delete_btn">删除任务</button>
<span id="task-edit-modal-status"
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
<button id="task-edit-modal-cancel"
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 text-sm font-medium
hover:bg-slate-50 dark:hover:bg-white/5
cursor-pointer transition-colors duration-150"
data-i18n="cancel">取消</button>
<button id="task-edit-modal-save"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
data-i18n="save">保存</button>
</div>
</div>
</div>
<script defer src="assets/js/console.js"></script>
</body>
</html>

View File

@@ -244,6 +244,52 @@
}
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
/* Rename button: shares the look of the delete button, sits to its left.
Negative right margin tightens the gap to the delete button only. */
.session-rename {
flex-shrink: 0;
margin-right: -6px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: #9ca3af;
font-size: 11px;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.session-item:hover .session-rename { opacity: 1; }
.session-rename:hover {
color: #4ABE6E;
background: rgba(74, 190, 110, 0.12);
}
.dark .session-rename:hover { background: rgba(74, 190, 110, 0.18); }
/* Inline title editor */
.session-title-input {
flex: 1;
min-width: 0;
font-size: 13px;
font-family: inherit;
color: #111827;
background: #ffffff;
border: 1px solid #4ABE6E;
border-radius: 6px;
padding: 2px 6px;
outline: none;
}
.dark .session-title-input {
color: #e5e5e5;
background: rgba(255, 255, 255, 0.06);
border-color: #4ABE6E;
}
/* Context Divider */
.context-divider {
display: flex;

View File

@@ -191,6 +191,30 @@ const I18N = {
feishu_mode_scan: '扫码创建', feishu_mode_manual: '手动填写',
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
task_add_btn: '新增任务',
task_edit_title: '编辑定时任务',
task_add_title: '新增定时任务',
task_name: '任务名称',
task_enabled: '启用任务',
task_schedule_type: '调度类型',
task_schedule_cron: 'Cron 表达式',
task_schedule_interval: '固定间隔',
task_schedule_once: '一次性任务',
task_cron_expression: 'Cron 表达式',
task_cron_hint: '格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00',
task_interval_seconds: '间隔秒数',
task_interval_hint: '最小 60 秒,例如 3600 表示每小时执行一次',
task_once_time: '执行时间',
task_action_type: '动作类型',
task_action_send_message: '发送消息',
task_action_agent_task: 'AI 任务',
task_channel_type: '通道类型',
task_channel_hint: '选择定时消息发送的通道',
task_message_content: '消息内容',
task_task_description: '任务描述',
task_delete_btn: '删除任务',
task_delete_confirm_title: '删除定时任务',
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
new_chat: '新对话',
@@ -198,6 +222,7 @@ const I18N = {
today: '今天', yesterday: '昨天', earlier: '更早',
delete_session_confirm: '确认删除该会话?所有消息将被清除。',
delete_session_title: '删除会话',
rename_session: '重命名',
delete_message_confirm: '确认删除这条消息?',
delete_message_title: '删除消息',
edit_disabled_reply_active: '正在生成回复,暂时无法编辑。',
@@ -409,6 +434,30 @@ const I18N = {
feishu_mode_scan: 'Scan QR', feishu_mode_manual: 'Manual',
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
task_add_btn: 'Add Task',
task_edit_title: 'Edit Task',
task_add_title: 'Add Task',
task_name: 'Task Name',
task_enabled: 'Enable Task',
task_schedule_type: 'Schedule Type',
task_schedule_cron: 'Cron Expression',
task_schedule_interval: 'Fixed Interval',
task_schedule_once: 'One-time Task',
task_cron_expression: 'Cron Expression',
task_cron_hint: 'Format: minute hour day month weekday, e.g. "0 9 * * *" means daily at 9:00',
task_interval_seconds: 'Interval (seconds)',
task_interval_hint: 'Minimum 60 seconds, e.g. 3600 means once per hour',
task_once_time: 'Execution Time',
task_action_type: 'Action Type',
task_action_send_message: 'Send Message',
task_action_agent_task: 'AI Task',
task_channel_type: 'Channel Type',
task_channel_hint: 'Select the channel to send scheduled messages',
task_message_content: 'Message Content',
task_task_description: 'Task Description',
task_delete_btn: 'Delete Task',
task_delete_confirm_title: 'Delete Task',
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
new_chat: 'New Chat',
@@ -416,6 +465,7 @@ const I18N = {
today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier',
delete_session_confirm: 'Delete this session? All messages will be removed.',
delete_session_title: 'Delete Session',
rename_session: 'Rename',
delete_message_confirm: 'Delete this message?',
delete_message_title: 'Delete Message',
edit_disabled_reply_active: 'Reply is being generated; editing is temporarily unavailable.',
@@ -566,6 +616,11 @@ function rerenderDynamicViews() {
&& modelsState && (modelsState.providers || modelsState.capabilities)) {
renderModelsView();
}
// Reload task list after language switch
if (currentView === 'tasks') {
tasksLoaded = false;
loadTasksView();
}
}
// Floating tooltip portal for [data-tip-key] elements. Tooltip nodes are
@@ -3621,6 +3676,9 @@ function _addOptimisticSessionItem(sid) {
item.innerHTML = `
<i class="fas fa-message session-icon"></i>
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
<button class="session-rename" onclick="event.stopPropagation(); renameSession('${sid}')" title="${escapeHtml(t('rename_session'))}">
<i class="fas fa-pen"></i>
</button>
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${sid}')" title="Delete">
<i class="fas fa-trash-can"></i>
</button>
@@ -3708,6 +3766,9 @@ function _fetchSessionPage(page, clear, onDone) {
item.innerHTML = `
<i class="fas fa-message session-icon"></i>
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
<button class="session-rename" onclick="event.stopPropagation(); renameSession('${s.session_id}')" title="${escapeHtml(t('rename_session'))}">
<i class="fas fa-pen"></i>
</button>
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${s.session_id}')" title="Delete">
<i class="fas fa-trash-can"></i>
</button>
@@ -3826,6 +3887,84 @@ function switchSession(newSessionId) {
if (currentView !== 'chat') navigateTo('chat');
}
// In-place rename a session title: replace the title <span> with an <input>,
// commit on Enter/blur, cancel on Escape. Persists via PUT /api/sessions/<id>.
function renameSession(sid) {
const item = document.querySelector(`.session-item[data-session-id="${sid}"]`);
if (!item) return;
const titleEl = item.querySelector('.session-title');
if (!titleEl || item.querySelector('.session-title-input')) return;
const oldTitle = titleEl.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'session-title-input';
input.value = oldTitle;
input.maxLength = 100;
// Avoid switching session while interacting with the input
const stop = e => e.stopPropagation();
input.addEventListener('click', stop);
input.addEventListener('mousedown', stop);
titleEl.replaceWith(input);
input.focus();
input.select();
let done = false;
const restore = (title) => {
if (done) return;
done = true;
const span = document.createElement('span');
span.className = 'session-title';
span.title = title;
span.textContent = title;
input.replaceWith(span);
};
const commit = () => {
if (done) return;
const newTitle = input.value.trim();
if (!newTitle || newTitle === oldTitle) {
restore(oldTitle);
return;
}
// Optimistically show the new title, then persist.
restore(newTitle);
fetch(`/api/sessions/${encodeURIComponent(sid)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle })
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
// Revert UI on failure
const span = item.querySelector('.session-title');
if (span) {
span.title = oldTitle;
span.textContent = oldTitle;
}
}
})
.catch(() => {
const span = item.querySelector('.session-title');
if (span) {
span.title = oldTitle;
span.textContent = oldTitle;
}
});
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); restore(oldTitle); }
});
input.addEventListener('blur', commit);
}
function deleteSession(sid) {
showConfirmModal(t('delete_session_title'), t('delete_session_confirm'), () => {
// Before deleting, find the next real session to fall back to when the
@@ -4749,7 +4888,7 @@ const MODELS_CAPABILITY_DEFS = [
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
{ id: 'tts', icon: 'fa-volume-high', editable: true, needsModel: true, titleKey: 'models_capability_tts', descKey: 'models_capability_tts_desc',
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: false, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: true, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
iconChip: 'bg-purple-50 dark:bg-purple-900/30', iconGlyph: 'text-purple-500' },
{ id: 'search', icon: 'fa-magnifying-glass', editable: true, needsModel: false, titleKey: 'models_capability_search', descKey: 'models_capability_search_desc',
iconChip: 'bg-orange-50 dark:bg-orange-900/30', iconGlyph: 'text-orange-500' },
@@ -5470,8 +5609,10 @@ function renderCapabilityBody(def, cap, body) {
if (def.needsModel) {
rebuildCapabilityModelDropdown(def, initialProviderValue, cap.current_model || '', body);
// Hide model picker in auto mode — fallback hint below covers it.
setCapabilityModelPickerVisible(def, initialProviderValue !== '' || !capabilitySupportsAuto(def.id), body);
// Embedding: hide model picker when no provider is selected.
const showModel = def.id === 'embedding' ? initialProviderValue !== '' :
(initialProviderValue !== '' || !capabilitySupportsAuto(def.id));
setCapabilityModelPickerVisible(def, showModel, body);
}
if (def.id === 'tts') {
@@ -5766,6 +5907,9 @@ function rebuildCapabilityModelDropdown(def, providerId, selectedModel, scope) {
let rawList;
if (capModelMap[providerId]) {
rawList = capModelMap[providerId].slice();
} else if (providerId.startsWith('custom:') && capModelMap['custom']) {
// Expanded custom:<id> entries share the same preset model list
rawList = capModelMap['custom'].slice();
} else {
const provider = modelsState.providers.find(p => p.id === providerId);
rawList = (provider && provider.models) ? provider.models.slice() : [];
@@ -5896,12 +6040,13 @@ function rebuildCapabilityVoiceDropdown(providerId, selectedVoice, scope, modelI
function onCapabilityProviderChange(def, providerId, scope) {
if (def.needsModel) {
// Empty sentinel hides the model picker (capability is in auto mode).
const isAuto = providerId === '' && capabilitySupportsAuto(def.id);
if (!isAuto) {
// Embedding: hide model picker when no provider is selected.
const showModel = def.id === 'embedding' ? providerId !== '' :
!(providerId === '' && capabilitySupportsAuto(def.id));
if (showModel) {
rebuildCapabilityModelDropdown(def, providerId, '', scope);
}
setCapabilityModelPickerVisible(def, !isAuto, scope);
setCapabilityModelPickerVisible(def, showModel, scope);
}
if (def.id === 'tts') {
rebuildCapabilityVoiceDropdown(providerId, '', scope);
@@ -5936,7 +6081,9 @@ function saveCapability(capId) {
// hidden and any value left in it is stale; persist an empty model so
// the backend treats this as "fall back to the runtime chain".
const isAuto = provider === '' && capabilitySupportsAuto(capId);
const model = isAuto ? '' : getCapabilityModelValue(def);
// Embedding without a provider similarly means "cleared" — don't leak
// a stale model value into config.
const model = (isAuto || (capId === 'embedding' && !provider)) ? '' : getCapabilityModelValue(def);
// TTS carries an extra voice timbre (supports free-text custom ids).
let voice = '';
if (capId === 'tts' && !isAuto) {
@@ -7383,6 +7530,26 @@ function connectFeishuAfterRegister(appId, appSecret) {
// Scheduler View
// =====================================================================
let tasksLoaded = false;
function refreshTasksView() {
const btn = document.getElementById('task-refresh-btn');
const icon = btn.querySelector('i');
// Add spin animation
icon.classList.add('fa-spin');
btn.disabled = true;
tasksLoaded = false;
const listEl = document.getElementById('tasks-list');
listEl.innerHTML = '';
loadTasksView();
// Restore button after animation ends
setTimeout(() => {
icon.classList.remove('fa-spin');
btn.disabled = false;
}, 500);
}
function loadTasksView() {
if (tasksLoaded) return;
fetch('/api/scheduler').then(r => r.json()).then(data => {
@@ -7390,39 +7557,94 @@ function loadTasksView() {
const emptyEl = document.getElementById('tasks-empty');
const listEl = document.getElementById('tasks-list');
const allTasks = data.tasks || [];
// Only show active (enabled) tasks
const tasks = allTasks.filter(t => t.enabled !== false);
if (tasks.length === 0) {
// Backend already sorted by enabled and next_run_at, no need to re-sort on frontend
if (allTasks.length === 0) {
emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无定时任务' : 'No scheduled tasks';
emptyEl.classList.remove('hidden');
listEl.classList.add('hidden');
tasksLoaded = true;
return;
}
emptyEl.classList.add('hidden');
listEl.classList.remove('hidden');
listEl.innerHTML = '';
tasks.forEach(task => {
allTasks.forEach(task => {
const isEnabled = task.enabled !== false;
const card = document.createElement('div');
card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4';
const typeLabel = task.type === 'cron'
? `<span class="text-xs font-mono text-slate-400">${escapeHtml(task.cron || '')}</span>`
: `<span class="text-xs text-slate-400">${escapeHtml(task.type || 'once')}</span>`;
card.dataset.taskId = task.id;
if (!isEnabled) card.classList.add('opacity-50');
const schedule = task.schedule || {};
let typeLabel = '';
if (schedule.type === 'cron') {
typeLabel = `<span class="text-xs font-mono text-slate-400">${escapeHtml(schedule.expression || '')}</span>`;
} else if (schedule.type === 'interval') {
const seconds = schedule.seconds || 0;
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
let intervalText = [];
if (hours > 0) intervalText.push(`${hours}h`);
if (mins > 0) intervalText.push(`${mins}m`);
if (secs > 0 || intervalText.length === 0) intervalText.push(`${secs}s`);
typeLabel = `<span class="text-xs text-slate-400">${intervalText.join(' ')}</span>`;
} else {
typeLabel = `<span class="text-xs text-slate-400">${escapeHtml(schedule.type || 'once')}</span>`;
}
let nextRun = '--';
if (task.next_run_at) {
// next_run_at is an ISO string, not a Unix timestamp
const d = new Date(task.next_run_at);
if (!isNaN(d.getTime())) nextRun = d.toLocaleString();
}
const action = task.action || {};
const taskContent = action.content || action.task_description || '';
const toggleId = 'toggle-' + task.id;
card.innerHTML = `
<div class="flex items-center gap-2 mb-2">
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
<span class="w-2 h-2 rounded-full ${isEnabled ? 'bg-primary-400' : 'bg-slate-300 dark:bg-slate-600'}"></span>
<span class="font-medium text-sm text-slate-700 dark:text-slate-200">${escapeHtml(task.name || task.id || '--')}</span>
<div class="flex-1"></div>
${typeLabel}
</div>
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2 line-clamp-2">${escapeHtml(task.prompt || task.description || '')}</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2 line-clamp-2">${escapeHtml(taskContent)}</p>
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
<div class="flex-1"></div>
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
</label>
</div>`;
const checkbox = card.querySelector('#' + toggleId);
checkbox.addEventListener('change', function() {
const newEnabled = this.checked;
fetch('/api/scheduler/toggle', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({task_id: task.id, enabled: newEnabled})
}).then(r => r.json()).then(res => {
if (res.status === 'success') {
const dot = card.querySelector('.rounded-full.w-2');
if (newEnabled) {
card.classList.remove('opacity-50');
if (dot) { dot.classList.remove('bg-slate-300','dark:bg-slate-600'); dot.classList.add('bg-primary-400'); }
} else {
card.classList.add('opacity-50');
if (dot) { dot.classList.remove('bg-primary-400'); dot.classList.add('bg-slate-300','dark:bg-slate-600'); }
}
} else {
this.checked = !newEnabled;
}
}).catch(() => { this.checked = !newEnabled; });
});
// Card click event (excluding toggle switch clicks)
card.addEventListener('click', function(e) {
if (!e.target.closest('label') && !e.target.closest('input[type="checkbox"]')) {
openTaskEditModal(task);
}
});
card.style.cursor = 'pointer';
listEl.appendChild(card);
});
tasksLoaded = true;
@@ -8527,3 +8749,382 @@ fetch('/auth/check').then(r => r.json()).then(data => {
requestAnimationFrame(() => {
document.body.classList.add('transition-colors', 'duration-200');
});
// =====================================================================
// Task Edit Modal
// =====================================================================
let currentEditingTask = null;
function loadTaskChannelOptions(selectedChannelType) {
const select = document.getElementById('task-edit-channel-type');
select.innerHTML = '';
fetch('/api/channels').then(r => r.json()).then(data => {
if (data.status !== 'success') return;
const allChannels = data.channels || [];
// Only include currently active channels, strictly following the channel management page logic
let channels = allChannels.filter(c => c.active).map(c => {
const label = (typeof c.label === 'object') ? (c.label[currentLang] || c.label.en || c.name) : (c.label || c.name);
return { name: c.name, label: label };
});
const channelNames = channels.map(c => c.name);
// Always include the web console channel
if (!channelNames.includes('web')) {
channels.unshift({ name: 'web', label: currentLang === 'zh' ? 'Web' : 'Web' });
}
// If the currently selected channel is not in the active list (e.g. disabled), append it to preserve selection
if (selectedChannelType && !channelNames.includes(selectedChannelType) && selectedChannelType !== 'web') {
const ch = allChannels.find(c => c.name === selectedChannelType);
const label = ch
? ((typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en || ch.name) : (ch.label || ch.name))
: selectedChannelType;
channels.push({ name: selectedChannelType, label: label });
}
channels.forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
opt.textContent = c.label;
select.appendChild(opt);
});
// Set selected value
if (selectedChannelType) {
select.value = selectedChannelType;
}
}).catch(() => {
// fallback: at least keep the current selection and web
select.innerHTML = '';
const webOpt = document.createElement('option');
webOpt.value = 'web';
webOpt.textContent = 'Web';
select.appendChild(webOpt);
if (selectedChannelType && selectedChannelType !== 'web') {
const opt = document.createElement('option');
opt.value = selectedChannelType;
opt.textContent = selectedChannelType;
select.appendChild(opt);
}
if (selectedChannelType) {
select.value = selectedChannelType;
}
// Show error message
console.error('Failed to load channel options');
});
}
function openTaskEditModal(task) {
currentEditingTask = task;
const overlay = document.getElementById('task-edit-modal-overlay');
const titleEl = document.querySelector('#task-edit-modal-overlay h3');
const subtitle = document.getElementById('task-edit-modal-subtitle');
const deleteBtn = document.getElementById('task-edit-modal-delete');
const nameInput = document.getElementById('task-edit-name');
const enabledInput = document.getElementById('task-edit-enabled');
const scheduleTypeSelect = document.getElementById('task-edit-schedule-type');
const cronInput = document.getElementById('task-edit-cron-expression');
const intervalInput = document.getElementById('task-edit-interval-seconds');
const onceInput = document.getElementById('task-edit-once-time');
const actionTypeSelect = document.getElementById('task-edit-action-type');
const receiverInput = document.getElementById('task-edit-receiver');
const contentInput = document.getElementById('task-edit-content');
// Set title and subtitle
titleEl.textContent = t('task_edit_title');
subtitle.textContent = task.id;
deleteBtn.classList.remove('hidden');
// Populate data
nameInput.value = task.name || '';
enabledInput.checked = task.enabled !== false;
const schedule = task.schedule || {};
scheduleTypeSelect.value = schedule.type || 'cron';
// Clear all schedule type input values first to avoid stale data
cronInput.value = '';
intervalInput.value = '';
onceInput.value = '';
if (schedule.type === 'cron') {
cronInput.value = schedule.expression || '';
} else if (schedule.type === 'interval') {
intervalInput.value = schedule.seconds || '';
} else if (schedule.type === 'once') {
if (schedule.run_at) {
// Manually parse ISO time string to avoid cross-browser timezone issues with new Date()
// run_at format: "YYYY-MM-DDTHH:mm:ss" or "YYYY-MM-DDTHH:mm:ss.ffffff"
const parts = schedule.run_at.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
if (parts) {
const timeInput = document.getElementById('task-edit-once-time');
timeInput.value = `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}:${parts[6]}`;
}
}
}
const action = task.action || {};
actionTypeSelect.value = action.type || 'send_message';
receiverInput.value = action.receiver || '';
contentInput.value = action.content || action.task_description || '';
// Load channel options and set selected value
loadTaskChannelOptions(action.channel_type || 'web');
// Disable channel type selector — channel is read-only when editing.
// Switching the channel after a task is created is problematic because:
// 1. The WeChat (weixin/ilink) bot requires a valid context_token that is tied
// to a specific user-session on that channel. Changing the channel to weixin
// would invalidate the existing token — the new receiver on weixin may not
// have an active context_token, causing the scheduled push to silently fail.
// 2. Other channels (DingTalk, Feishu, etc.) also carry channel-specific fields
// (e.g. dingtalk_sender_staff_id) that cannot be trivially re-populated for
// a different channel type without user intervention.
// 3. The receiver identity itself is channel-bound — a weixin user-id means
// nothing on a Feishu channel, so changing the channel would orphan the task.
// For these reasons, the channel type is intentionally frozen once a task exists.
// Users who need a task on a different channel should create a new task through
// the chat interface (by asking the bot) rather than editing an existing one.
document.getElementById('task-edit-channel-type').disabled = true;
// Update UI
updateTaskScheduleFields();
updateTaskActionLabel();
overlay.classList.remove('hidden');
}
function closeTaskEditModal() {
document.getElementById('task-edit-modal-overlay').classList.add('hidden');
currentEditingTask = null;
}
function updateTaskScheduleFields() {
const scheduleType = document.getElementById('task-edit-schedule-type').value;
const cronWrap = document.getElementById('task-edit-cron-wrap');
const intervalWrap = document.getElementById('task-edit-interval-wrap');
const onceWrap = document.getElementById('task-edit-once-wrap');
const cronHint = document.getElementById('task-edit-cron-hint');
const intervalHint = document.getElementById('task-edit-interval-hint');
cronWrap.classList.toggle('hidden', scheduleType !== 'cron');
intervalWrap.classList.toggle('hidden', scheduleType !== 'interval');
onceWrap.classList.toggle('hidden', scheduleType !== 'once');
if (cronHint) cronHint.classList.toggle('hidden', scheduleType !== 'cron');
if (intervalHint) intervalHint.classList.toggle('hidden', scheduleType !== 'interval');
}
function updateTaskActionLabel() {
const actionType = document.getElementById('task-edit-action-type').value;
const label = document.getElementById('task-edit-content-label');
const content = document.getElementById('task-edit-content');
if (actionType === 'send_message') {
label.textContent = t('task_message_content');
content.placeholder = t('task_message_content');
} else {
label.textContent = t('task_task_description');
content.placeholder = t('task_task_description');
}
}
function saveTaskEdit() {
const nameInput = document.getElementById('task-edit-name');
const enabledInput = document.getElementById('task-edit-enabled');
const scheduleTypeSelect = document.getElementById('task-edit-schedule-type');
const cronInput = document.getElementById('task-edit-cron-expression');
const intervalInput = document.getElementById('task-edit-interval-seconds');
const onceInput = document.getElementById('task-edit-once-time');
const actionTypeSelect = document.getElementById('task-edit-action-type');
const channelTypeSelect = document.getElementById('task-edit-channel-type');
const receiverInput = document.getElementById('task-edit-receiver');
const contentInput = document.getElementById('task-edit-content');
const statusEl = document.getElementById('task-edit-modal-status');
const saveBtn = document.getElementById('task-edit-modal-save');
const name = nameInput.value.trim();
if (!name) {
statusEl.textContent = currentLang === 'zh' ? '请输入任务名称' : 'Please enter task name';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
const scheduleType = scheduleTypeSelect.value;
const schedule = { type: scheduleType };
if (scheduleType === 'cron') {
const expr = cronInput.value.trim();
if (!expr) {
statusEl.textContent = currentLang === 'zh' ? '请输入 Cron 表达式' : 'Please enter cron expression';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
// Basic cron expression format validation: 5 or 6 fields
const fields = expr.split(/\s+/);
if (fields.length < 5 || fields.length > 6) {
statusEl.textContent = currentLang === 'zh' ? 'Cron 表达式格式错误,应为 5 或 6 个字段(分 时 日 月 周)' : 'Invalid cron expression, expected 5 or 6 fields (min hour day month weekday)';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
schedule.expression = expr;
// Note: detailed cron expression validity is verified by the backend croniter library; frontend only does basic format validation
} else if (scheduleType === 'interval') {
const seconds = parseInt(intervalInput.value);
if (!seconds || seconds < 60) {
statusEl.textContent = currentLang === 'zh' ? '间隔秒数最小为 60 秒' : 'Interval must be at least 60 seconds';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
schedule.seconds = seconds;
} else if (scheduleType === 'once') {
const time = onceInput.value;
if (!time) {
statusEl.textContent = currentLang === 'zh' ? '请选择执行时间' : 'Please select execution time';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
// Validate execution time format
const selectedTime = new Date(time);
if (isNaN(selectedTime.getTime())) {
statusEl.textContent = currentLang === 'zh' ? '执行时间格式错误' : 'Invalid execution time format';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
// Validate that time is in the future for one-time tasks
if (selectedTime <= new Date()) {
statusEl.textContent = currentLang === 'zh' ? '执行时间必须在当前时间之后' : 'Execution time must be in the future';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
// datetime-local value with step="1" is already in YYYY-MM-DDTHH:mm:ss format
// Backend _parse_naive_local treats strings without timezone suffix as local time
schedule.run_at = time;
}
const actionType = actionTypeSelect.value;
const channelType = channelTypeSelect.value;
const content = contentInput.value.trim();
if (!content) {
statusEl.textContent = currentLang === 'zh' ? '请输入内容' : 'Please enter content';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
return;
}
// Build action with only necessary fields to avoid stale data
const action = {
type: actionType,
channel_type: channelType,
receiver: '',
receiver_name: '',
is_group: false,
notify_session_id: ''
};
if (actionType === 'send_message') {
action.content = content;
} else {
action.task_description = content;
}
// Preserve the original receiver info (channel is read-only, so it never changes)
if (currentEditingTask && currentEditingTask.action) {
action.receiver = currentEditingTask.action.receiver || '';
action.receiver_name = currentEditingTask.action.receiver_name || '';
action.is_group = currentEditingTask.action.is_group || false;
action.notify_session_id = currentEditingTask.action.notify_session_id || '';
// Preserve channel-specific fields (e.g. DingTalk sender_staff_id)
if (channelType === 'dingtalk' && currentEditingTask.action.dingtalk_sender_staff_id) {
action.dingtalk_sender_staff_id = currentEditingTask.action.dingtalk_sender_staff_id;
}
}
saveBtn.disabled = true;
const payload = {
task_id: currentEditingTask.id,
name: name,
enabled: enabledInput.checked,
schedule: schedule,
action: action
};
fetch('/api/scheduler/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(r => r.json()).then(res => {
saveBtn.disabled = false;
if (res.status === 'success') {
closeTaskEditModal();
tasksLoaded = false;
loadTasksView();
} else {
statusEl.textContent = res.message || (currentLang === 'zh' ? '保存失败' : 'Save failed');
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
}
}).catch(() => {
saveBtn.disabled = false;
statusEl.textContent = currentLang === 'zh' ? '网络错误' : 'Network error';
statusEl.style.opacity = '1';
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
});
}
function deleteTask() {
if (!currentEditingTask) return;
const taskName = currentEditingTask.name || currentEditingTask.id || '未知任务';
const taskId = currentEditingTask.id; // Capture early to avoid closure race condition
showConfirmDialog({
title: t('task_delete_confirm_title'),
message: (currentLang === 'zh' ? `确定要删除任务「${taskName}」吗?` : `Are you sure to delete task "${taskName}"?`),
onConfirm: () => {
fetch('/api/scheduler/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
}).then(r => r.json()).then(res => {
if (res.status === 'success') {
closeTaskEditModal();
tasksLoaded = false;
loadTasksView();
} else {
const statusEl = document.getElementById('task-edit-modal-status');
if (statusEl) {
statusEl.textContent = res.message || 'Delete failed';
statusEl.classList.remove('hidden', 'text-green-500');
statusEl.classList.add('text-red-500');
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
}
}
}).catch(() => {
const statusEl = document.getElementById('task-edit-modal-status');
if (statusEl) {
statusEl.textContent = 'Network error';
statusEl.classList.remove('hidden', 'text-green-500');
statusEl.classList.add('text-red-500');
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
}
});
}
});
}
document.getElementById('task-edit-schedule-type').addEventListener('change', updateTaskScheduleFields);
document.getElementById('task-edit-action-type').addEventListener('change', updateTaskActionLabel);
document.getElementById('task-edit-modal-cancel').addEventListener('click', closeTaskEditModal);
document.getElementById('task-edit-modal-save').addEventListener('click', saveTaskEdit);
document.getElementById('task-edit-modal-delete').addEventListener('click', deleteTask);
document.getElementById('task-edit-modal-overlay').addEventListener('click', function(e) {
if (e.target === this) closeTaskEditModal();
});

View File

@@ -1180,6 +1180,9 @@ class WebChannel(ChatChannel):
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
'/api/scheduler/delete', 'SchedulerDeleteHandler',
'/api/sessions', 'SessionsHandler',
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
@@ -1500,10 +1503,10 @@ class ConfigHandler:
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
]
@@ -1566,7 +1569,7 @@ class ConfigHandler:
"api_base_key": "zhipu_ai_api_base",
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
"api_base_placeholder": _PLACEHOLDER_ZHIPU,
"models": [const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
"models": [const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
}),
("dashscope", {
"label": {"zh": "通义千问", "en": "Qwen"},
@@ -1590,7 +1593,7 @@ class ConfigHandler:
"api_base_key": "moonshot_base_url",
"api_base_default": "https://api.moonshot.cn/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
}),
("qianfan", {
"label": {"zh": "百度千帆", "en": "ERNIE"},
@@ -2083,7 +2086,20 @@ class ModelsHandler:
],
},
}
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai"]
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai", "custom"]
# Embedding model catalog per provider. Mirrors the default_model in
# agent/memory/embedding/provider.py::EMBEDDING_VENDORS.
# Custom providers have no preset list — model names vary per vendor,
# so the user always types the model id manually.
_EMBEDDING_PROVIDER_MODELS = {
"openai": ["text-embedding-3-small", "text-embedding-3-large"],
"dashscope": ["text-embedding-v4"],
"doubao": ["doubao-embedding-vision-251215"],
"zhipu": ["embedding-3"],
"linkai": ["text-embedding-3-small"],
"custom": [],
}
# Capability-scoped model catalogs. The chat dropdown can reuse the
# provider's generic model list, but vision and image generation are
@@ -2133,6 +2149,9 @@ class ModelsHandler:
const.CLAUDE_4_6_SONNET,
const.GEMINI_31_FLASH_LITE_PRE,
],
# Custom OpenAI-compatible providers have no preset list — model
# names vary per vendor, so the user types the model id manually.
"custom": [],
}
# Image-generation catalog. Source of truth: skills/image-generation/SKILL.md.
@@ -2446,18 +2465,31 @@ class ModelsHandler:
user_specified = (vision_conf.get("model") or "").strip()
explicit_provider = (vision_conf.get("provider") or "").strip()
# Build provider list: built-in providers + expanded custom:<id> entries.
# Same pattern as _embedding_capability — each user-created custom
# provider gets its own dropdown entry showing the user-chosen name.
providers = []
custom_cards = cls._custom_provider_cards(local_config)
for pid in cls._VISION_PROVIDER_MODELS:
if pid == "custom":
if custom_cards:
providers.extend(c["id"] for c in custom_cards)
else:
providers.append(pid)
# Provider resolution priority:
# 1. Explicit `tools.vision.provider` (persisted via UI; supports
# custom model names that prefix-inference can't recognize).
# 2. Scan per-provider model lists by model name.
# Empty provider keeps the dropdown on "auto" when we can't tell.
inferred_provider = ""
if explicit_provider and explicit_provider in cls._VISION_PROVIDER_MODELS:
if explicit_provider and explicit_provider in providers:
inferred_provider = explicit_provider
elif user_specified:
for pid, models in cls._VISION_PROVIDER_MODELS.items():
if user_specified in models:
inferred_provider = pid
# For "custom" key, map to the first custom card
inferred_provider = custom_cards[0]["id"] if pid == "custom" and custom_cards else pid
break
# In auto mode the hint should reflect what vision.py will actually
@@ -2473,7 +2505,7 @@ class ModelsHandler:
"current_model": user_specified,
"fallback_provider": predicted["provider"],
"fallback_model": predicted["model"],
"providers": list(cls._VISION_PROVIDER_MODELS.keys()),
"providers": providers,
"provider_models": cls._VISION_PROVIDER_MODELS,
}
@@ -2546,18 +2578,40 @@ class ModelsHandler:
suggested = ""
if not explicit:
for pid in cls._EMBEDDING_PROVIDERS:
if pid == "custom":
continue
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
key_field = meta.get("api_key_field")
if key_field and cls._is_real_key(local_config.get(key_field, "")):
suggested = pid
break
if not suggested:
custom_cards = cls._custom_provider_cards(local_config)
if custom_cards:
suggested = custom_cards[0]["id"]
# Build provider list: built-in providers + expanded custom:<id> entries
# Same pattern as _chat_capability — each user-created custom provider
# gets its own dropdown entry showing the user-chosen name.
providers = []
custom_cards = cls._custom_provider_cards(local_config)
for pid in cls._EMBEDDING_PROVIDERS:
if pid == "custom":
if custom_cards:
providers.extend(c["id"] for c in custom_cards)
# No custom providers configured — skip the bare "custom" entry
# since the runtime cannot resolve its credentials.
else:
providers.append(pid)
return {
"editable": True,
"current_provider": explicit,
"suggested_provider": suggested,
"current_model": local_config.get("embedding_model", "") or "",
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
"providers": cls._EMBEDDING_PROVIDERS,
"providers": providers,
"provider_models": cls._EMBEDDING_PROVIDER_MODELS,
}
# Auto-fallback order for image generation. Mirrors the global priority
@@ -2919,10 +2973,10 @@ class ModelsHandler:
{
"action": "set_custom_provider",
"id": "3f2a9c1b", # required for edit; omit for create
"name": "siliconflow", # required, display label
"name": "my-provider", # required, display label
"api_base": "https://...", # required when creating
"api_key": "sk-...", # optional on edit (keep existing)
"model": "deepseek-ai/...", # optional default model
"model": "model-name", # optional default model
"make_active": true # optional, also activate it
}
"""
@@ -3143,6 +3197,25 @@ class ModelsHandler:
# is persisted so users picking a custom model under a specific vendor
# still get routed there — runtime falls back to model-name prefix
# inference only when provider is empty.
# Validate provider_id — mirrors _set_chat / _set_embedding pattern.
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
if not model:
model = custom_provider.get("model") or ""
elif provider_id and provider_id not in {k for k in ModelsHandler._VISION_PROVIDER_MODELS if k != "custom"}:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
if provider_id and not model:
return json.dumps({
"status": "error",
"message": "vision model is required when a provider is selected",
})
local_config = conf()
file_cfg = self._read_file_config()
self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model)
@@ -3268,7 +3341,20 @@ class ModelsHandler:
logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}")
def _set_embedding(self, provider_id: str, model: str) -> str:
# Two valid states: both empty (reset to pick-or-empty) OR both set.
# Validate provider_id — mirrors _set_chat's validation pattern.
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
# Fall back to the custom provider's default model when none is given.
if not model:
model = custom_provider.get("model") or ""
elif provider_id and provider_id not in {p for p in ModelsHandler._EMBEDDING_PROVIDERS if p != "custom"}:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
# A provider without a model leaves the runtime in a broken half-state,
# so reject that explicitly instead of silently writing it through.
if provider_id and not model:
@@ -4186,6 +4272,141 @@ class SchedulerHandler:
return json.dumps({"status": "error", "message": str(e)})
class SchedulerToggleHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
task_id = body.get("task_id")
enabled = body.get("enabled", True)
if not task_id:
return json.dumps({"status": "error", "message": "task_id required"})
from agent.tools.scheduler.task_store import TaskStore
workspace_root = _get_workspace_root()
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
store = TaskStore(store_path)
store.enable_task(task_id, enabled)
task = store.get_task(task_id)
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Scheduler toggle error: {e}")
return json.dumps({"status": "error", "message": str(e)})
class SchedulerUpdateHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
task_id = body.get("task_id")
if not task_id:
return json.dumps({"status": "error", "message": "task_id required"})
from agent.tools.scheduler.task_store import TaskStore
from agent.tools.scheduler.scheduler_service import SchedulerService
from datetime import datetime
workspace_root = _get_workspace_root()
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
store = TaskStore(store_path)
# Get original task (single query to avoid repeated I/O)
original_task = store.get_task(task_id)
if not original_task:
return json.dumps({"status": "error", "message": f"Task '{task_id}' not found"})
# Build updates dict
updates = {}
if "name" in body:
updates["name"] = body["name"]
if "enabled" in body:
updates["enabled"] = body["enabled"]
# Update schedule
if "schedule" in body:
updates["schedule"] = body["schedule"]
# If schedule config changed, recalculate next_run_at
# Build merged temp task data for calculation (without modifying the original object)
merged = dict(original_task)
merged.update(updates)
if "action" in body:
merged["action"] = body["action"]
temp_service = SchedulerService(store, lambda t: None)
next_run = temp_service._calculate_next_run(merged, datetime.now())
if next_run:
updates["next_run_at"] = next_run.isoformat()
else:
# Cannot calculate next run time, schedule config may be invalid
return json.dumps({
"status": "error",
"message": "Cannot calculate next run time. Please check the schedule config (e.g., cron expression format, or whether the one-time task time has already passed)."
}, ensure_ascii=False)
# Update action
if "action" in body:
action = body["action"]
channel_type = action.get("channel_type", "web")
# Get the task's original channel_type
old_channel = original_task.get("action", {}).get("channel_type", "web")
# If channel type changed or no receiver, reject the update.
# Note: the web UI disables the channel selector, so this branch
# is only reachable via direct API calls. Changing a task's channel
# after creation is not supported because the receiver identity is
# channel-bound and cannot be trivially re-populated (e.g. weixin
# requires a valid context_token tied to the original user-session).
if old_channel and old_channel != channel_type:
return json.dumps({
"status": "error",
"message": f"Cannot change channel type from '{old_channel}' to '{channel_type}'. Please create a new task on the target channel instead."
}, ensure_ascii=False)
if not action.get("receiver"):
return json.dumps({
"status": "error",
"message": "Receiver is required. Please create a new task through the chat interface."
}, ensure_ascii=False)
updates["action"] = action
# If schedule was not updated but action was, ensure next_run_at exists
if "schedule" not in body and "next_run_at" not in original_task:
merged = dict(original_task)
merged.update(updates)
temp_service = SchedulerService(store, lambda t: None)
next_run = temp_service._calculate_next_run(merged, datetime.now())
if next_run:
updates["next_run_at"] = next_run.isoformat()
store.update_task(task_id, updates)
task = store.get_task(task_id)
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Scheduler update error: {e}")
return json.dumps({"status": "error", "message": str(e)})
class SchedulerDeleteHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
task_id = body.get("task_id")
if not task_id:
return json.dumps({"status": "error", "message": "task_id required"})
from agent.tools.scheduler.task_store import TaskStore
workspace_root = _get_workspace_root()
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
store = TaskStore(store_path)
store.delete_task(task_id)
return json.dumps({"status": "success"}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Scheduler delete error: {e}")
return json.dumps({"status": "error", "message": str(e)})
class SessionsHandler:
def GET(self):
_require_auth()
@@ -4362,7 +4583,7 @@ class MessageDeleteHandler:
# 2. Sync agent's in-memory context so its next turn sees the
# same history as the DB. Handled by the agent_bridge helper.
try:
from bridge import Bridge
from bridge.bridge import Bridge
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
except Exception as sync_err:
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")

View File

@@ -12,16 +12,19 @@ import hashlib
import json
import math
import os
import re
import threading
import time
import uuid
import requests
import web
import websocket
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
from common.expired_dict import ExpiredDict
from common.log import logger
@@ -32,6 +35,9 @@ from config import conf
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
HEARTBEAT_INTERVAL = 30
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
# Fixed URL path for the callback (webhook) HTTP server. The bot's
# receive-message URL must point at this path, e.g. http://host:9892/wecombot
CALLBACK_PATH = "/wecombot"
def _escape_control_chars_inside_json_strings(s: str) -> str:
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
self._pending_lock = threading.Lock()
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
# Transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
self.mode = "websocket"
self._crypt = None
self._http_server = None
# stream_id -> {"committed", "current", "finished", "images", "last_access"}
self._callback_streams = ExpiredDict(60 * 10) # auto-expire after 10min (max poll window is 6min)
self._callback_lock = threading.Lock()
conf()["group_name_white_list"] = ["ALL_GROUP"]
conf()["single_chat_prefix"] = [""]
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
# ------------------------------------------------------------------
def startup(self):
self.mode = conf().get("wecom_bot_mode", "websocket")
if self.mode == "webhook":
self._startup_callback()
return
self.bot_id = conf().get("wecom_bot_id", "")
self.bot_secret = conf().get("wecom_bot_secret", "")
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
pass
self._ws = None
self._connected = False
if self._http_server:
try:
self._http_server.stop()
logger.info("[WecomBot] Callback HTTP server stopped")
except Exception as e:
logger.warning(f"[WecomBot] Error stopping HTTP server: {e}")
self._http_server = None
# ------------------------------------------------------------------
# WebSocket connection
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
def _gen_req_id(self) -> str:
return uuid.uuid4().hex[:16]
# ------------------------------------------------------------------
# Callback (webhook) mode
# ------------------------------------------------------------------
def _startup_callback(self):
"""Start an HTTP server that receives encrypted callbacks (webhook mode).
The bot's "接收消息" URL in the WeCom admin console should point at this
server (any path is accepted). Verification (GET) and message delivery
(POST) are both handled by ``WecomBotCallbackController``.
"""
token = conf().get("wecom_bot_token", "")
aes_key = conf().get("wecom_bot_encoding_aes_key", "")
if not token or not aes_key:
err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key"
logger.error(err)
self.report_startup_error(err)
return
try:
# Enterprise-internal smart bot: receive_id is an empty string.
self._crypt = WecomBotCrypt(token, aes_key, "")
except Exception as e:
err = f"[WecomBot] invalid callback credentials: {e}"
logger.error(err)
self.report_startup_error(err)
return
port = int(conf().get("wecom_bot_port", 9892))
logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}, path {CALLBACK_PATH} ...")
# Only serve the fixed callback path; everything else 404s instead of being
# treated as a (signature-failing) WeCom callback.
urls = (re.escape(CALLBACK_PATH), "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController")
app = web.application(urls, globals(), autoreload=False)
func = web.httpserver.StaticMiddleware(app.wsgifunc())
func = web.httpserver.LogMiddleware(func)
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
self._http_server = server
self.report_startup_success()
try:
server.start()
except (KeyboardInterrupt, SystemExit):
server.stop()
def _new_callback_stream(self, response_url: str = "") -> str:
"""Create a new stream state and return its id."""
stream_id = uuid.uuid4().hex[:16]
now = time.time()
with self._callback_lock:
self._callback_streams[stream_id] = {
"committed": "",
"current": "",
"finished": False,
"images": [], # list of (base64_str, md5_str), flushed only at finish
"image_urls": [], # public http(s) image urls (usable in response_url markdown)
"image_pending": False, # an image reply is being prepared; don't finish on text yet
"last_access": now,
"created_at": now,
"response_url": response_url or "",
"delivered": False, # final answer handed to WeCom via a poll
"url_sent": False, # final answer pushed via response_url (active reply)
}
return stream_id
def _callback_handle_message(self, data: dict) -> dict:
"""Handle a freshly-received user message in callback mode.
Produces the context for async processing and returns the initial passive
reply (a stream packet with finish=false) so WeCom starts polling for the
agent's streamed answer. Returns ``None`` when there's nothing to reply
(e.g. an image/file silently cached for the next query).
"""
msg_id = data.get("msgid", "")
if msg_id and self.received_msgs.get(msg_id):
logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
return None
if msg_id:
self.received_msgs[msg_id] = True
chattype = data.get("chattype", "single")
is_group = chattype == "group"
default_aeskey = conf().get("wecom_bot_encoding_aes_key", "")
result = self._build_context(data, is_group, default_aeskey=default_aeskey)
if not result:
return None
context, wecom_msg = result
# response_url lets us actively reply once within 1h, used as a fallback
# when the agent finishes after WeCom stops polling (max ~6min window).
response_url = data.get("response_url", "") or ""
stream_id = self._new_callback_stream(response_url=response_url)
wecom_msg.stream_id = stream_id
context["wecom_stream_id"] = stream_id
context["on_event"] = self._make_callback_stream_callback(stream_id)
self.produce(context)
# First passive reply: register the stream id, WeCom will poll for updates.
return {
"msgtype": "stream",
"stream": {"id": stream_id, "finish": False, "content": ""},
}
def _callback_handle_stream_poll(self, data: dict) -> dict:
"""Handle a "流式消息刷新" poll: return the latest accumulated content."""
stream_id = data.get("stream", {}).get("id", "")
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if state is None:
# Unknown / expired stream: tell WeCom we're done to stop polling.
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
state["last_access"] = time.time()
if state.get("url_sent"):
# Final answer already pushed via response_url; finish silently.
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
# We never force-finish on a timer: while a task is still running the
# bubble should keep spinning until either the task finishes or the
# user cancels. If WeCom's 6min window closes before completion, the
# answer is delivered later via response_url instead.
finished = state["finished"]
content = state["committed"] + state["current"]
images = state["images"] if finished else []
if finished:
state["delivered"] = True
logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}")
stream = {"id": stream_id, "finish": finished, "content": content}
if images:
stream["msg_item"] = [
{"msgtype": "image", "image": {"base64": b64, "md5": md5}}
for (b64, md5) in images
]
return {"msgtype": "stream", "stream": stream}
def _make_callback_stream_callback(self, stream_id: str):
"""Build an on_event callback that accumulates agent output into stream state.
Mirrors the websocket streaming behaviour: intermediate turns (text before
a tool call) are committed with a '---' separator; WeCom reads the full
accumulated content on each poll.
"""
def on_event(event: dict):
event_type = event.get("type")
edata = event.get("data", {})
cancelled = False
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if not state:
return
if event_type == "turn_start":
state["current"] = ""
elif event_type == "message_update":
delta = edata.get("delta", "")
if delta:
state["current"] += delta
elif event_type == "message_end":
tool_calls = edata.get("tool_calls", [])
if tool_calls:
if state["current"].strip():
state["committed"] += state["current"].strip() + "\n\n---\n\n"
state["current"] = ""
else:
state["committed"] += state["current"]
state["current"] = ""
elif event_type == "agent_cancelled":
# Mechanism 1: a cancelled run never reaches send(), so finalize
# its stream here to stop the "···" bubble immediately.
if state["current"]:
state["committed"] += state["current"]
state["current"] = ""
state["committed"] = state["committed"].rstrip()
if state["committed"].endswith("---"):
state["committed"] = state["committed"][:-3].rstrip()
if not state["committed"].strip():
state["committed"] = "🛑 已中止"
state["finished"] = True
state["last_access"] = time.time()
cancelled = True
if cancelled:
# Outside the lock: response_url fallback re-acquires it.
self._schedule_response_url_fallback(stream_id)
return on_event
# ------------------------------------------------------------------
# Subscribe & heartbeat
# ------------------------------------------------------------------
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
chattype = body.get("chattype", "single")
is_group = chattype == "group"
result = self._build_context(body, is_group)
if not result:
return
context, wecom_msg = result
wecom_msg.req_id = req_id
if req_id:
context["on_event"] = self._make_stream_callback(req_id)
self.produce(context)
def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""):
"""Parse a wecom message body into a Context, applying file-cache logic.
Shared by both the websocket (long-connection) and callback (webhook)
receive paths. Returns ``(context, wecom_msg)`` when the message should be
handed to the agent, or ``None`` when it was consumed (cached image/file,
parse failure, etc.).
"""
try:
wecom_msg = WecomBotMessage(body, is_group=is_group)
wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
except NotImplementedError as e:
logger.warning(f"[WecomBot] {e}")
return
return None
except Exception as e:
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
return
wecom_msg.req_id = req_id
return None
# File cache logic (same pattern as feishu)
from channel.file_cache import get_file_cache
@@ -314,13 +541,13 @@ class WecomBotChannel(ChatChannel):
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
logger.info(f"[WecomBot] Image cached for session {session_id}")
return
return None
if wecom_msg.ctype == ContextType.FILE:
wecom_msg.prepare()
file_cache.add(session_id, wecom_msg.content, file_type="file")
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
return
return None
if wecom_msg.ctype == ContextType.TEXT:
cached_files = file_cache.get(session_id)
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
msg=wecom_msg,
no_need_at=True,
)
if context:
if req_id:
context["on_event"] = self._make_stream_callback(req_id)
self.produce(context)
if not context:
return None
return context, wecom_msg
# ------------------------------------------------------------------
# Event callback
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
return context
# ------------------------------------------------------------------
# Callback (webhook) send: write the final reply into the stream state
# so the next "流式消息刷新" poll returns it with finish=true.
# ------------------------------------------------------------------
def _callback_send(self, reply: Reply, context: Context):
msg = context.get("msg")
stream_id = getattr(msg, "stream_id", None) if msg else None
if not stream_id:
stream_id = context.get("wecom_stream_id")
if not stream_id:
logger.warning("[WecomBot] callback send without stream_id, dropping reply")
return
if reply.type == ReplyType.TEXT:
self._callback_finalize_text(stream_id, reply.content)
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
self._callback_finalize_image(stream_id, reply.content)
elif reply.type == ReplyType.FILE:
# Passive callback replies only support text + image (base64); files
# are not supported by the protocol, so append a notice to whatever
# text the agent already streamed (do not drop it).
text = getattr(reply, "text_content", "") or ""
note = (text + "\n\n" if text else "") + "(文件无法在企微回调模式下直接发送)"
self._callback_finalize_text(stream_id, note, append=True)
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
text = getattr(reply, "text_content", "") or ""
note = (text + "\n\n" if text else "") + "(该消息类型无法在企微回调模式下直接发送)"
self._callback_finalize_text(stream_id, note, append=True)
else:
self._callback_finalize_text(stream_id, str(reply.content))
def _callback_get_or_create_state(self, stream_id: str) -> dict:
state = self._callback_streams.get(stream_id)
if state is None:
now = time.time()
state = {
"committed": "",
"current": "",
"finished": False,
"images": [],
"image_urls": [],
"image_pending": False,
"last_access": now,
"created_at": now,
"response_url": "",
"delivered": False,
"url_sent": False,
}
self._callback_streams[stream_id] = state
return state
def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False):
with self._callback_lock:
state = self._callback_get_or_create_state(stream_id)
accumulated = (state["committed"] + state["current"]).strip()
if append and accumulated:
state["committed"] = (accumulated + "\n\n" + (content or "")).strip()
else:
state["committed"] = accumulated if accumulated else (content or "")
state["current"] = ""
state["last_access"] = time.time()
# Don't finish synchronously: chat_channel splits an image-with-caption
# reply into a TEXT send followed (0.3s later) by the IMAGE send. If the
# text finished the stream immediately, WeCom would close it before the
# image arrives. Defer the finish so a trailing image can merge in.
self._schedule_text_finish(stream_id)
def _schedule_text_finish(self, stream_id: str, delay: float = 1.2):
def _run():
time.sleep(delay)
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if not state or state["finished"] or state.get("image_pending"):
return # already finished, or an image reply is on its way
state["finished"] = True
state["last_access"] = time.time()
self._schedule_response_url_fallback(stream_id)
threading.Thread(target=_run, daemon=True, name=f"wecom-textfin-{stream_id}").start()
def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
# Mark the image as pending up front (before the slow load/compress) so a
# preceding text finalize won't close the stream while we work.
with self._callback_lock:
self._callback_get_or_create_state(stream_id)["image_pending"] = True
b64md5 = self._load_image_base64(img_path_or_url)
with self._callback_lock:
state = self._callback_get_or_create_state(stream_id)
accumulated = (state["committed"] + state["current"]).strip()
state["current"] = ""
if b64md5:
state["images"].append(b64md5)
state["committed"] = accumulated
# Remember the public url (if any) so the response_url fallback
# can embed it as markdown when the poll window has closed.
if img_path_or_url.startswith(("http://", "https://")):
state["image_urls"].append(img_path_or_url)
else:
state["committed"] = accumulated or "[图片发送失败]"
state["finished"] = True
state["image_pending"] = False
state["last_access"] = time.time()
self._schedule_response_url_fallback(stream_id)
# ------------------------------------------------------------------
# Active reply fallback (response_url): rescue replies that finish after
# WeCom stops polling (the passive stream window is ~6 min from the user's
# message). A short delay lets an in-flight poll deliver first; only if no
# poll picks up the finished answer do we push it actively via response_url.
# ------------------------------------------------------------------
def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0):
def _run():
time.sleep(delay)
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if not state:
return
if state.get("delivered") or state.get("url_sent"):
return # a poll already delivered (or fallback already ran)
response_url = state.get("response_url") or ""
if not response_url:
logger.warning(
f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped"
)
return
content = (state["committed"] + state["current"]).strip()
image_urls = list(state.get("image_urls") or [])
has_images = bool(state.get("images"))
state["url_sent"] = True
self._send_via_response_url(stream_id, response_url, content, image_urls, has_images)
threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start()
def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images):
"""Push a one-shot active markdown reply to response_url (valid 1h, single use)."""
md = content or ""
if image_urls:
md += ("\n\n" if md else "") + "\n".join(f"![]({u})" for u in image_urls)
elif has_images:
md += ("\n\n" if md else "") + "(图片已生成,但因处理超时无法通过回调发送)"
if not md:
md = "(处理完成)"
payload = {"msgtype": "markdown", "markdown": {"content": md}}
try:
resp = requests.post(response_url, json=payload, timeout=15)
logger.info(
f"[WecomBot] response_url active reply sent for {stream_id}: "
f"status={resp.status_code}, body={resp.text[:200]}"
)
except Exception as e:
logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}")
def _load_image_base64(self, img_path_or_url: str):
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
local_path = img_path_or_url
if local_path.startswith("file://"):
local_path = local_path[7:]
# Temp files we create here (downloads/conversions/compressions) must be
# cleaned up afterwards; the caller's original local file must not be.
temp_files = []
try:
if local_path.startswith(("http://", "https://")):
try:
resp = requests.get(local_path, timeout=30)
resp.raise_for_status()
tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
with open(tmp_path, "wb") as f:
f.write(resp.content)
temp_files.append(tmp_path)
local_path = tmp_path
except Exception as e:
logger.error(f"[WecomBot] Failed to download image for callback reply: {e}")
return None
if not os.path.exists(local_path):
logger.error(f"[WecomBot] Image file not found: {local_path}")
return None
formatted = self._ensure_image_format(local_path)
if not formatted:
return None
if formatted != local_path:
temp_files.append(formatted)
local_path = formatted
# Unlike the long-connection path (which uploads and sends only a tiny
# media_id), the callback reply embeds the whole image as base64 inside
# an AES-encrypted body that is returned on EVERY poll. Empirically a
# ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the
# finish packet and poll forever, so cap well below that.
callback_max_size = 512 * 1024
if os.path.getsize(local_path) > callback_max_size:
compressed = self._compress_image(local_path, callback_max_size)
if compressed:
temp_files.append(compressed)
local_path = compressed
else:
logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)")
try:
with open(local_path, "rb") as f:
raw = f.read()
return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
except Exception as e:
logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
return None
finally:
for path in temp_files:
try:
os.remove(path)
except OSError:
pass
# ------------------------------------------------------------------
# Send reply
# ------------------------------------------------------------------
def send(self, reply: Reply, context: Context):
if self.mode == "webhook":
self._callback_send(reply, context)
return
msg = context.get("msg")
is_group = context.get("isgroup", False)
receiver = context.get("receiver", "")
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
else:
logger.error("[WecomBot] Failed to get media_id from finish response")
return media_id
class WecomBotCallbackController:
"""HTTP controller for wecom bot callback (webhook) mode.
- GET : URL verification (echo the decrypted echostr).
- POST : encrypted message / stream-refresh / event callbacks; returns an
encrypted passive reply (or "success" for an empty reply).
"""
@staticmethod
def _channel() -> "WecomBotChannel":
return WecomBotChannel()
def GET(self):
channel = self._channel()
params = web.input(msg_signature="", timestamp="", nonce="", echostr="")
if not channel._crypt:
return "wecom bot callback not ready"
ret, echo = channel._crypt.verify_url(
params.msg_signature, params.timestamp, params.nonce, params.echostr
)
if ret != 0:
logger.error(f"[WecomBot] URL verify failed: ret={ret}")
return "verify fail"
if isinstance(echo, bytes):
echo = echo.decode("utf-8")
return echo
def POST(self):
channel = self._channel()
if not channel._crypt:
return "success"
params = web.input(msg_signature="", timestamp="", nonce="")
body = web.data()
ret, plain = channel._crypt.decrypt_msg(
body, params.msg_signature, params.timestamp, params.nonce
)
if ret != 0:
logger.error(f"[WecomBot] callback decrypt failed: ret={ret}")
return "success"
try:
data = json.loads(plain)
except Exception as e:
logger.error(f"[WecomBot] callback json parse failed: {e}")
return "success"
msgtype = data.get("msgtype", "")
# Stream polls arrive ~1/s; logging each is noisy, so only log non-poll
# callbacks here (poll completion is logged in the stream-poll handler).
if msgtype != "stream":
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
try:
if msgtype == "stream":
reply = channel._callback_handle_stream_poll(data)
elif msgtype == "event":
event_type = data.get("event", {}).get("eventtype", "")
logger.info(f"[WecomBot] callback event: {event_type}")
reply = None
elif msgtype in ("text", "image", "voice", "file", "video", "mixed"):
reply = channel._callback_handle_message(data)
else:
logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}")
reply = None
except Exception as e:
logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True)
reply = None
if not reply:
# Empty reply package is acceptable.
return "success"
plain_reply = json.dumps(reply, ensure_ascii=False)
ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp)
if ret != 0:
logger.error(f"[WecomBot] callback encrypt failed: ret={ret}")
return "success"
web.header("Content-Type", "application/json; charset=utf-8")
return json.dumps(enc, ensure_ascii=False)

View File

@@ -0,0 +1,203 @@
"""
WeCom (企业微信) smart-bot callback message encryption/decryption.
Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the
AI bot callback (webhook) mode. The bot's receive-message callback delivers
AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the
same way before being returned in the HTTP response.
For an enterprise-internal smart bot, ``receive_id`` is always an empty string.
"""
import base64
import hashlib
import random
import socket
import struct
import time
from Crypto.Cipher import AES
from common.log import logger
# Error codes (mirrors the official ierror.py)
WXBizMsgCrypt_OK = 0
WXBizMsgCrypt_ValidateSignature_Error = -40001
WXBizMsgCrypt_ParseJson_Error = -40002
WXBizMsgCrypt_ComputeSignature_Error = -40003
WXBizMsgCrypt_IllegalAesKey = -40004
WXBizMsgCrypt_ValidateCorpid_Error = -40005
WXBizMsgCrypt_EncryptAES_Error = -40006
WXBizMsgCrypt_DecryptAES_Error = -40007
WXBizMsgCrypt_IllegalBuffer = -40008
WXBizMsgCrypt_EncodeBase64_Error = -40009
WXBizMsgCrypt_DecodeBase64_Error = -40010
WXBizMsgCrypt_GenReturnJson_Error = -40011
class FormatException(Exception):
pass
def _gen_sha1(token, timestamp, nonce, encrypt):
"""Compute the WeCom message signature with SHA1 over the sorted parts."""
try:
if isinstance(encrypt, bytes):
encrypt = encrypt.decode("utf-8")
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
sortlist.sort()
sha = hashlib.sha1()
sha.update("".join(sortlist).encode("utf-8"))
return WXBizMsgCrypt_OK, sha.hexdigest()
except Exception as e:
logger.error(f"[WecomBot] compute signature error: {e}")
return WXBizMsgCrypt_ComputeSignature_Error, None
class _PKCS7Encoder:
"""PKCS#7 padding with a 32-byte block size (AES-256)."""
block_size = 32
def encode(self, text: bytes) -> bytes:
text_length = len(text)
amount_to_pad = self.block_size - (text_length % self.block_size)
if amount_to_pad == 0:
amount_to_pad = self.block_size
pad = bytes([amount_to_pad])
return text + pad * amount_to_pad
def decode(self, decrypted: bytes) -> bytes:
pad = decrypted[-1]
if pad < 1 or pad > 32:
pad = 0
return decrypted[:-pad] if pad else decrypted
class _Prpcrypt:
"""AES-256-CBC encrypt/decrypt for WeCom callback messages."""
def __init__(self, key: bytes):
self.key = key
self.mode = AES.MODE_CBC
def encrypt(self, text: str, receive_id: str):
text_bytes = text.encode()
# 16-byte random prefix + network-order length + body + receive_id
text_bytes = (
self._get_random_str()
+ struct.pack("I", socket.htonl(len(text_bytes)))
+ text_bytes
+ receive_id.encode()
)
text_bytes = _PKCS7Encoder().encode(text_bytes)
try:
cryptor = AES.new(self.key, self.mode, self.key[:16])
ciphertext = cryptor.encrypt(text_bytes)
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
except Exception as e:
logger.error(f"[WecomBot] AES encrypt error: {e}")
return WXBizMsgCrypt_EncryptAES_Error, None
def decrypt(self, text, receive_id: str):
try:
cryptor = AES.new(self.key, self.mode, self.key[:16])
plain_text = cryptor.decrypt(base64.b64decode(text))
except Exception as e:
logger.error(f"[WecomBot] AES decrypt error: {e}")
return WXBizMsgCrypt_DecryptAES_Error, None
try:
pad = plain_text[-1]
content = plain_text[16:-pad]
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
json_content = content[4 : json_len + 4].decode("utf-8")
from_receive_id = content[json_len + 4 :].decode("utf-8")
except Exception as e:
logger.error(f"[WecomBot] illegal buffer when decrypting: {e}")
return WXBizMsgCrypt_IllegalBuffer, None
if from_receive_id != receive_id:
logger.error(
f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}"
)
return WXBizMsgCrypt_ValidateCorpid_Error, None
return WXBizMsgCrypt_OK, json_content
@staticmethod
def _get_random_str() -> bytes:
return str(random.randint(1000000000000000, 9999999999999999)).encode()
class WecomBotCrypt:
"""High-level helper for verifying URLs and (de)crypting callback messages."""
def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""):
try:
self.key = base64.b64decode(encoding_aes_key + "=")
assert len(self.key) == 32
except Exception:
raise FormatException("[WecomBot] invalid EncodingAESKey")
self.token = token
self.receive_id = receive_id
def verify_url(self, msg_signature, timestamp, nonce, echostr):
ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr)
if ret != 0:
return ret, None
if signature != msg_signature:
return WXBizMsgCrypt_ValidateSignature_Error, None
pc = _Prpcrypt(self.key)
return pc.decrypt(echostr, self.receive_id)
def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None):
"""Encrypt a passive-reply JSON string and return the full response JSON.
Returns (ret, response_dict). On success ret==0 and response_dict is a
dict with encrypt/msgsignature/timestamp/nonce fields.
"""
pc = _Prpcrypt(self.key)
ret, encrypt = pc.encrypt(reply_msg, self.receive_id)
if ret != 0:
return ret, None
encrypt = encrypt.decode("utf-8")
if timestamp is None:
timestamp = str(int(time.time()))
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
if ret != 0:
return ret, None
return WXBizMsgCrypt_OK, {
"encrypt": encrypt,
"msgsignature": signature,
"timestamp": timestamp,
"nonce": nonce,
}
def decrypt_msg(self, post_data, msg_signature, timestamp, nonce):
"""Verify signature and decrypt the encrypted callback payload.
``post_data`` may be the raw request body (bytes/str) containing
``{"encrypt": "..."}`` or the already-extracted encrypt string.
Returns (ret, plaintext_json_str).
"""
import json
encrypt = None
if isinstance(post_data, (bytes, bytearray)):
post_data = post_data.decode("utf-8")
if isinstance(post_data, str):
try:
encrypt = json.loads(post_data).get("encrypt")
except Exception:
encrypt = post_data
elif isinstance(post_data, dict):
encrypt = post_data.get("encrypt")
if not encrypt:
return WXBizMsgCrypt_ParseJson_Error, None
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
if ret != 0:
return ret, None
if signature != msg_signature:
logger.error("[WecomBot] callback signature not match")
return WXBizMsgCrypt_ValidateSignature_Error, None
pc = _Prpcrypt(self.key)
return pc.decrypt(encrypt, self.receive_id)

View File

@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
class WecomBotMessage(ChatMessage):
"""Message wrapper for wecom bot (websocket long-connection mode)."""
def __init__(self, msg_body: dict, is_group: bool = False):
def __init__(self, msg_body: dict, is_group: bool = False, default_aeskey: str = ""):
super().__init__(msg_body)
self.msg_id = msg_body.get("msgid")
self.create_time = msg_body.get("create_time")
self.is_group = is_group
# In callback (webhook) mode the media bodies carry no per-message aeskey;
# the download url is encrypted with the bot's EncodingAESKey instead.
self._default_aeskey = default_aeskey
msg_type = msg_body.get("msgtype")
from_userid = msg_body.get("from", {}).get("userid", "")
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.IMAGE
image_info = msg_body.get("image", {})
image_url = image_info.get("url", "")
aeskey = image_info.get("aeskey", "")
aeskey = image_info.get("aeskey", "") or self._default_aeskey
tmp_dir = _get_tmp_dir()
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
elif item_type == "image":
img_info = item.get("image", {})
img_url = img_info.get("url", "")
img_aeskey = img_info.get("aeskey", "")
img_aeskey = img_info.get("aeskey", "") or self._default_aeskey
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
try:
img_data = _decrypt_media(img_url, img_aeskey)
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.FILE
file_info = msg_body.get("file", {})
file_url = file_info.get("url", "")
aeskey = file_info.get("aeskey", "")
aeskey = file_info.get("aeskey", "") or self._default_aeskey
tmp_dir = _get_tmp_dir()
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
self.content = base_path
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.FILE
video_info = msg_body.get("video", {})
video_url = video_info.get("url", "")
aeskey = video_info.get("aeskey", "")
aeskey = video_info.get("aeskey", "") or self._default_aeskey
tmp_dir = _get_tmp_dir()
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")

View File

@@ -1 +1 @@
2.1.1
2.1.2

View File

@@ -874,7 +874,7 @@ def _build_config():
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
"agent_max_steps": local_conf.get("agent_max_steps"),
# Self-evolution switch reported so the cloud console can reflect state
# Self-evolution switch reported so the console can reflect state
"self_evolution_enabled": "Y" if local_conf.get("self_evolution_enabled") else "N",
"self_evolution_idle_minutes": local_conf.get("self_evolution_idle_minutes"),
"self_evolution_min_turns": local_conf.get("self_evolution_min_turns"),

View File

@@ -121,7 +121,8 @@ MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax multimodal (vision)
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
# GLM (Zhipu AI)
GLM_5_1 = "glm-5.1" # GLM-5.1 - Agent recommended model (default)
GLM_5_2 = "glm-5.2" # GLM-5.2 - Agent recommended model (default)
GLM_5_1 = "glm-5.1" # GLM-5.1
GLM_5_TURBO = "glm-5-turbo" # GLM-5-Turbo
GLM_5 = "glm-5" # GLM-5
GLM_5V_TURBO = "glm-5v-turbo" # Zhipu multimodal (vision)
@@ -137,9 +138,11 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
# Kimi (Moonshot)
MOONSHOT = "moonshot"
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
KIMI_K2 = "kimi-k2"
KIMI_K2_5 = "kimi-k2.5"
KIMI_K2_6 = "kimi-k2.6" # Kimi K2.6 - Agent recommended model (default)
KIMI_K2_6 = "kimi-k2.6"
# Xiaomi MiMo
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - flagship, long context (default recommendation)
@@ -213,7 +216,7 @@ MODEL_LIST = [
O1, O1_MINI,
# GLM (Zhipu AI)
ZHIPU_AI, GLM_5_1, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
ZHIPU_AI, GLM_5_2, GLM_5_1, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
# Qwen
@@ -224,7 +227,7 @@ MODEL_LIST = [
# Kimi (Moonshot)
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
KIMI_K2_6, KIMI_K2_5, KIMI_K2,
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
# ModelScope
MODELSCOPE,

View File

@@ -27,7 +27,7 @@ available_setting = {
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
# Each item: {"id": "3f2a9c1b", "name": "my-provider", "api_key": "sk-...", "api_base": "https://api.example.com/v1", "model": "model-name"}
"custom_providers": [],
"proxy": "", # proxy used by openai
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
@@ -183,6 +183,11 @@ available_setting = {
# WeCom smart bot config (long connection mode)
"wecom_bot_id": "", # WeCom smart bot BotID
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
# WeCom smart bot transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
"wecom_bot_mode": "websocket",
"wecom_bot_token": "", # webhook mode: Token configured on the bot's receive-message URL
"wecom_bot_encoding_aes_key": "", # webhook mode: EncodingAESKey configured on the bot's receive-message URL
"wecom_bot_port": 9892, # webhook mode: local HTTP server port for the receive-message URL
# Telegram config
"telegram_token": "", # Bot token from @BotFather
"telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)

View File

@@ -38,12 +38,13 @@ services:
DINGTALK_CLIENT_SECRET: ''
WECOM_BOT_ID: ''
WECOM_BOT_SECRET: ''
# 如需通过宿主机访问 Web 控制台,改为 '0.0.0.0' 并设置 WEB_PASSWORD
# To access the web console from the host, set this to '0.0.0.0' and set WEB_PASSWORD
WEB_HOST: '127.0.0.1'
WEB_PASSWORD: ''
AGENT: 'True'
AGENT_MAX_CONTEXT_TOKENS: 50000
AGENT_MAX_CONTEXT_TURNS: 20
AGENT_MAX_STEPS: 20
SELF_EVOLUTION_ENABLED: 'True'
volumes:
- ./cow:/home/agent/cow

View File

@@ -69,6 +69,20 @@ Create the AI Bot in WeCom and obtain the Bot ID and Secret, then connect via th
The log line `[WecomBot] Subscribe success` confirms the connection is established.
<Note>
A **webhook (HTTP callback) mode** is also supported: when creating the bot, choose **Use URL callback**, set the receive-message URL to `http(s)://<your-domain-or-ip>:9892/wecombot`, and copy the Token and EncodingAESKey from that page. This mode needs a publicly reachable address and does not support file sending or scheduled push, so the long connection is generally recommended. The corresponding `config.json`:
```json
{
"channel_type": "wecom_bot",
"wecom_bot_mode": "webhook",
"wecom_bot_token": "YOUR_TOKEN",
"wecom_bot_encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"wecom_bot_port": 9892
}
```
</Note>
## 2. Supported features
| Feature | Status |

View File

@@ -22,6 +22,10 @@
"label": "官网",
"href": "https://cowagent.ai/"
},
{
"label": "博客",
"href": "https://cowagent.ai/zh/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -51,6 +55,10 @@
"label": "Website",
"href": "https://cowagent.ai/"
},
{
"label": "Blog",
"href": "https://cowagent.ai/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -241,6 +249,7 @@
"group": "Release Notes",
"pages": [
"releases/overview",
"releases/v2.1.2",
"releases/v2.1.1",
"releases/v2.1.0",
"releases/v2.0.9",
@@ -267,6 +276,10 @@
"label": "官网",
"href": "https://cowagent.ai/?lang=zh"
},
{
"label": "博客",
"href": "https://cowagent.ai/zh/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -457,6 +470,7 @@
"group": "发布记录",
"pages": [
"zh/releases/overview",
"zh/releases/v2.1.2",
"zh/releases/v2.1.1",
"zh/releases/v2.1.0",
"zh/releases/v2.0.9",
@@ -483,6 +497,10 @@
"label": "ウェブサイト",
"href": "https://cowagent.ai/"
},
{
"label": "ブログ",
"href": "https://cowagent.ai/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -673,6 +691,7 @@
"group": "リリースノート",
"pages": [
"ja/releases/overview",
"ja/releases/v2.1.2",
"ja/releases/v2.1.1",
"ja/releases/v2.1.0",
"ja/releases/v2.0.9",

View File

@@ -108,9 +108,9 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
@@ -199,6 +199,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
## 🏷 更新履歴
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー、自己進化の改善、新モデルkimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデルMiniMax-M3、qwen3.7-plus、Python 3.13 対応。
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国際化対応、新チャネルTelegram、Discord、Slack、WeChat カスタマーサービス、CLI インタラクション強化、ワンライナーインストールの最適化、MCP Streamable HTTP 対応、新モデルclaude-opus-4-8、MiMo

View File

@@ -52,6 +52,20 @@ WeCom AI Bot を介して CowAgent を接続し、ダイレクトメッセージ
設定後、プログラムを起動します。ログに `[WecomBot] Subscribe success` と表示されれば接続成功です。
<Note>
ロングコネクションのほかに、**WebhookHTTP コールバック)モード**にも対応しています。Bot 作成時に **URL コールバックを使用**を選択し、受信メッセージ URL を `http(s)://<ドメインまたはIP>:9892/wecombot` に設定して、その画面の Token と EncodingAESKey をコピーします。このモードは外部からアクセス可能なアドレスが必要で、ファイル送信とスケジュール配信には対応していないため、通常はロングコネクションを推奨します。対応する `config.json`
```json
{
"channel_type": "wecom_bot",
"wecom_bot_mode": "webhook",
"wecom_bot_token": "YOUR_TOKEN",
"wecom_bot_encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"wecom_bot_port": 9892
}
```
</Note>
## 3. 対応機能
| 機能 | 状態 |

View File

@@ -13,20 +13,20 @@ Zhipu AI はテキスト対話、画像理解、音声認識ASR、ベク
```json
{
"model": "glm-5.1",
"model": "glm-5.2",
"zhipu_ai_api_key": "YOUR_API_KEY"
}
```
| パラメータ | 説明 |
| --- | --- |
| `model` | `glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` などを指定可能。詳細は [モデルコード](https://bigmodel.cn/dev/api/normal-model/glm-4) を参照 |
| `model` | `glm-5.2`、`glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` などを指定可能。詳細は [モデルコード](https://bigmodel.cn/dev/api/normal-model/glm-4) を参照 |
| `zhipu_ai_api_key` | [Zhipu AI コンソール](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) で作成 |
| `zhipu_ai_api_base` | 任意。デフォルトは `https://open.bigmodel.cn/api/paas/v4` |
## 画像理解
Zhipu の chat 系モデル(`glm-5.1`、`glm-5-turbo` など)はビジョンに対応していないため、ビジョン呼び出しは `glm-5v-turbo` に統一的にルーティングされます。`zhipu_ai_api_key` を設定すると、Agent の Vision ツールは自動的にこのモデルを使用するため、設定ファイルで明示的に指定する必要はありません。
Zhipu の chat 系モデル(`glm-5.2`、`glm-5.1`、`glm-5-turbo` など)はビジョンに対応していないため、ビジョン呼び出しは `glm-5v-turbo` に統一的にルーティングされます。`zhipu_ai_api_key` を設定すると、Agent の Vision ツールは自動的にこのモデルを使用するため、設定ファイルで明示的に指定する必要はありません。
## 音声認識

View File

@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
<Note>
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨しますdeepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.7-plus、kimi-k2.6、ernie-5.1。
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨しますdeepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
</Note>
@@ -23,10 +23,10 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Zhipu GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |

View File

@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
```json
{
"model": "kimi-k2.6",
"model": "kimi-k2.7-code",
"moonshot_api_key": "YOUR_API_KEY"
}
```
| パラメータ | 説明 |
| --- | --- |
| `model` | `kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
| `model` | `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |

View File

@@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴
| バージョン | 日付 | 説明 |
| --- | --- | --- |
| [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 など)、各種改善 |
| [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル、MCP Streamable HTTP、新モデル追加 |
| [2.0.9](/ja/releases/v2.0.9) | 2026.05.22 | モデル管理機能の追加、MCP プロトコル対応、ブラウザログイン状態の永続化、新モデル追加gpt-5.5、gemini-3.5-flash、qwen3.7-max など)、デプロイ・セキュリティ強化 |

View File

@@ -0,0 +1,67 @@
---
title: v2.1.2
description: CowAgent 2.1.2 - Web コンソールの管理機能強化、自己進化の改善、新モデル、企業微信スマートボットのコールバックモード、セキュリティ強化
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
## 💬 Web コンソールの改善
本リリースでは Web コンソールに複数の可視化管理機能を追加し、ファイルを編集せずに UI 上でより多くの設定を行えるようになりました:
- **定期タスク管理**コンソール上で任意の定期タスクを直接表示・編集・有効化無効化・削除できます。タスク一覧は有効状態を優先し、次回実行時刻の順にソートされます。Thanks @HnBigVolibear (#2892)
- **ナレッジベースのカテゴリと文書管理**:ナレッジベースをカテゴリで整理し、各カテゴリ配下の文書を UI で管理できるようになりました。Thanks @yangziyu-hhh (#2893)
- **複数のカスタムモデルプロバイダー**:複数の OpenAI 互換プロバイダーを設定し、有効なものをワンクリックで切り替えられます。既存の設定とも完全に互換です。Thanks @kirs-hi (#2877)
- **セッションのリネーム**:セッションを手動でリネームでき、並行する複数のタスクを区別しやすくなります (#2897)
- **Bash のストリーミング出力**:長時間実行される Bash コマンドの進捗をリアルタイムにストリーミング出力します。Thanks @yangziyu-hhh (#2879)
## 🧬 自己進化の改善
前バージョンで導入した自己進化を、本バージョンでさらに改善しました:
- **トリガー閾値の引き下げ**:デフォルトのレビュー閾値を引き下げ、日々の協働がより早く改善へとつながります
- **同時レビューの回避**:単一ターンの処理が長引いた場合に、アイドルレビューが誤って起動しなくなり、進行中の会話との干渉を回避します
- **レビュー要約の改善**:要約生成のプロンプトを改善し、要約を簡潔に保ちつつ情報密度を高め、会話の言語で出力します
ドキュメント:[自己進化](https://docs.cowagent.ai/ja/memory/self-evolution)
## 🤖 新モデル
- **kimi-k2.7-code**:追加して Kimi のデフォルトモデルに設定。高速版の `kimi-k2.7-code-highspeed` も利用できます
- **glm-5.2**:追加して GLM のデフォルトモデルに設定
ドキュメント:[モデル概要](https://docs.cowagent.ai/ja/models)
## 🏢 企業微信スマートボットのコールバックモード
企業微信スマートボットのチャネルに、既存のロングコネクションに加えて **HTTP コールバックモード** を追加しました。ロングコネクションを維持できない環境でも安定して接続できます:
- **モード切替**`wecom_bot_mode` で `websocket`(ロングコネクション)と `webhook`(コールバック)を切り替えます
- **暗号化通信**:コールバックモードは URL 検証、メッセージ復号、受動応答の暗号化に完全対応します
- **安定性の修正**:応答の中断、ストリームの早期終了、一時画像ファイルのリークなどの問題を修正しました
Thanks @6vision (#2896 #2869)
ドキュメント:[企業微信スマートボット](https://docs.cowagent.ai/ja/channels/wecom-bot)
## 🔒 セキュリティ強化
- **Vision ツールの SSRF 対策**:画像 URL を解決する前に対象アドレスを検証し、内部・ループバック・クラウドサーバーのメタデータエンドポイントへのリクエストをブロックします。Thanks @kirs-hi (#2886)
- **Web フェッチの SSRF 対策**`web_fetch` は取得前に対象アドレスを検証し、リダイレクトのたびに再検証することで、リダイレクト経由で検証を回避して内部アドレスへ到達することを防ぎます。Thanks @christop (#2900)
- **Skill インストールのパストラバーサル対策**Skill のインストール時にパスを検証し、悪意ある Skill 名がパストラバーサルで `skills/` ディレクトリを抜け出して許可されない場所に書き込むことを防ぎます。Thanks @kirs-hi (#2886)
## 🛠 改善と修正
- **CLI セルフ再起動**self-restart コマンドを追加し、Agent が自身のプロセスを再起動できるようになりました
- **Windows 互換性**cow CLI のディレクトリをユーザー PATH に永続化。`python -c` の長いコマンドが `cmd.exe` の長さ制限を超える問題を修正。インストール時に greenlet をソースからビルドしないように修正
- **カスタムロール**:ロールプラグインが `roles/*.json` 配下の独立したプロンプトファイルによるカスタマイズに対応しました。Thanks @sufan721 (#2891)
- **安定性の修正**`/cancel` 時の KeyError と画像圧縮の無限ループを修正Thanks @kirs-hi #2888
- **インストールの改善**起動スクリプトとデフォルト設定を更新。ASR/TTS のデフォルト値、自己進化フラグ、インストール時のハングを修正
- **Vision ツールの安定性**Vision ツールのタイムアウトと max_tokens を引き上げ
- **記憶の蒸留**:ディープドリーム蒸留の出力長制限を撤廃し、大きな `MEMORY.md` が切り詰められないようにしました
## 📦 アップグレード
ソースコードでデプロイしている場合は `cow update` でワンクリックアップグレードするか、最新コードを取得して手動で再起動してください。詳細は [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照してください。
**リリース日**2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)

View File

@@ -9,6 +9,10 @@ description: Self-Evolution — review a conversation after it goes idle to cons
Self-Evolution lets the Agent do more than finish one task at a time; it keeps improving as it works with you. After a conversation winds down, it quietly reviews what just happened: it saves anything worth remembering into long-term memory, fixes problems that surfaced in a skill, and picks up tasks that were left unfinished. Over time the Agent learns your preferences, repeats fewer mistakes, and gets better at wrapping things up on its own. All of this runs in the background, and it only tells you when it actually did something.
<Note>
For the full architecture and engineering behind the self-evolution mechanism, see the blog post: [A Five-Layer Self-Evolution Mechanism for AI Agents](https://cowagent.ai/blog/self-evolution/).
</Note>
> Self-Evolution complements [Deep Dream](/memory/deep-dream). Deep Dream organizes memory itself, while Self-Evolution goes a step further to improve skills and push unfinished tasks forward, sharpening the Agent's abilities through everyday use.
<Frame>

View File

@@ -13,20 +13,20 @@ Zhipu AI supports text chat, image understanding, speech-to-text (ASR), and embe
```json
{
"model": "glm-5.1",
"model": "glm-5.2",
"zhipu_ai_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | Can be `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-4.7`, `glm-4-plus`, `glm-4-flash`, `glm-4-air`, etc. See [model codes](https://bigmodel.cn/dev/api/normal-model/glm-4) |
| `model` | Can be `glm-5.2`, `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-4.7`, `glm-4-plus`, `glm-4-flash`, `glm-4-air`, etc. See [model codes](https://bigmodel.cn/dev/api/normal-model/glm-4) |
| `zhipu_ai_api_key` | Create one in the [Zhipu AI Console](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| `zhipu_ai_api_base` | Optional, defaults to `https://open.bigmodel.cn/api/paas/v4` |
## Image Understanding
Zhipu's chat models (`glm-5.1`, `glm-5-turbo`, etc.) do not support vision; vision calls are uniformly routed to `glm-5v-turbo`. Once `zhipu_ai_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file.
Zhipu's chat models (`glm-5.2`, `glm-5.1`, `glm-5-turbo`, etc.) do not support vision; vision calls are uniformly routed to `glm-5v-turbo`. Once `zhipu_ai_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file.
## Speech-to-Text (ASR)

View File

@@ -16,10 +16,10 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
```json
{
"model": "kimi-k2.6",
"model": "kimi-k2.7-code",
"moonshot_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | Can be `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
| `model` | Can be `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |

View File

@@ -5,6 +5,7 @@ description: CowAgent version history
| Version | Date | Description |
| --- | --- | --- |
| [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 |
| [2.1.0](/releases/v2.1.0) | 2026.06.01 | Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades (streaming output, fuzzy command matching, task cancellation), MCP Streamable HTTP, new models |
| [2.0.9](/releases/v2.0.9) | 2026.05.22 | Model management console, MCP protocol support, browser persistent login, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max, etc.), deployment hardening |

67
docs/releases/v2.1.2.mdx Normal file
View File

@@ -0,0 +1,67 @@
---
title: v2.1.2
description: CowAgent 2.1.2 - Web Console management upgrades, Self-Evolution improvements, new models, WeCom smart-bot callback mode, and security hardening
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
## 💬 Web Console Improvements
This release adds several visual management capabilities to the Web Console, so more configuration can be done in the UI without editing files:
- **Scheduled task management**: View, edit, enable/disable, and delete any scheduled task directly in the console. The task list is sorted by enabled status first, then by next run time. Thanks @HnBigVolibear (#2892)
- **Knowledge base categories and document management**: The knowledge base can now be organized by category, with documents under each category managed in the UI. Thanks @yangziyu-hhh (#2893)
- **Multiple custom model providers**: Configure multiple OpenAI-compatible providers and switch the active one with a single click, fully compatible with existing configuration. Thanks @kirs-hi (#2877)
- **Session renaming**: Rename sessions manually to tell parallel tasks apart (#2897)
- **Bash streaming output**: Long-running Bash commands now stream their progress in real time. Thanks @yangziyu-hhh (#2879)
## 🧬 Self-Evolution Improvements
Building on the Self-Evolution introduced in the previous release, this version refines it further:
- **Lower trigger thresholds**: The default review thresholds are lowered, so everyday collaboration turns into improvements sooner
- **No concurrent reviews**: When a single turn runs long, the idle review no longer fires by mistake, avoiding interference with the active conversation
- **Better review summary**: Refined the summary prompt to keep summaries concise, raise their information density, and output them in the conversation language
Documentation: [Self-Evolution](https://docs.cowagent.ai/memory/self-evolution)
## 🤖 New Models
- **kimi-k2.7-code**: Added and set as the default Kimi model, with `kimi-k2.7-code-highspeed` also available
- **glm-5.2**: Added and set as the default GLM model
Documentation: [Models Overview](https://docs.cowagent.ai/models)
## 🏢 WeCom Smart-Bot Callback Mode
The WeCom smart-bot channel adds an **HTTP callback mode** alongside the existing long connection, so deployments that cannot keep a long connection open can still connect reliably:
- **Mode switching**: Switch between `websocket` (long connection) and `webhook` (callback) via `wecom_bot_mode`
- **Encrypted transport**: Callback mode fully supports URL verification, message decryption, and passive-reply encryption
- **Stability fixes**: Fixed reply interruption, premature stream termination, and temporary image file leaks
Thanks @6vision (#2896 #2869)
Documentation: [WeCom Smart Bot](https://docs.cowagent.ai/channels/wecom-bot)
## 🔒 Security Hardening
- **Vision tool SSRF protection**: Validates the target address before resolving an image URL, blocking requests to internal, loopback, and cloud server metadata endpoints. Thanks @kirs-hi (#2886)
- **Web fetch SSRF protection**: `web_fetch` validates the target address before fetching and re-validates every redirect hop, preventing redirects from bypassing the check to reach internal addresses. Thanks @christop (#2900)
- **Skill install path traversal protection**: Validates the path when installing a skill, preventing a malicious skill name from escaping the `skills/` directory through path traversal and writing to an unauthorized location. Thanks @kirs-hi (#2886)
## 🛠 Improvements & Fixes
- **CLI self-restart**: Added the self-restart command so the agent can restart its own process
- **Windows compatibility**: Persist the cow CLI directory to the user PATH; fixed `python -c` long commands exceeding the `cmd.exe` length limit; avoid building greenlet from source during install
- **Custom roles**: The role plugin supports customization via standalone prompt files under `roles/*.json`. Thanks @sufan721 (#2891)
- **Stability fixes**: Fixed a KeyError on `/cancel` and an infinite loop in image compression (Thanks @kirs-hi #2888)
- **Install improvements**: Updated the startup script and default config; fixed ASR/TTS defaults, the self-evolution flag, and install hangs
- **Vision tool stability**: Increased the vision tool timeout and max_tokens
- **Memory distillation**: Removed the output length cap in deep-dream distillation to avoid truncating a large `MEMORY.md`
## 📦 Upgrade
Source-code deployments can run `cow update` for a one-click upgrade, or pull the latest code and restart manually. See the [Upgrade Guide](https://docs.cowagent.ai/guide/upgrade) for details.
**Release Date**: 2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)

View File

@@ -108,10 +108,10 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -200,6 +200,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
## 🏷 更新日志
> **2026.06.18** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级定时任务管理、知识库分类、多模型自定义厂商、自主进化优化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和体验优化
> **2026.06.09** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自进化能力、Web 控制台升级消息管理、多会话并行、新模型接入MiniMax-M3、qwen3.7-plus、Python 3.13 支持
> **2026.06.01** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国际化支持、新增通道Telegram、Discord、Slack、微信客服、命令行交互升级、一键安装脚本优化、MCP Streamable HTTP 支持、新模型接入claude-opus-4-8、MiMo

View File

@@ -69,6 +69,20 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
日志显示 `[WecomBot] Subscribe success` 即表示连接成功。
<Note>
除长连接外,也支持**回调HTTP 回调)模式**:创建机器人时选择「使用 URL 回调」,将接收消息 URL 设为 `http(s)://<域名或IP>:9892/wecombot`,并复制该页面的 Token 和 EncodingAESKey。该模式需公网可达且不支持文件发送与定时推送一般推荐使用长连接。对应 `config.json` 配置如下:
```json
{
"channel_type": "wecom_bot",
"wecom_bot_mode": "webhook",
"wecom_bot_token": "YOUR_TOKEN",
"wecom_bot_encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"wecom_bot_port": 9892
}
```
</Note>
## 二、功能说明
| 功能 | 支持情况 |

View File

@@ -9,6 +9,10 @@ description: Self-Evolution自动复盘沉淀记忆、优化技能、处
自主进化Self-Evolution让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。在每段对话告一段落后,它会自动"回头复盘"一次把使用中暴露的问题修进技能、把没做完的事情接着推进并把值得记住的沉淀进记忆与知识库。久而久之Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,当真正做了事情时才会主动地告诉你。
<Note>
想了解自进化机制完整的架构设计与工程实现,可阅读博客文章:[让 Agent 在对话中成长:自进化机制的五层实现](https://cowagent.ai/zh/blog/self-evolution/)。
</Note>
> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。
<Frame>

View File

@@ -13,20 +13,20 @@ description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 /
```json
{
"model": "glm-5.1",
"model": "glm-5.2",
"zhipu_ai_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
| `model` | 可填 `glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` 等,参考 [模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4) |
| `model` | 可填 `glm-5.2`、`glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` 等,参考 [模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4) |
| `zhipu_ai_api_key` | 在 [智谱 AI 控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建 |
| `zhipu_ai_api_base` | 可选,默认为 `https://open.bigmodel.cn/api/paas/v4` |
## 图像理解
智谱 chat 系列模型(`glm-5.1`、`glm-5-turbo` 等)不支持视觉,视觉调用统一路由到 `glm-5v-turbo`。配置 `zhipu_ai_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。
智谱 chat 系列模型(`glm-5.2`、`glm-5.1`、`glm-5-turbo` 等)不支持视觉,视觉调用统一路由到 `glm-5v-turbo`。配置 `zhipu_ai_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。
## 语音识别

View File

@@ -17,10 +17,10 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
| [Claude](/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [智谱 GLM](/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
```json
{
"model": "kimi-k2.6",
"model": "kimi-k2.7-code",
"moonshot_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
| `model` | 可填 `kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
| `model` | 可填 `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |

View File

@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
| [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 等)、其他多项优化和修复 |
| [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级流式输出、命令模糊匹配、任务取消、MCP Streamable HTTP、新模型接入 |
| [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 |

View File

@@ -0,0 +1,67 @@
---
title: v2.1.2
description: CowAgent 2.1.2Web 控制台多项管理能力升级、自主进化体验优化、模型新增支持、企业微信智能机器人回调模式、安全加固和体验优化
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
## 💬 Web 控制台优化
本次 Web 控制台围绕「可视化管理」做了多项增强,让更多配置无需改文件即可在界面完成:
- **定时任务管理**:支持在控制台直接查看、编辑、启用/停用、删除任意定时任务任务列表按启用状态与下次执行时间排序。Thanks @HnBigVolibear (#2892)
- **知识库分类与文档管理**知识库支持按分类组织可在界面管理分类下的文档。Thanks @yangziyu-hhh (#2893)
- **多自定义模型厂商**:支持配置多个 OpenAI 兼容供应商并一键切换当前生效项且兼容已有配置。Thanks @kirs-hi (#2877)
- **会话重命名**:支持手动重命名会话,便于区分多个并行任务 (#2897)
- **Bash 流式输出**:长时间运行的 Bash 命令支持流式实时输出进度。Thanks @yangziyu-hhh (#2879)
## 🧬 自主进化体验优化
延续上个版本的自主进化能力,本次迭代进一步优化:
- **触发阈值下调**:默认复盘触发阈值下调,更快触发复盘、沉淀经验
- **避免并发冲突**:单轮任务耗时较长时不再误触发空闲复盘,避免与主对话相互干扰
- **自进化摘要优化**:优化摘要生成提示词,精简篇幅、提升信息密度,并跟随对话语言输出
相关文档:[自主进化](https://docs.cowagent.ai/zh/memory/self-evolution)
## 🤖 模型新增
- **kimi-k2.7-code**:新增并设为 Kimi 默认模型,同时提供 `kimi-k2.7-code-highspeed` 高速版
- **glm-5.2**:新增并设为 GLM 默认模型
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
## 🏢 企业微信智能机器人回调模式
企业微信智能机器人通道在原有长连接的基础上,新增 **HTTP 回调模式**,让无法保持长连接的部署环境也能稳定接入:
- **模式切换**:通过 `wecom_bot_mode` 在 `websocket`(长连接)与 `webhook`(回调)之间切换
- **加密传输**:回调模式完整支持 URL 验签、消息解密与被动回复加密
- **稳定性优化**:修复回复中断、流式提前结束、临时图片文件泄漏等问题
Thanks @6vision (#2896 #2869)
相关文档:[企业微信智能机器人](https://docs.cowagent.ai/zh/channels/wecom-bot)
## 🔒 安全加固
- **视觉工具 SSRF 防护**:解析图片 URL 前校验目标地址拦截指向内网、回环及云服务器元数据接口的请求。Thanks @kirs-hi (#2886)
- **网页抓取 SSRF 防护**`web_fetch` 抓取前校验目标地址并逐跳校验重定向目标防止通过跳转绕过校验。Thanks @christop (#2900)
- **技能安装路径穿越防护**:安装技能时校验路径,防止恶意技能名通过路径穿越逃逸 `skills/` 目录、写入越权位置。Thanks @kirs-hi (#2886)
## 🛠 体验优化与修复
- **CLI 自重启**:新增 self-restart 命令Agent 可自行重启进程
- **Windows 兼容**:将 cow CLI 自动写入用户 PATH修复 `python -c` 长命令超出 `cmd.exe` 长度限制的问题;安装时避免 greenlet 源码编译
- **角色插件自定义**:角色插件支持通过 `roles/*.json` 下的独立提示词文件进行自定义。Thanks @sufan721 (#2891)
- **稳定性修复**:修复 `/cancel` 时的 KeyError 与图片压缩死循环Thanks @kirs-hi #2888
- **一键安装优化**:更新启动脚本与默认配置,修复 ASR/TTS 默认值、自主进化开关及安装过程卡顿等问题
- **视觉工具稳定性**:提升视觉工具的超时时间与 max_tokens
- **记忆蒸馏优化**:深度梦境蒸馏移除输出长度限制,避免大体量 `MEMORY.md` 被截断
## 📦 升级方式
源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
**发布日期**2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)

View File

@@ -14,10 +14,10 @@ Config model
{
"id": "3f2a9c1b", # server-generated short uuid (primary key)
"name": "siliconflow", # user-facing display label (not a key)
"name": "my-provider", # user-facing display label (not a key)
"api_key": "sk-...", # required
"api_base": "https://...", # required, must be OpenAI-compatible
"model": "deepseek-ai/DeepSeek-V3" # optional default model
"model": "model-name" # optional default model
}
Routing

View File

@@ -47,9 +47,20 @@ class MoonshotBot(Bot):
return model == "kimi-for-coding" or "api.kimi.com/coding" in base
@staticmethod
def _model_supports_thinking(model_name: str) -> bool:
"""Return True if the model supports the ``thinking`` request parameter."""
def _is_builtin_reasoning_model(model_name: str) -> bool:
"""Return True for Kimi code models with built-in reasoning.
These models only accept thinking type=enabled and reject disabled,
so the thinking param must be omitted entirely.
"""
return model_name.lower().startswith("kimi-k2.7-code")
@classmethod
def _model_supports_thinking(cls, model_name: str) -> bool:
"""Return True if the model accepts the ``thinking`` request parameter."""
m = model_name.lower()
if cls._is_builtin_reasoning_model(m):
return False
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
def _build_headers(self) -> dict:

View File

@@ -607,6 +607,9 @@ class CowCliPlugin(Plugin):
"agent_max_steps",
"knowledge",
"enable_thinking",
"self_evolution_enabled",
"self_evolution_idle_minutes",
"self_evolution_min_turns",
}
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
@@ -1331,8 +1334,19 @@ class CowCliPlugin(Plugin):
return "linkai (legacy)", "text-embedding-3-small", 1536
return "(legacy)", None, None
meta = EMBEDDING_VENDORS.get(provider_key) or {}
# Since we have added support for custom providers for vector models, this part should be modified accordingly:
# Custom providers ("custom:<id>") resolve to the "custom" vendor key.
resolved_key = "custom" if provider_key.startswith("custom:") else provider_key
meta = EMBEDDING_VENDORS.get(resolved_key) or {}
model = cfg_model or meta.get("default_model")
# Custom provider model fallback: read from custom_providers entry.
if not model and provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if entry and entry.get("model"):
model = entry["model"]
dim = cfg_dim if cfg_dim > 0 else meta.get("default_dimensions")
return provider_key, model, dim

8
run.sh
View File

@@ -601,10 +601,10 @@ select_model() {
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
"OpenAI (gpt-5.5, etc.)" \
"MiniMax (MiniMax-M3, etc.)" \
"GLM (glm-5.1, etc.)" \
"GLM (glm-5.2, etc.)" \
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
"Doubao (doubao-seed-2.0, etc.)" \
"Kimi (kimi-k2.6, etc.)" \
"Kimi (kimi-k2.7-code, etc.)" \
"MiMo (mimo-v2.5-pro, etc.)" \
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
@@ -633,10 +633,10 @@ configure_model() {
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
6) read_model_config "GLM" "glm-5.1" "ZHIPU_KEY" ;;
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-0-code-preview-260215" "ARK_KEY" ;;
9) read_model_config "Kimi (Moonshot)" "kimi-k2.6" "MOONSHOT_KEY" ;;
9) read_model_config "Kimi (Moonshot)" "kimi-k2.7-code" "MOONSHOT_KEY" ;;
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
11)
# Show where to obtain a LinkAI key (zh users -> console page).

View File

@@ -400,12 +400,11 @@ function Install-Dependencies {
& $PythonCmd -m pip install -e $BaseDir @pipMirror 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
# Ensure Python Scripts dir is in PATH for this session
# Add the Scripts dir (where pip puts cow.exe) to PATH, both for this
# session and persistently, so `cow` keeps working after a reboot.
$scriptsDir = & $PythonCmd -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
if ($scriptsDir -and (Test-Path $scriptsDir)) {
if ($env:PATH -notlike "*$scriptsDir*") {
$env:PATH = "$scriptsDir;$env:PATH"
}
Add-ScriptsDirToPath $scriptsDir
}
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
@@ -413,7 +412,39 @@ function Install-Dependencies {
Write-Cow ((T "cow CLI 注册成功" "cow CLI registered") + ": $($cowBin.Source)")
} else {
Write-Warn ((T "cow CLI 不在 PATH 中,你可以使用" "cow CLI not in PATH. You can use") + ": $PythonCmd -m cli.cli")
Write-Warn (T "如需永久修复,请将 Python Scripts 目录加入系统 PATH。" "To fix permanently, add Python Scripts directory to your system PATH.")
}
}
# Add the Python Scripts dir to PATH for this session and persist it to the
# user PATH (User scope needs no admin rights), so `cow` survives a reboot.
function Add-ScriptsDirToPath {
param([string]$ScriptsDir)
if ($env:PATH -notlike "*$ScriptsDir*") {
$env:PATH = "$ScriptsDir;$env:PATH"
}
# Persist to the User PATH only if missing from BOTH User and Machine scopes
# (checking Machine avoids a duplicate when Python is installed for all
# users). Read raw scope values, not the already-merged $env:PATH.
try {
$target = $ScriptsDir.TrimEnd('\')
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$machinePath = ""
try { $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") } catch {}
$allParts = @()
foreach ($p in @($userPath, $machinePath)) {
if ($p) { $allParts += ($p -split ';' | Where-Object { $_ -ne "" }) }
}
$already = $allParts | Where-Object { $_.TrimEnd('\') -ieq $target }
if (-not $already) {
$newPath = if ($userPath) { "$userPath;$ScriptsDir" } else { $ScriptsDir }
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
Write-Cow ((T "已将 cow 命令目录加入用户 PATH重启后仍可用" "Added cow command directory to user PATH (persists after reboot)") + ": $ScriptsDir")
}
} catch {
Write-Warn (T "无法自动写入持久化 PATH重启后可能需要重新配置。" "Could not persist PATH automatically; you may need to reconfigure after reboot.")
}
}
@@ -427,10 +458,10 @@ $ModelChoices = @{
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
6 = @{ Provider = "GLM"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" }
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Field = "ark_api_key" }
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.6"; Field = "moonshot_api_key" }
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.7-code"; Field = "moonshot_api_key" }
10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" }
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
}
@@ -444,10 +475,10 @@ function Select-Model {
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
"OpenAI (gpt-5.5, etc.)",
"MiniMax (MiniMax-M3, etc.)",
"GLM (glm-5.1, etc.)",
"GLM (glm-5.2, etc.)",
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
"Doubao (doubao-seed-2.0, etc.)",
"Kimi (kimi-k2.6, etc.)",
"Kimi (kimi-k2.7-code, etc.)",
"MiMo (mimo-v2.5-pro, etc.)",
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
@@ -664,11 +695,27 @@ function New-ConfigFile {
Write-Cow (T "配置文件创建成功。" "Configuration file created.")
}
# Resolve the `cow` command, self-healing PATH if needed. When `cow` is missing
# (e.g. an older install whose PATH was never persisted), locate the Scripts dir,
# re-add it to PATH (session + persistent), and retry. Returns $true if callable.
function Resolve-CowCommand {
if (Get-Command cow -ErrorAction SilentlyContinue) { return $true }
$py = if ($PythonCmd) { $PythonCmd } else { Find-Python }
if ($py) {
$scriptsDir = & $py -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
if ($scriptsDir -and (Test-Path "$scriptsDir\cow.exe")) {
Add-ScriptsDirToPath $scriptsDir
if (Get-Command cow -ErrorAction SilentlyContinue) { return $true }
}
}
return $false
}
# ── start via cow CLI ─────────────────────────────────────────────
function Start-CowAgent {
Write-Cow (T "正在启动 CowAgent..." "Starting CowAgent...")
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
if ($cowBin) {
if (Resolve-CowCommand) {
& cow start
} else {
Write-Warn (T "未找到 cow CLI直接启动..." "cow CLI not found, starting directly...")
@@ -679,12 +726,21 @@ function Start-CowAgent {
# ── delegate management commands to cow CLI ──────────────────────
function Invoke-CowCommand {
param([string]$Cmd)
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
if ($cowBin) {
if (Resolve-CowCommand) {
& cow $Cmd
} else {
Write-Err (T "未找到 cow CLI请先不带参数运行本脚本进行安装。" "cow CLI not found. Run this script without arguments first to install.")
exit 1
# Fall back to the module entrypoint so management commands still work
# even when cow.exe isn't on PATH (e.g. right after a fresh reboot on
# an older install).
$py = if ($PythonCmd) { $PythonCmd } else { Find-Python }
if ($py -and (Test-Path "$BaseDir\app.py")) {
Write-Warn (T "未找到 cow 命令,使用 python -m cli.cli 兜底运行..." "cow command not found, falling back to python -m cli.cli...")
Push-Location $BaseDir
try { & $py -m cli.cli $Cmd } finally { Pop-Location }
} else {
Write-Err (T "未找到 cow CLI请先不带参数运行本脚本进行安装。" "cow CLI not found. Run this script without arguments first to install.")
exit 1
}
}
}

View File

@@ -88,15 +88,15 @@ class TestResolveCustomCredentials(unittest.TestCase):
set_conf({
"bot_type": "custom:abc12345",
"custom_providers": [
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
"api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"},
{"id": "abc12345", "name": "qiniu", "api_key": "qn-key",
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
{"id": "sf001", "name": "provider-a", "api_key": "key-a",
"api_base": "https://api.example.com/v1", "model": "model-a"},
{"id": "abc12345", "name": "provider-b", "api_key": "key-b",
"api_base": "https://api.example.org/v1", "model": "model-b"},
],
})
self.assertEqual(
self.resolve(),
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
("key-b", "https://api.example.org/v1", "model-b"),
)
def test_id_not_found_falls_back_to_legacy(self):
@@ -105,8 +105,8 @@ class TestResolveCustomCredentials(unittest.TestCase):
"custom_api_key": "legacy-key",
"custom_api_base": "https://legacy.example.com/v1",
"custom_providers": [
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
"api_base": "https://api.siliconflow.cn/v1"},
{"id": "sf001", "name": "provider-a", "api_key": "key-a",
"api_base": "https://api.example.com/v1"},
],
})
self.assertEqual(

View File

@@ -74,8 +74,8 @@ class TestSetCustomProvider(unittest.TestCase):
def test_create_provider_does_not_hijack_bot_type(self):
"""Creating a provider without make_active must not change bot_type."""
res = self.h.call(action="set_custom_provider", name="siliconflow",
api_base="https://api.siliconflow.cn/v1", api_key="sf-key")
res = self.h.call(action="set_custom_provider", name="my-provider",
api_base="https://api.example.com/v1", api_key="key-a")
self.assertEqual(res["status"], "success")
self.assertTrue(res["created"])
self.assertIn("id", res)
@@ -85,13 +85,13 @@ class TestSetCustomProvider(unittest.TestCase):
providers = config_module.conf().get("custom_providers")
self.assertEqual(len(providers), 1)
self.assertEqual(providers[0]["id"], res["id"])
self.assertEqual(providers[0]["name"], "siliconflow")
self.assertEqual(providers[0]["name"], "my-provider")
self.assertEqual(self.h.bridge_resets, 1)
def test_create_with_make_active_switches_bot_type(self):
"""Creating a provider with make_active=true must switch bot_type."""
res = self.h.call(action="set_custom_provider", name="siliconflow",
api_base="https://api.siliconflow.cn/v1", api_key="sf-key",
res = self.h.call(action="set_custom_provider", name="my-provider",
api_base="https://api.example.com/v1", api_key="key-a",
make_active=True)
self.assertEqual(res["status"], "success")
bot_type = config_module.conf().get("bot_type")

View File

@@ -0,0 +1,172 @@
# encoding:utf-8
"""
Regression tests for browser-navigate SSRF protection.
The browser tool navigates to a model-supplied URL via Playwright
``page.goto`` and then auto-snapshots the page back to the model. Without a
guard, a model (including one under prompt injection) can point it at the
cloud-metadata endpoint (169.254.169.254) and read the credentials back
through the snapshot.
Unlike the vision / web_fetch tools, the browser legitimately needs local
pages — a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP. So the guard
is deliberately narrow: it blocks only **link-local** addresses
(169.254.0.0/16, which includes the metadata endpoint, plus IPv6 fe80::/10) and
the IPv6 cloud-metadata address, while leaving loopback and RFC1918/LAN
reachable.
These tests ensure ``BrowserTool``:
- blocks link-local / cloud-metadata targets *before* the navigation reaches
the browser service,
- still lets loopback, RFC1918/LAN and public URLs through to the (stubbed)
service,
- preserves the documented non-HTTP scheme behaviour (about:/data:), and
- honours an explicit opt-out (allow_private_targets).
No real browser / Playwright / network is used: the BrowserService that the
tool would create is replaced with a stub, and DNS resolution is mocked.
"""
import os
import sys
import types
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Stub 'requests' if not installed so sibling tool imports don't fail.
if "requests" not in sys.modules:
_requests_stub = types.ModuleType("requests")
_requests_stub.get = lambda *a, **k: None
sys.modules["requests"] = _requests_stub
def _gai(ip_str):
"""Build a socket.getaddrinfo return value for a single IPv4 address."""
return [(2, 1, 6, "", (ip_str, 0))]
class _StubService:
"""Stand-in for BrowserService that records navigation attempts."""
def __init__(self):
self.navigated = []
def navigate(self, url, timeout=30000):
self.navigated.append(url)
return {"url": url, "title": "page", "status": 200}
def snapshot(self, selector=None):
return "Page: page (http://page/)\nInteractive elements: 0\n---\ncontent"
class TestBrowserNavigateSSRF(unittest.TestCase):
"""Browser navigate blocks link-local/metadata but keeps local dev reachable."""
def setUp(self):
from agent.tools.browser.browser_tool import BrowserTool
self.tool = BrowserTool()
self.stub = _StubService()
# Force the tool to use our stub instead of a real BrowserService.
self.tool._service = self.stub
patcher = patch.object(BrowserTool, "_get_service", return_value=self.stub)
patcher.start()
self.addCleanup(patcher.stop)
# --- Link-local / cloud-metadata: rejected before any service call ---
def test_cloud_metadata_literal_blocked(self):
result = self.tool.execute(
{"action": "navigate", "url": "http://169.254.169.254/latest/meta-data/"}
)
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
def test_link_local_literal_blocked(self):
result = self.tool.execute({"action": "navigate", "url": "http://169.254.1.1/x"})
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
def test_ipv6_metadata_literal_blocked(self):
result = self.tool.execute(
{"action": "navigate", "url": "http://[fd00:ec2::254]/latest/"}
)
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
def test_metadata_hostname_blocked(self):
# A hostname that resolves to the metadata endpoint is blocked too.
with patch("socket.getaddrinfo", return_value=_gai("169.254.169.254")):
result = self.tool.execute(
{"action": "navigate", "url": "http://metadata.internal/latest/meta-data/"}
)
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
# --- Local dev targets stay reachable (the maintainer's core workflow) ---
def test_loopback_literal_allowed(self):
result = self.tool.execute({"action": "navigate", "url": "http://127.0.0.1:3000/"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://127.0.0.1:3000/"])
def test_ipv6_loopback_literal_allowed(self):
result = self.tool.execute({"action": "navigate", "url": "http://[::1]:5173/"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://[::1]:5173/"])
def test_localhost_bare_allowed(self):
"""A bare 'localhost' (no scheme) gets https:// prepended, then allowed."""
with patch("socket.getaddrinfo", return_value=_gai("127.0.0.1")):
result = self.tool.execute({"action": "navigate", "url": "localhost:3000"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["https://localhost:3000"])
def test_rfc1918_10_hostname_allowed(self):
with patch("socket.getaddrinfo", return_value=_gai("10.1.2.3")):
result = self.tool.execute({"action": "navigate", "url": "http://dev.lan/app"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://dev.lan/app"])
def test_rfc1918_192_168_hostname_allowed(self):
with patch("socket.getaddrinfo", return_value=_gai("192.168.0.5")):
result = self.tool.execute({"action": "navigate", "url": "http://router.lan/admin"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://router.lan/admin"])
# --- Public URL is allowed through to the (stubbed) service ---
def test_public_url_allowed(self):
with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34")):
result = self.tool.execute({"action": "navigate", "url": "http://example.com/page"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://example.com/page"])
# --- Documented non-HTTP scheme behaviour preserved (not an egress path) ---
def test_about_blank_not_blocked(self):
result = self.tool.execute({"action": "navigate", "url": "about:blank"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["about:blank"])
# --- Explicit opt-out lets an operator re-enable metadata/link-local ---
def test_opt_out_allows_metadata(self):
from agent.tools.browser.browser_tool import BrowserTool
tool = BrowserTool({"allow_private_targets": True})
stub = _StubService()
tool._service = stub
with patch.object(BrowserTool, "_get_service", return_value=stub):
result = tool.execute(
{"action": "navigate", "url": "http://169.254.169.254/latest/meta-data/"}
)
self.assertEqual(result.status, "success")
self.assertEqual(stub.navigated, ["http://169.254.169.254/latest/meta-data/"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,188 @@
# encoding:utf-8
"""
Regression tests for web_fetch SSRF protection.
The web_fetch tool fetches model-supplied URLs. Without a guard, a model
(including one under prompt injection) can point it at loopback, RFC1918,
link-local or cloud-metadata (169.254.169.254) endpoints, or use a public
URL that 3xx-redirects into such a target. These tests ensure web_fetch
refuses the request instead of connecting to the internal address.
No real network is used: DNS resolution and ``requests.get`` are stubbed.
"""
import os
import sys
import types
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Stub 'requests' if not installed so the module can be imported for testing.
if "requests" not in sys.modules:
_requests_stub = types.ModuleType("requests")
_requests_stub.get = lambda *a, **k: None
class _Exc(Exception):
pass
_requests_stub.Timeout = type("Timeout", (_Exc,), {})
_requests_stub.ConnectionError = type("ConnectionError", (_Exc,), {})
_requests_stub.HTTPError = type("HTTPError", (_Exc,), {})
_requests_stub.Response = object
_compat = types.SimpleNamespace(urljoin=__import__("urllib.parse", fromlist=["urljoin"]).urljoin)
_requests_stub.compat = _compat
sys.modules["requests"] = _requests_stub
def _gai(ip_str):
"""Build a socket.getaddrinfo return value for a single IPv4 address."""
return [(2, 1, 6, "", (ip_str, 0))]
class _FakeRedirect:
"""Minimal stand-in for a requests redirect Response."""
def __init__(self, location):
self.is_redirect = True
self.is_permanent_redirect = False
self.headers = {"Location": location}
self.closed = False
def close(self):
self.closed = True
def _fake_ok_response(body=b"<html><head><title>internal</title></head><body>secret</body></html>"):
"""A well-formed non-redirect response.
Returned by the mocked ``requests.get`` so that on UNPATCHED code the
fetch path runs to completion and the test fails specifically on the
``assert_not_called`` guard (proving a request reached the internal
target), rather than on an incidental error.
"""
resp = MagicMock()
resp.is_redirect = False
resp.is_permanent_redirect = False
resp.status_code = 200
resp.headers = {"Content-Type": "text/html; charset=utf-8"}
resp.content = body
resp.text = body.decode("utf-8")
resp.apparent_encoding = "utf-8"
resp.raise_for_status = lambda: None
return resp
class TestWebFetchSSRF(unittest.TestCase):
"""web_fetch must refuse internal targets and never connect to them."""
def setUp(self):
from agent.tools.web_fetch.web_fetch import WebFetch
self.tool = WebFetch()
# --- Literal internal IPs: rejected before any socket call ---
def test_loopback_literal_blocked(self):
"""http://127.0.0.1:<port>/x must be refused, no request issued."""
with patch("requests.get", return_value=_fake_ok_response()) as mock_get:
result = self.tool.execute({"url": "http://127.0.0.1:8080/canary"})
self.assertEqual(result.status, "error")
self.assertIn("non-public", result.result)
mock_get.assert_not_called()
def test_cloud_metadata_literal_blocked(self):
"""http://169.254.169.254/latest/meta-data/ must be refused."""
with patch("requests.get", return_value=_fake_ok_response()) as mock_get:
result = self.tool.execute(
{"url": "http://169.254.169.254/latest/meta-data/"}
)
self.assertEqual(result.status, "error")
self.assertIn("non-public", result.result)
mock_get.assert_not_called()
def test_ipv6_loopback_literal_blocked(self):
"""http://[::1]/x must be refused."""
with patch("requests.get", return_value=_fake_ok_response()) as mock_get:
result = self.tool.execute({"url": "http://[::1]/canary"})
self.assertEqual(result.status, "error")
self.assertIn("non-public", result.result)
mock_get.assert_not_called()
# --- RFC1918 host resolved via DNS: rejected after resolution ---
def test_rfc1918_hostname_blocked(self):
"""A hostname that resolves to 10.x.x.x must be refused, no request."""
with patch("socket.getaddrinfo", return_value=_gai("10.1.2.3")), \
patch("requests.get", return_value=_fake_ok_response()) as mock_get:
result = self.tool.execute({"url": "http://internal.corp/secret"})
self.assertEqual(result.status, "error")
self.assertIn("non-public", result.result)
mock_get.assert_not_called()
def test_192_168_hostname_blocked(self):
"""A hostname that resolves to 192.168.x.x must be refused."""
with patch("socket.getaddrinfo", return_value=_gai("192.168.0.5")), \
patch("requests.get", return_value=_fake_ok_response()) as mock_get:
result = self.tool.execute({"url": "http://router.local/admin"})
self.assertEqual(result.status, "error")
self.assertIn("non-public", result.result)
mock_get.assert_not_called()
# --- Redirect bounce: public entry URL 302 -> loopback ---
def test_public_to_loopback_redirect_blocked(self):
"""A public URL that redirects to a loopback target must be refused.
The first hop resolves to a public IP and returns a 302 pointing at
127.0.0.1; the guard must re-validate the redirect target and refuse
instead of fetching the internal address.
"""
redirect = _FakeRedirect("http://127.0.0.1:8080/canary")
def fake_getaddrinfo(host, *a, **k):
# Public entry host resolves to a public IP; the loopback literal
# echoes back (as the real getaddrinfo does for an IP literal).
if host == "evil.example.com":
return _gai("93.184.216.34")
return _gai(host)
with patch("socket.getaddrinfo", side_effect=fake_getaddrinfo), \
patch("requests.get", return_value=redirect) as mock_get:
result = self.tool.execute({"url": "http://evil.example.com/start"})
self.assertEqual(result.status, "error")
self.assertIn("non-public", result.result)
# The first (public) hop is issued exactly once; the loopback hop is
# rejected by the guard BEFORE a second requests.get to the internal
# target is made.
self.assertEqual(mock_get.call_count, 1)
first_call_url = mock_get.call_args[0][0]
self.assertEqual(first_call_url, "http://evil.example.com/start")
# The follow-up request to the internal target was never issued.
for call in mock_get.call_args_list:
self.assertNotIn("127.0.0.1", call[0][0])
# --- Sanity: a public URL is allowed to proceed to the fetch path ---
def test_public_url_allowed_through_guard(self):
"""A public URL passes the guard and a (mocked) request is issued."""
ok = MagicMock()
ok.is_redirect = False
ok.is_permanent_redirect = False
ok.headers = {"Content-Type": "text/html; charset=utf-8"}
ok.content = b"<html><head><title>Hi</title></head><body>ok</body></html>"
ok.text = "<html><head><title>Hi</title></head><body>ok</body></html>"
ok.apparent_encoding = "utf-8"
ok.raise_for_status = lambda: None
with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34")), \
patch("requests.get", return_value=ok) as mock_get:
result = self.tool.execute({"url": "http://example.com/page"})
self.assertEqual(result.status, "success")
mock_get.assert_called_once()
self.assertEqual(mock_get.call_args[0][0], "http://example.com/page")
if __name__ == "__main__":
unittest.main()