diff --git a/README.md b/README.md index 4978a30c..2e98edb1 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/agent/chat/service.py b/agent/chat/service.py index 47767aa4..66e91792 100644 --- a/agent/chat/service.py +++ b/agent/chat/service.py @@ -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: diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py index 63380343..5c418f3c 100644 --- a/agent/evolution/executor.py +++ b/agent/evolution/executor.py @@ -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 diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py index 66b068bb..649495e5 100644 --- a/agent/evolution/prompts.py +++ b/agent/evolution/prompts.py @@ -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 skill" / "finished "). - Keep it to 1-3 lines. Generic shape (do not copy domain words): - "I just did a self-learning pass. - - Learned: - - Changed: skill / finished > - 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" "\n" f"{transcript}\n" "" diff --git a/agent/evolution/trigger.py b/agent/evolution/trigger.py index 8055cbae..e044339c 100644 --- a/agent/evolution/trigger.py +++ b/agent/evolution/trigger.py @@ -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. diff --git a/agent/memory/embedding/provider.py b/agent/memory/embedding/provider.py index a106a43c..6fcaf132 100644 --- a/agent/memory/embedding/provider.py +++ b/agent/memory/embedding/provider.py @@ -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:") 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"], diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py index dad6fb95..a3652cac 100644 --- a/agent/tools/bash/bash.py +++ b/agent/tools/bash/bash.py @@ -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 ""` into `python ` 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: [flags] -c "" (single or double quoted) + m = re.search( + r'^(?P.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)' + r'(?P["\'])(?P.*)(?P=quote)\s*(?P.*)$', + 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 diff --git a/agent/tools/browser/browser_tool.py b/agent/tools/browser/browser_tool.py index c91be26c..9930a1ae 100644 --- a/agent/tools/browser/browser_tool.py +++ b/agent/tools/browser/browser_tool.py @@ -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) diff --git a/agent/tools/memory/memory_get.py b/agent/tools/memory/memory_get.py index ec466849..53a05cc7 100644 --- a/agent/tools/memory/memory_get.py +++ b/agent/tools/memory/memory_get.py @@ -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(): diff --git a/agent/tools/scheduler/task_store.py b/agent/tools/scheduler/task_store.py index a54ce46b..a904788b 100644 --- a/agent/tools/scheduler/task_store.py +++ b/agent/tools/scheduler/task_store.py @@ -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 diff --git a/agent/tools/tool_manager.py b/agent/tools/tool_manager.py index 73b76943..823e8b8e 100644 --- a/agent/tools/tool_manager.py +++ b/agent/tools/tool_manager.py @@ -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()) diff --git a/agent/tools/utils/__init__.py b/agent/tools/utils/__init__.py index 56e9a97e..698f75ef 100644 --- a/agent/tools/utils/__init__.py +++ b/agent/tools/utils/__init__.py @@ -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' ] diff --git a/agent/tools/utils/url_safety.py b/agent/tools/utils/url_safety.py new file mode 100644 index 00000000..59e6bddb --- /dev/null +++ b/agent/tools/utils/url_safety.py @@ -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]) diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index 170d2175..bd2f3aa2 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -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: 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: """ diff --git a/agent/tools/web_fetch/web_fetch.py b/agent/tools/web_fetch/web_fetch.py index 14acfa47..a80356ce 100644 --- a/agent/tools/web_fetch/web_fetch.py +++ b/agent/tools/web_fetch/web_fetch.py @@ -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}") diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index fc8c2756..e0b54f88 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -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 diff --git a/bridge/agent_initializer.py b/bridge/agent_initializer.py index 857c0f92..4bbd4b2c 100644 --- a/bridge/agent_initializer.py +++ b/bridge/agent_initializer.py @@ -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:") 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:") 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:") 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", diff --git a/channel/web/chat.html b/channel/web/chat.html index 97fe61ce..be7379ec 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -1009,6 +1009,14 @@

定时任务

查看和管理定时任务

+
+ +
@@ -1290,6 +1298,180 @@
+ + + diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index c24b3dd5..3051976f 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -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; diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index d14bc531..b25b088a 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -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 = ` ${escapeHtml(title)} + @@ -3708,6 +3766,9 @@ function _fetchSessionPage(page, clear, onDone) { item.innerHTML = ` ${escapeHtml(title)} + @@ -3826,6 +3887,84 @@ function switchSession(newSessionId) { if (currentView !== 'chat') navigateTo('chat'); } +// In-place rename a session title: replace the title with an , +// commit on Enter/blur, cancel on Escape. Persists via PUT /api/sessions/. +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: 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' - ? `${escapeHtml(task.cron || '')}` - : `${escapeHtml(task.type || 'once')}`; + card.dataset.taskId = task.id; + if (!isEnabled) card.classList.add('opacity-50'); + const schedule = task.schedule || {}; + let typeLabel = ''; + if (schedule.type === 'cron') { + typeLabel = `${escapeHtml(schedule.expression || '')}`; + } 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 = `${intervalText.join(' ')}`; + } else { + typeLabel = `${escapeHtml(schedule.type || 'once')}`; + } 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 = `
- + ${escapeHtml(task.name || task.id || '--')}
${typeLabel}
-

${escapeHtml(task.prompt || task.description || '')}

+

${escapeHtml(taskContent)}

${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun} +
+
`; + 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(); +}); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 04a0bf70..e877c2a6 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -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: 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: 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}") diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py index ebc1104b..9ecbbefe 100644 --- a/channel/wecom_bot/wecom_bot_channel.py +++ b/channel/wecom_bot/wecom_bot_channel.py @@ -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) diff --git a/channel/wecom_bot/wecom_bot_crypt.py b/channel/wecom_bot/wecom_bot_crypt.py new file mode 100644 index 00000000..262ca3f1 --- /dev/null +++ b/channel/wecom_bot/wecom_bot_crypt.py @@ -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) diff --git a/channel/wecom_bot/wecom_bot_message.py b/channel/wecom_bot/wecom_bot_message.py index 16b7dfdc..f021a24f 100644 --- a/channel/wecom_bot/wecom_bot_message.py +++ b/channel/wecom_bot/wecom_bot_message.py @@ -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") diff --git a/cli/VERSION b/cli/VERSION index 3e3c2f1e..eca07e4c 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -2.1.1 +2.1.2 diff --git a/common/cloud_client.py b/common/cloud_client.py index c66eecb5..79d9b248 100644 --- a/common/cloud_client.py +++ b/common/cloud_client.py @@ -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"), diff --git a/common/const.py b/common/const.py index 4a28c226..6f2478b0 100644 --- a/common/const.py +++ b/common/const.py @@ -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, diff --git a/config.py b/config.py index 6d81f5ff..baf8e0b2 100644 --- a/config.py +++ b/config.py @@ -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:". - # 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) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6e5dfde7..7e5c4e6b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/docs/channels/wecom-bot.mdx b/docs/channels/wecom-bot.mdx index 2cb51fff..93da5156 100644 --- a/docs/channels/wecom-bot.mdx +++ b/docs/channels/wecom-bot.mdx @@ -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. + + A **webhook (HTTP callback) mode** is also supported: when creating the bot, choose **Use URL callback**, set the receive-message URL to `http(s)://: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 +} +``` + + ## 2. Supported features | Feature | Status | diff --git a/docs/docs.json b/docs/docs.json index c5c27837..cccce886 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/ja/README.md b/docs/ja/README.md index 1ae5cb84..7f0ec06e 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -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)。 diff --git a/docs/ja/channels/wecom-bot.mdx b/docs/ja/channels/wecom-bot.mdx index 9a5d1958..4e463e28 100644 --- a/docs/ja/channels/wecom-bot.mdx +++ b/docs/ja/channels/wecom-bot.mdx @@ -52,6 +52,20 @@ WeCom AI Bot を介して CowAgent を接続し、ダイレクトメッセージ 設定後、プログラムを起動します。ログに `[WecomBot] Subscribe success` と表示されれば接続成功です。 + + ロングコネクションのほかに、**Webhook(HTTP コールバック)モード**にも対応しています。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 +} +``` + + ## 3. 対応機能 | 機能 | 状態 | diff --git a/docs/ja/models/glm.mdx b/docs/ja/models/glm.mdx index b8ace28f..bf3fb7be 100644 --- a/docs/ja/models/glm.mdx +++ b/docs/ja/models/glm.mdx @@ -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 ツールは自動的にこのモデルを使用するため、設定ファイルで明示的に指定する必要はありません。 ## 音声認識 diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx index 2e25171e..0ded47d7 100644 --- a/docs/ja/models/index.mdx +++ b/docs/ja/models/index.mdx @@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。 - 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 で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。 @@ -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) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | | diff --git a/docs/ja/models/kimi.mdx b/docs/ja/models/kimi.mdx index ce09efea..e6092187 100644 --- a/docs/ja/models/kimi.mdx +++ b/docs/ja/models/kimi.mdx @@ -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` | diff --git a/docs/ja/releases/overview.mdx b/docs/ja/releases/overview.mdx index 355165ab..c9cada76 100644 --- a/docs/ja/releases/overview.mdx +++ b/docs/ja/releases/overview.mdx @@ -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 など)、デプロイ・セキュリティ強化 | diff --git a/docs/ja/releases/v2.1.2.mdx b/docs/ja/releases/v2.1.2.mdx new file mode 100644 index 00000000..ef24b442 --- /dev/null +++ b/docs/ja/releases/v2.1.2.mdx @@ -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) diff --git a/docs/memory/self-evolution.mdx b/docs/memory/self-evolution.mdx index ca9342a8..7e1a821f 100644 --- a/docs/memory/self-evolution.mdx +++ b/docs/memory/self-evolution.mdx @@ -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. + + 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/). + + > 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. diff --git a/docs/models/glm.mdx b/docs/models/glm.mdx index 473a805c..63f8d0a6 100644 --- a/docs/models/glm.mdx +++ b/docs/models/glm.mdx @@ -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) diff --git a/docs/models/index.mdx b/docs/models/index.mdx index f99acd6f..54543249 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/models/kimi.mdx b/docs/models/kimi.mdx index 3292a976..193124ba 100644 --- a/docs/models/kimi.mdx +++ b/docs/models/kimi.mdx @@ -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` | diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx index 6f0542fd..2b14e930 100644 --- a/docs/releases/overview.mdx +++ b/docs/releases/overview.mdx @@ -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 | diff --git a/docs/releases/v2.1.2.mdx b/docs/releases/v2.1.2.mdx new file mode 100644 index 00000000..45e22a1b --- /dev/null +++ b/docs/releases/v2.1.2.mdx @@ -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) diff --git a/docs/zh/README.md b/docs/zh/README.md index c57332b3..94dea68f 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -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) diff --git a/docs/zh/channels/wecom-bot.mdx b/docs/zh/channels/wecom-bot.mdx index 7275639f..5d4f7536 100644 --- a/docs/zh/channels/wecom-bot.mdx +++ b/docs/zh/channels/wecom-bot.mdx @@ -69,6 +69,20 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式) 日志显示 `[WecomBot] Subscribe success` 即表示连接成功。 + + 除长连接外,也支持**回调(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 +} +``` + + ## 二、功能说明 | 功能 | 支持情况 | diff --git a/docs/zh/memory/self-evolution.mdx b/docs/zh/memory/self-evolution.mdx index ed21d677..6136069a 100644 --- a/docs/zh/memory/self-evolution.mdx +++ b/docs/zh/memory/self-evolution.mdx @@ -9,6 +9,10 @@ description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处 自主进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。在每段对话告一段落后,它会自动"回头复盘"一次:把使用中暴露的问题修进技能、把没做完的事情接着推进,并把值得记住的沉淀进记忆与知识库。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,当真正做了事情时才会主动地告诉你。 + + 想了解自进化机制完整的架构设计与工程实现,可阅读博客文章:[让 Agent 在对话中成长:自进化机制的五层实现](https://cowagent.ai/zh/blog/self-evolution/)。 + + > 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 diff --git a/docs/zh/models/glm.mdx b/docs/zh/models/glm.mdx index ad5f8fd3..e6e7bd1c 100644 --- a/docs/zh/models/glm.mdx +++ b/docs/zh/models/glm.mdx @@ -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 工具会自动使用该模型,无需在配置文件中显式指定。 ## 语音识别 diff --git a/docs/zh/models/index.mdx b/docs/zh/models/index.mdx index 0a1e0910..13ea923b 100644 --- a/docs/zh/models/index.mdx +++ b/docs/zh/models/index.mdx @@ -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+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/zh/models/kimi.mdx b/docs/zh/models/kimi.mdx index beb5beaf..776c2dc8 100644 --- a/docs/zh/models/kimi.mdx +++ b/docs/zh/models/kimi.mdx @@ -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` | diff --git a/docs/zh/releases/overview.mdx b/docs/zh/releases/overview.mdx index d9263da1..b3eeb3c4 100644 --- a/docs/zh/releases/overview.mdx +++ b/docs/zh/releases/overview.mdx @@ -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 等)、部署安全加固 | diff --git a/docs/zh/releases/v2.1.2.mdx b/docs/zh/releases/v2.1.2.mdx new file mode 100644 index 00000000..b45a0f37 --- /dev/null +++ b/docs/zh/releases/v2.1.2.mdx @@ -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 控制台围绕「可视化管理」做了多项增强,让更多配置无需改文件即可在界面完成: + +- **定时任务管理**:支持在控制台直接查看、编辑、启用/停用、删除任意定时任务,任务列表按启用状态与下次执行时间排序。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) diff --git a/models/custom_provider.py b/models/custom_provider.py index f6db2d34..5a2e11dd 100644 --- a/models/custom_provider.py +++ b/models/custom_provider.py @@ -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 diff --git a/models/moonshot/moonshot_bot.py b/models/moonshot/moonshot_bot.py index a2cbbe7f..fd3fb706 100644 --- a/models/moonshot/moonshot_bot.py +++ b/models/moonshot/moonshot_bot.py @@ -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: diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index 5ee25cf9..74df4799 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -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:") 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 diff --git a/run.sh b/run.sh index 769273b5..c7054298 100755 --- a/run.sh +++ b/run.sh @@ -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). diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 5b6c1daa..8d63f8eb 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -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 + } } } diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index fcd5dceb..d80f2a34 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -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( diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py index 9a47b564..48d666a1 100644 --- a/tests/test_custom_provider_handlers.py +++ b/tests/test_custom_provider_handlers.py @@ -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") diff --git a/tests/test_security_ssrf_browser_navigate.py b/tests/test_security_ssrf_browser_navigate.py new file mode 100644 index 00000000..f36e42ae --- /dev/null +++ b/tests/test_security_ssrf_browser_navigate.py @@ -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() diff --git a/tests/test_security_ssrf_web_fetch.py b/tests/test_security_ssrf_web_fetch.py new file mode 100644 index 00000000..c4c4d319 --- /dev/null +++ b/tests/test_security_ssrf_web_fetch.py @@ -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"internalsecret"): + """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:/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"Hiok" + ok.text = "Hiok" + 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()