mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01373465b0 | ||
|
|
3b3ef715bb | ||
|
|
47b2bf9d46 | ||
|
|
ea47f3097e | ||
|
|
70c1c44d15 | ||
|
|
e3dce45b2a | ||
|
|
3bb8ec3bea | ||
|
|
35e42a3ad6 | ||
|
|
949575ad14 | ||
|
|
1c34f0f03d | ||
|
|
eed2eab014 | ||
|
|
381cbed4fd | ||
|
|
4cde25325d | ||
|
|
8d70af5e89 | ||
|
|
b3408d8e5f | ||
|
|
e74906fbec | ||
|
|
52209217fc | ||
|
|
018493a60b | ||
|
|
630373b1f0 | ||
|
|
dec31dfd75 | ||
|
|
2397ea019e | ||
|
|
77da90e316 | ||
|
|
18ce17d21a | ||
|
|
ce09b14836 | ||
|
|
e7a069b060 | ||
|
|
6e3933be30 | ||
|
|
e2cb9e11b0 | ||
|
|
d281a34c6f | ||
|
|
c97cf5610f | ||
|
|
ab674a3517 | ||
|
|
7d63e7d8fa | ||
|
|
6538843bdf | ||
|
|
bd5fede122 | ||
|
|
047fb57630 | ||
|
|
583c1de5ba | ||
|
|
c9c293f67c | ||
|
|
561631baba | ||
|
|
80d0a6aeb2 | ||
|
|
6b5ee245ae | ||
|
|
5c43c2f519 | ||
|
|
9387980e74 | ||
|
|
075d9fc608 | ||
|
|
63bfab03f6 | ||
|
|
1d7e6b3703 | ||
|
|
ad64e17a34 | ||
|
|
0092376c07 | ||
|
|
6fb19a68b5 | ||
|
|
d5427d967a | ||
|
|
7fd30b608c | ||
|
|
830b05f243 | ||
|
|
e85290cddc | ||
|
|
1940d628a8 | ||
|
|
cffa590d3e | ||
|
|
402e2bfee0 | ||
|
|
f5caba81d6 | ||
|
|
354350dec9 | ||
|
|
0513298f57 | ||
|
|
08e23e5bd8 | ||
|
|
e812c7d29a | ||
|
|
ef46199346 | ||
|
|
7c9ea62993 |
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Windows Bash Streaming Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "agent/tools/bash/bash.py"
|
||||||
|
- "tests/test_bash_streaming.py"
|
||||||
|
- ".github/workflows/test-windows-bash.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-bash-tests:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install pytest
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run Windows Bash streaming tests
|
||||||
|
run: python -m pytest tests/test_bash_streaming.py -v
|
||||||
10
README.md
10
README.md
@@ -48,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
|||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
|
||||||
|
|
||||||
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
|
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
|
||||||
|
|
||||||
@@ -103,14 +103,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
|
|
||||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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 | ✅ | ✅ | ✅ | | | ✅ |
|
| [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 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
| [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
|
## 🏷 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.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).
|
> **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).
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class ChatService:
|
|||||||
agent.model.channel_type = channel_type or ""
|
agent.model.channel_type = channel_type or ""
|
||||||
agent.model.session_id = session_id 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 shared between the event callback and this method
|
||||||
state = _StreamState()
|
state = _StreamState()
|
||||||
|
|
||||||
@@ -199,6 +209,8 @@ class ChatService:
|
|||||||
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||||
raise
|
raise
|
||||||
finally:
|
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.
|
# Release cancel token to keep the registry bounded.
|
||||||
if session_id:
|
if session_id:
|
||||||
try:
|
try:
|
||||||
@@ -268,10 +280,68 @@ class ChatService:
|
|||||||
# Execute post-process tools
|
# Execute post-process tools
|
||||||
agent._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}")
|
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
|
@staticmethod
|
||||||
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from typing import Any
|
|||||||
# Defaults — conservative (see executor module docstring). Disabled by default
|
# Defaults — conservative (see executor module docstring). Disabled by default
|
||||||
# until release; enable via ``self_evolution_enabled``.
|
# until release; enable via ``self_evolution_enabled``.
|
||||||
DEFAULT_ENABLED = False
|
DEFAULT_ENABLED = False
|
||||||
DEFAULT_IDLE_MINUTES = 15
|
DEFAULT_IDLE_MINUTES = 10
|
||||||
DEFAULT_MIN_TURNS = 8
|
DEFAULT_MIN_TURNS = 6
|
||||||
# Max review steps for the isolated evolution agent. Kept small (not exposed as
|
# Max review steps for the isolated evolution agent. Kept small (not exposed as
|
||||||
# config): the review is meant to be cheap and focused, not a long autonomous run.
|
# config): the review is meant to be cheap and focused, not a long autonomous run.
|
||||||
DEFAULT_MAX_STEPS = 12
|
DEFAULT_MAX_STEPS = 12
|
||||||
|
|||||||
@@ -242,8 +242,9 @@ def _guard_tools(tools: list, workspace_dir: str) -> list:
|
|||||||
return guarded
|
return guarded
|
||||||
|
|
||||||
|
|
||||||
# Workspace subtrees worth watching for evolution-induced changes.
|
# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is
|
||||||
_WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output")
|
# watched too: evolution may rarely refine the assistant's persona/style there.
|
||||||
|
_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output")
|
||||||
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
|
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
|
||||||
# dream diary, none of which count as a user-facing change signal.
|
# dream diary, none of which count as a user-facing change signal.
|
||||||
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
|
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
|
||||||
@@ -381,7 +382,11 @@ def run_evolution_for_session(
|
|||||||
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
|
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
|
||||||
datetime.now().strftime("%Y-%m-%d") + ".md"
|
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||||
)
|
)
|
||||||
backup_files = [Path(memory_file), today_daily]
|
# AGENT.md (persona) is backed up too so a rare persona edit is undoable.
|
||||||
|
# Persona is workspace-global (not per-user): it always lives at the
|
||||||
|
# workspace root, regardless of user_id.
|
||||||
|
agent_file = Path(workspace_dir) / "AGENT.md"
|
||||||
|
backup_files = [Path(memory_file), today_daily, agent_file]
|
||||||
if skills_dir.exists():
|
if skills_dir.exists():
|
||||||
for skill_md in skills_dir.rglob("SKILL.md"):
|
for skill_md in skills_dir.rglob("SKILL.md"):
|
||||||
# The skill dir is the SKILL.md's parent (or an ancestor for
|
# The skill dir is the SKILL.md's parent (or an ancestor for
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ them. When their signal is clear, act; do not be shy here.
|
|||||||
short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the
|
short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the
|
||||||
daily file is the safe place — but first ask whether this really belongs
|
daily file is the safe place — but first ask whether this really belongs
|
||||||
in a skill.
|
in a skill.
|
||||||
|
- PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal
|
||||||
|
about the assistant's own identity/personality/style, make a small edit to
|
||||||
|
AGENT.md; never for user/world facts, and when in doubt do nothing.
|
||||||
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
|
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
|
||||||
conversation, never copy what the main assistant already recorded.
|
conversation, never copy what the main assistant already recorded.
|
||||||
- If it is already captured anywhere (check MEMORY.md AND the daily file
|
- If it is already captured anywhere (check MEMORY.md AND the daily file
|
||||||
@@ -127,17 +130,11 @@ them. When their signal is clear, act; do not be shy here.
|
|||||||
|
|
||||||
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
|
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
|
||||||
- Otherwise, after performing the edits, output a short user-facing summary in
|
- 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
|
everyday words — NOT a developer report. No need to expose internal details
|
||||||
(file names/paths, system mechanics, etc.). Tell the user, briefly:
|
(file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass,
|
||||||
1) 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
|
||||||
2) what you learned and what you changed in THIS pass ("remembered X" /
|
the user know they can undo it.
|
||||||
"improved the <name> skill" / "finished <task>").
|
|
||||||
Keep it to 1-3 lines. Generic shape (do not copy domain words):
|
|
||||||
"I just did a self-learning pass.
|
|
||||||
- Learned: <what you learned>
|
|
||||||
- Changed: <remembered it / improved the <name> skill / finished <task>>
|
|
||||||
Reply 'undo the last learning' if this is wrong."
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +151,11 @@ def build_review_user_message(transcript: str, protected_skills: list = None) ->
|
|||||||
"\n\nPROTECTED skills (built-in — never edit these): "
|
"\n\nPROTECTED skills (built-in — never edit these): "
|
||||||
f"{names}\n"
|
f"{names}\n"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from common import i18n
|
||||||
|
lang_name = "中文" if i18n.is_zh() else "English"
|
||||||
|
except Exception:
|
||||||
|
lang_name = "中文"
|
||||||
return (
|
return (
|
||||||
"Here is the conversation transcript that just went idle. Review it per "
|
"Here is the conversation transcript that just went idle. Review it per "
|
||||||
"your instructions. Acting is the exception: the main value is fixing or "
|
"your instructions. Acting is the exception: the main value is fixing or "
|
||||||
@@ -161,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 "
|
"rare last resorts — stay [SILENT] unless there is a clear, durable signal "
|
||||||
"not already covered."
|
"not already covered."
|
||||||
f"{protected_note}\n"
|
f"{protected_note}\n"
|
||||||
|
f"The summary should preferably be written in: {lang_name}\n"
|
||||||
"<transcript>\n"
|
"<transcript>\n"
|
||||||
f"{transcript}\n"
|
f"{transcript}\n"
|
||||||
"</transcript>"
|
"</transcript>"
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
|
|||||||
pass
|
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:
|
def start_evolution_trigger(agent_bridge) -> None:
|
||||||
"""Start the idle-scan thread once per process (idempotent)."""
|
"""Start the idle-scan thread once per process (idempotent)."""
|
||||||
if getattr(agent_bridge, "_evolution_trigger_started", False):
|
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())
|
sessions = list(getattr(agent_bridge, "agents", {}).items())
|
||||||
for session_id, agent in sessions:
|
for session_id, agent in sessions:
|
||||||
try:
|
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)
|
last_active = getattr(agent, "_evo_last_active", 0)
|
||||||
turns = int(getattr(agent, "_evo_turns", 0))
|
turns = int(getattr(agent, "_evo_turns", 0))
|
||||||
# Enough signal = enough turns OR enough context pressure.
|
# Enough signal = enough turns OR enough context pressure.
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root):
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Iterable
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
|
from agent.memory.config import MemoryConfig
|
||||||
|
from agent.memory.manager import MemoryManager
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeService:
|
class KnowledgeService:
|
||||||
@@ -25,9 +30,189 @@ class KnowledgeService:
|
|||||||
Operates directly on the filesystem.
|
Operates directly on the filesystem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, workspace_root: str):
|
PROTECTED_FILES = {"index.md", "log.md"}
|
||||||
self.workspace_root = workspace_root
|
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||||
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
|
|
||||||
|
def __init__(self, workspace_root: str, memory_manager=None):
|
||||||
|
self.workspace_root = os.path.abspath(workspace_root)
|
||||||
|
self.knowledge_dir = os.path.join(self.workspace_root, "knowledge")
|
||||||
|
self._memory_manager = memory_manager
|
||||||
|
|
||||||
|
def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None,
|
||||||
|
allow_missing: bool = True) -> tuple:
|
||||||
|
if not isinstance(rel_path, str) or not rel_path.strip():
|
||||||
|
raise ValueError("path is required")
|
||||||
|
rel_path = rel_path.replace("\\", "/").strip("/")
|
||||||
|
parts = rel_path.split("/")
|
||||||
|
if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts):
|
||||||
|
raise ValueError("invalid path")
|
||||||
|
if kind == "document" and not rel_path.lower().endswith(".md"):
|
||||||
|
raise ValueError("document path must end with .md")
|
||||||
|
|
||||||
|
root = Path(self.knowledge_dir).resolve()
|
||||||
|
candidate = root.joinpath(*parts)
|
||||||
|
# Resolve the nearest existing ancestor so a symlink cannot be used
|
||||||
|
# to escape when the final destination does not exist yet.
|
||||||
|
ancestor = candidate
|
||||||
|
while not ancestor.exists() and ancestor != root:
|
||||||
|
ancestor = ancestor.parent
|
||||||
|
try:
|
||||||
|
ancestor.resolve().relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("path outside knowledge dir")
|
||||||
|
if candidate.exists():
|
||||||
|
try:
|
||||||
|
candidate.resolve().relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("path outside knowledge dir")
|
||||||
|
elif not allow_missing:
|
||||||
|
raise FileNotFoundError(f"path not found: {rel_path}")
|
||||||
|
return rel_path, candidate
|
||||||
|
|
||||||
|
def _ensure_not_protected(self, rel_path: str):
|
||||||
|
if rel_path in self.PROTECTED_FILES:
|
||||||
|
raise ValueError(f"protected knowledge file: {rel_path}")
|
||||||
|
|
||||||
|
def _manager(self):
|
||||||
|
if self._memory_manager is None:
|
||||||
|
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root))
|
||||||
|
return self._memory_manager
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_sync(coro):
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro)
|
||||||
|
result = []
|
||||||
|
error = []
|
||||||
|
|
||||||
|
def runner():
|
||||||
|
try:
|
||||||
|
result.append(asyncio.run(coro))
|
||||||
|
except Exception as exc:
|
||||||
|
error.append(exc)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=runner)
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
if error:
|
||||||
|
raise error[0]
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def _sync_index(self, old_paths: Iterable[str]):
|
||||||
|
old_paths = sorted(set(old_paths))
|
||||||
|
if not old_paths:
|
||||||
|
return
|
||||||
|
manager = self._manager()
|
||||||
|
for rel_path in old_paths:
|
||||||
|
manager.storage.delete_by_path(f"knowledge/{rel_path}")
|
||||||
|
manager.mark_dirty()
|
||||||
|
self._run_sync(manager.sync())
|
||||||
|
|
||||||
|
def create_category(self, path: str) -> dict:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||||
|
if full_path.exists():
|
||||||
|
return {"path": rel_path, "created": False, "reason": "already_exists"}
|
||||||
|
full_path.mkdir(parents=True)
|
||||||
|
return {"path": rel_path, "created": True}
|
||||||
|
|
||||||
|
def rename_category(self, path: str, new_path: str) -> dict:
|
||||||
|
old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False)
|
||||||
|
new_rel, new_full = self._resolve_path(new_path, kind="category")
|
||||||
|
if not old_full.is_dir():
|
||||||
|
raise ValueError(f"not a category: {old_rel}")
|
||||||
|
if new_full.exists():
|
||||||
|
raise FileExistsError(f"target already exists: {new_rel}")
|
||||||
|
old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/")
|
||||||
|
for p in old_full.rglob("*.md") if p.is_file()]
|
||||||
|
new_full.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
old_full.rename(new_full)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"}
|
||||||
|
except FileExistsError:
|
||||||
|
raise FileExistsError(f"target already exists: {new_rel}")
|
||||||
|
old_paths = [f"{old_rel}/{p}" for p in old_documents]
|
||||||
|
self._sync_index(old_paths)
|
||||||
|
return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)}
|
||||||
|
|
||||||
|
def delete_category(self, path: str, confirm: bool = False) -> dict:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||||
|
if not full_path.exists():
|
||||||
|
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||||
|
if not full_path.is_dir():
|
||||||
|
raise ValueError(f"not a category: {rel_path}")
|
||||||
|
knowledge_root = Path(self.knowledge_dir).resolve()
|
||||||
|
documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/")
|
||||||
|
for p in full_path.rglob("*.md") if p.is_file()]
|
||||||
|
if any(p in self.PROTECTED_FILES for p in documents):
|
||||||
|
raise ValueError("category contains protected knowledge files")
|
||||||
|
if any(full_path.iterdir()) and not confirm:
|
||||||
|
raise ValueError("category is not empty; confirmation is required")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(full_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||||
|
self._sync_index(documents)
|
||||||
|
return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)}
|
||||||
|
|
||||||
|
def delete_documents(self, paths: Iterable[str]) -> dict:
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
raise ValueError("paths must be a list")
|
||||||
|
results = []
|
||||||
|
deleted = []
|
||||||
|
for path in paths:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||||
|
self._ensure_not_protected(rel_path)
|
||||||
|
if not full_path.exists():
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||||
|
continue
|
||||||
|
if not full_path.is_file():
|
||||||
|
raise ValueError(f"not a document: {rel_path}")
|
||||||
|
try:
|
||||||
|
full_path.unlink()
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": True})
|
||||||
|
except FileNotFoundError:
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||||
|
self._sync_index(deleted)
|
||||||
|
return {"results": results, "deleted": sum(1 for item in results if item["deleted"])}
|
||||||
|
|
||||||
|
def move_documents(self, paths: Iterable[str], target_category: str) -> dict:
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
raise ValueError("paths must be a list")
|
||||||
|
target_rel, target_full = self._resolve_path(target_category, kind="category")
|
||||||
|
if not target_full.is_dir():
|
||||||
|
raise FileNotFoundError(f"category not found: {target_rel}")
|
||||||
|
results = []
|
||||||
|
moved_old_paths = []
|
||||||
|
for path in paths:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||||
|
self._ensure_not_protected(rel_path)
|
||||||
|
if not full_path.exists():
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||||
|
continue
|
||||||
|
destination = target_full / full_path.name
|
||||||
|
new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/")
|
||||||
|
if destination.exists():
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||||
|
"target": new_rel})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.link(full_path, destination)
|
||||||
|
full_path.unlink()
|
||||||
|
moved_old_paths.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "moved": True, "target": new_rel})
|
||||||
|
except FileExistsError:
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||||
|
"target": new_rel})
|
||||||
|
except FileNotFoundError:
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||||
|
self._sync_index(moved_old_paths)
|
||||||
|
return {"results": results, "moved": len(moved_old_paths)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# list — directory tree with stats
|
# list — directory tree with stats
|
||||||
@@ -121,15 +306,8 @@ class KnowledgeService:
|
|||||||
:raises ValueError: if path is invalid or escapes knowledge dir
|
:raises ValueError: if path is invalid or escapes knowledge dir
|
||||||
:raises FileNotFoundError: if file does not exist
|
:raises FileNotFoundError: if file does not exist
|
||||||
"""
|
"""
|
||||||
if not rel_path or ".." in rel_path:
|
rel_path, full_path = self._resolve_path(rel_path, kind="document")
|
||||||
raise ValueError("invalid path")
|
if not full_path.is_file():
|
||||||
|
|
||||||
full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path))
|
|
||||||
allowed = os.path.normpath(self.knowledge_dir)
|
|
||||||
if not full_path.startswith(allowed + os.sep) and full_path != allowed:
|
|
||||||
raise ValueError("path outside knowledge dir")
|
|
||||||
|
|
||||||
if not os.path.isfile(full_path):
|
|
||||||
raise FileNotFoundError(f"file not found: {rel_path}")
|
raise FileNotFoundError(f"file not found: {rel_path}")
|
||||||
|
|
||||||
with open(full_path, "r", encoding="utf-8") as f:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
@@ -228,13 +406,26 @@ class KnowledgeService:
|
|||||||
result = self.build_graph()
|
result = self.build_graph()
|
||||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
elif action == "create_category":
|
||||||
|
result = self.create_category(payload.get("path"))
|
||||||
|
elif action == "rename_category":
|
||||||
|
result = self.rename_category(payload.get("path"), payload.get("new_path"))
|
||||||
|
elif action == "delete_category":
|
||||||
|
result = self.delete_category(payload.get("path"), payload.get("confirm", False))
|
||||||
|
elif action == "delete_documents":
|
||||||
|
result = self.delete_documents(payload.get("paths") or [])
|
||||||
|
elif action == "move_documents":
|
||||||
|
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
|
||||||
else:
|
else:
|
||||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
||||||
|
except FileExistsError as e:
|
||||||
|
return {"action": action, "code": 409, "message": str(e), "payload": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
||||||
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
|
|||||||
@@ -825,9 +825,10 @@ class MemoryStorage:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def delete_by_path(self, path: str):
|
def delete_by_path(self, path: str):
|
||||||
"""Delete all chunks from a file"""
|
"""Delete all chunks and file metadata for a path."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
||||||
|
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_file_hash(self, path: str) -> Optional[str]:
|
def get_file_hash(self, path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -462,13 +462,12 @@ class MemoryFlushManager:
|
|||||||
daily_content=daily_content or "(no recent daily records)",
|
daily_content=daily_content or "(no recent daily records)",
|
||||||
)
|
)
|
||||||
from agent.protocol.models import LLMRequest
|
from agent.protocol.models import LLMRequest
|
||||||
# Scale max_tokens based on input size to avoid truncating large MEMORY.md
|
# No output cap: the prompt already keeps MEMORY.md concise (~50
|
||||||
input_chars = len(memory_content) + len(daily_content)
|
# items), so a hard max_tokens would only risk truncating a large
|
||||||
dream_max_tokens = max(2000, min(input_chars, 8000))
|
# rewrite. Let the model use its default output budget.
|
||||||
request = LLMRequest(
|
request = LLMRequest(
|
||||||
messages=[{"role": "user", "content": user_msg}],
|
messages=[{"role": "user", "content": user_msg}],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=dream_max_tokens,
|
|
||||||
stream=False,
|
stream=False,
|
||||||
system=_dream_system_prompt(),
|
system=_dream_system_prompt(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1174,10 +1174,21 @@ class AgentStreamExecutor:
|
|||||||
# Set tool context
|
# Set tool context
|
||||||
tool.model = self.model
|
tool.model = self.model
|
||||||
tool.context = self.agent
|
tool.context = self.agent
|
||||||
|
tool.progress_callback = lambda message: self._emit_event(
|
||||||
|
"tool_execution_progress",
|
||||||
|
{
|
||||||
|
"tool_call_id": tool_id,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Execute tool
|
# Execute tool
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
result: ToolResult = tool.execute_tool(arguments)
|
try:
|
||||||
|
result: ToolResult = tool.execute_tool(arguments)
|
||||||
|
finally:
|
||||||
|
tool.progress_callback = None
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
|
||||||
result_dict = {
|
result_dict = {
|
||||||
|
|||||||
@@ -34,6 +34,27 @@ class SkillService:
|
|||||||
"""
|
"""
|
||||||
self.manager = skill_manager
|
self.manager = skill_manager
|
||||||
|
|
||||||
|
def _safe_skill_dir(self, name: str) -> str:
|
||||||
|
"""Derive and validate the skill directory path.
|
||||||
|
|
||||||
|
Ensures the resolved path stays within the custom_dir root,
|
||||||
|
preventing path traversal via names like ``../escaped``.
|
||||||
|
|
||||||
|
:raises ValueError: if the name would escape the skills root.
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
raise ValueError("skill name is required")
|
||||||
|
# Reject obvious traversal components.
|
||||||
|
if ".." in name or name.startswith("/") or name.startswith("\\"):
|
||||||
|
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
|
||||||
|
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
|
||||||
|
root = os.path.realpath(self.manager.custom_dir)
|
||||||
|
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
|
||||||
|
raise ValueError(
|
||||||
|
f"skill name {name!r} resolves outside the skills directory"
|
||||||
|
)
|
||||||
|
return skill_dir
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# query
|
# query
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -107,7 +128,7 @@ class SkillService:
|
|||||||
if not files:
|
if not files:
|
||||||
raise ValueError("skill files list is empty")
|
raise ValueError("skill files list is empty")
|
||||||
|
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
|
|
||||||
tmp_dir = skill_dir + ".tmp"
|
tmp_dir = skill_dir + ".tmp"
|
||||||
if os.path.exists(tmp_dir):
|
if os.path.exists(tmp_dir):
|
||||||
@@ -146,7 +167,7 @@ class SkillService:
|
|||||||
raise ValueError("package url is required")
|
raise ValueError("package url is required")
|
||||||
|
|
||||||
url = files[0]["url"]
|
url = files[0]["url"]
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
zip_path = os.path.join(tmp_dir, "package.zip")
|
zip_path = os.path.join(tmp_dir, "package.zip")
|
||||||
@@ -217,7 +238,7 @@ class SkillService:
|
|||||||
if not name:
|
if not name:
|
||||||
raise ValueError("skill name is required")
|
raise ValueError("skill name is required")
|
||||||
|
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
if os.path.exists(skill_dir):
|
if os.path.exists(skill_dir):
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(skill_dir)
|
||||||
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ class BaseTool:
|
|||||||
description: str = "Base tool"
|
description: str = "Base tool"
|
||||||
params: dict = {} # Store JSON Schema
|
params: dict = {} # Store JSON Schema
|
||||||
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
||||||
|
progress_callback = None
|
||||||
|
|
||||||
|
def report_progress(self, message: str):
|
||||||
|
callback = getattr(self, "progress_callback", None)
|
||||||
|
if not callback:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(str(message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[{self.name}] progress callback failed: {e}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_json_schema(cls) -> dict:
|
def get_json_schema(cls) -> dict:
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ Bash tool - Execute bash commands
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
@@ -19,6 +22,10 @@ class Bash(BaseTool):
|
|||||||
"""Tool for executing bash commands"""
|
"""Tool for executing bash commands"""
|
||||||
|
|
||||||
_IS_WIN = sys.platform == "win32"
|
_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"
|
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.
|
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.
|
||||||
@@ -106,25 +113,35 @@ SAFETY:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
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
|
# On Windows, convert $VAR references to %VAR% for cmd.exe
|
||||||
if self._IS_WIN:
|
if self._IS_WIN:
|
||||||
env["PYTHONIOENCODING"] = "utf-8"
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
command = self._convert_env_vars_for_windows(command, dotenv_vars)
|
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"):
|
if command and not command.strip().lower().startswith("chcp"):
|
||||||
command = f"chcp 65001 >nul 2>&1 && {command}"
|
command = f"chcp 65001 >nul 2>&1 && {command}"
|
||||||
|
|
||||||
result = subprocess.run(
|
try:
|
||||||
command,
|
result = self._run_streaming(
|
||||||
shell=True,
|
command,
|
||||||
cwd=self.cwd,
|
timeout,
|
||||||
stdout=subprocess.PIPE,
|
env,
|
||||||
stderr=subprocess.PIPE,
|
dotenv_vars,
|
||||||
text=True,
|
)
|
||||||
encoding="utf-8",
|
finally:
|
||||||
errors="replace",
|
if temp_script_path:
|
||||||
timeout=timeout,
|
try:
|
||||||
env=env,
|
os.remove(temp_script_path)
|
||||||
)
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.debug(f"[Bash] Exit code: {result.returncode}")
|
logger.debug(f"[Bash] Exit code: {result.returncode}")
|
||||||
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
||||||
@@ -236,6 +253,105 @@ SAFETY:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error executing command: {str(e)}")
|
return ToolResult.fail(f"Error executing command: {str(e)}")
|
||||||
|
|
||||||
|
def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict):
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
cwd=self.cwd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
start_new_session=not self._IS_WIN,
|
||||||
|
)
|
||||||
|
stdout_chunks, stderr_chunks = [], []
|
||||||
|
recent = bytearray()
|
||||||
|
recent_lock = threading.Lock()
|
||||||
|
|
||||||
|
def drain(stream, chunks):
|
||||||
|
while True:
|
||||||
|
chunk = os.read(stream.fileno(), 4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
with recent_lock:
|
||||||
|
recent.extend(chunk)
|
||||||
|
if len(recent) > self._PROGRESS_MAX_BYTES:
|
||||||
|
del recent[:-self._PROGRESS_MAX_BYTES]
|
||||||
|
|
||||||
|
readers = [
|
||||||
|
threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True),
|
||||||
|
threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True),
|
||||||
|
]
|
||||||
|
for reader in readers:
|
||||||
|
reader.start()
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
last_reported_at = started
|
||||||
|
last_snapshot = None
|
||||||
|
try:
|
||||||
|
while process.poll() is None:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - started
|
||||||
|
if elapsed >= timeout:
|
||||||
|
self._kill_process(process)
|
||||||
|
raise subprocess.TimeoutExpired(command, timeout)
|
||||||
|
if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL:
|
||||||
|
with recent_lock:
|
||||||
|
snapshot = bytes(recent).decode("utf-8", errors="replace")
|
||||||
|
snapshot = self._redact_progress(snapshot, dotenv_vars)
|
||||||
|
if snapshot and snapshot != last_snapshot:
|
||||||
|
self.report_progress(snapshot)
|
||||||
|
last_snapshot = snapshot
|
||||||
|
last_reported_at = now
|
||||||
|
time.sleep(0.1)
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
self._kill_process(process)
|
||||||
|
process.wait()
|
||||||
|
join_deadline = time.monotonic() + 5
|
||||||
|
for reader in readers:
|
||||||
|
reader.join(timeout=max(0, join_deadline - time.monotonic()))
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=process.returncode,
|
||||||
|
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
|
||||||
|
stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _kill_process(self, process):
|
||||||
|
if self._IS_WIN:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 and process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except (PermissionError, ProcessLookupError):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redact_progress(text: str, dotenv_vars: dict) -> str:
|
||||||
|
text = re.sub(
|
||||||
|
r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+',
|
||||||
|
lambda match: f"{match.group(1)}=[REDACTED]",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
for value in dotenv_vars.values():
|
||||||
|
value = str(value or "")
|
||||||
|
if len(value) >= 6:
|
||||||
|
text = text.replace(value, "[REDACTED]")
|
||||||
|
return text
|
||||||
|
|
||||||
def _get_safety_warning(self, command: str) -> str:
|
def _get_safety_warning(self, command: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get safety warning for absolutely catastrophic commands only.
|
Get safety warning for absolutely catastrophic commands only.
|
||||||
@@ -293,3 +409,43 @@ SAFETY:
|
|||||||
return m.group(0)
|
return m.group(0)
|
||||||
|
|
||||||
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
|
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rewrite_long_python_c(command: str):
|
||||||
|
"""
|
||||||
|
Rewrite `python -c "<code>"` into `python <tempfile>` to bypass the
|
||||||
|
cmd.exe command line length limit on Windows.
|
||||||
|
|
||||||
|
Returns (new_command, temp_file_path). On any parse failure the original
|
||||||
|
command and None are returned, so behavior is unchanged when unmatched.
|
||||||
|
"""
|
||||||
|
# Match: <python|python3|py> [flags] -c "<code>" (single or double quoted)
|
||||||
|
m = re.search(
|
||||||
|
r'^(?P<prefix>.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)'
|
||||||
|
r'(?P<quote>["\'])(?P<code>.*)(?P=quote)\s*(?P<suffix>.*)$',
|
||||||
|
command,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
quote = m.group("quote")
|
||||||
|
code = m.group("code")
|
||||||
|
# Reverse common shell-level escaping of the quote char inside the code.
|
||||||
|
code = code.replace("\\" + quote, quote)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(code)
|
||||||
|
except OSError:
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
prefix = m.group("prefix")
|
||||||
|
# Drop the trailing "-c " from the prefix, keep the interpreter + flags.
|
||||||
|
interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip()
|
||||||
|
suffix = m.group("suffix").strip()
|
||||||
|
new_command = f'{interp} "{path}"'
|
||||||
|
if suffix:
|
||||||
|
new_command += f' {suffix}'
|
||||||
|
return new_command, path
|
||||||
|
|||||||
@@ -182,8 +182,15 @@ class TaskStore:
|
|||||||
if enabled_only:
|
if enabled_only:
|
||||||
task_list = [t for t in task_list if t.get("enabled", True)]
|
task_list = [t for t in task_list if t.get("enabled", True)]
|
||||||
|
|
||||||
# Sort by next_run_at
|
# Sort by enabled status (enabled first), then by next_run_at
|
||||||
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
|
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
|
return task_list
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ from .diff import (
|
|||||||
FuzzyMatchResult
|
FuzzyMatchResult
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .url_safety import (
|
||||||
|
validate_url_safe,
|
||||||
|
assert_public_ip
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'truncate_head',
|
'truncate_head',
|
||||||
'truncate_tail',
|
'truncate_tail',
|
||||||
@@ -36,5 +41,7 @@ __all__ = [
|
|||||||
'normalize_for_fuzzy_match',
|
'normalize_for_fuzzy_match',
|
||||||
'fuzzy_find_text',
|
'fuzzy_find_text',
|
||||||
'generate_diff_string',
|
'generate_diff_string',
|
||||||
'FuzzyMatchResult'
|
'FuzzyMatchResult',
|
||||||
|
'validate_url_safe',
|
||||||
|
'assert_public_ip'
|
||||||
]
|
]
|
||||||
|
|||||||
66
agent/tools/utils/url_safety.py
Normal file
66
agent/tools/utils/url_safety.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
|
||||||
|
|
||||||
|
A URL is only considered safe when it uses an http/https scheme, has a
|
||||||
|
hostname, that hostname resolves, and every resolved address is a public
|
||||||
|
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local
|
||||||
|
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved
|
||||||
|
addresses are rejected, for both IPv4 and IPv6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||||
|
"""Return True if the address is not safe to connect to (non-public)."""
|
||||||
|
return (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_public_ip(ip_str: str) -> None:
|
||||||
|
"""Raise ValueError if the given literal IP is a non-public address.
|
||||||
|
|
||||||
|
Used to re-validate the concrete address a redirect resolved to.
|
||||||
|
"""
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise ValueError(
|
||||||
|
f"URL resolves to a non-public address ({ip_str}), "
|
||||||
|
f"request blocked for security"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url_safe(url: str) -> None:
|
||||||
|
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||||
|
|
||||||
|
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||||
|
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||||
|
schemes, or hosts that fail DNS resolution.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the URL targets a disallowed address.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||||
|
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("URL has no hostname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Resolve all addresses for the hostname.
|
||||||
|
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror:
|
||||||
|
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||||
|
|
||||||
|
for family, _, _, _, sockaddr in addr_infos:
|
||||||
|
assert_public_ip(sockaddr[0])
|
||||||
@@ -26,13 +26,14 @@ from typing import Any, Dict, List, Optional
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from agent.tools.utils.url_safety import validate_url_safe
|
||||||
from common import const
|
from common import const
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
DEFAULT_MODEL = const.GPT_41_MINI
|
DEFAULT_MODEL = const.GPT_41_MINI
|
||||||
DEFAULT_TIMEOUT = 60
|
DEFAULT_TIMEOUT = 180
|
||||||
MAX_TOKENS = 1000
|
MAX_TOKENS = 4000
|
||||||
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
||||||
|
|
||||||
SUPPORTED_EXTENSIONS = {
|
SUPPORTED_EXTENSIONS = {
|
||||||
@@ -654,6 +655,22 @@ class Vision(BaseTool):
|
|||||||
return api_base
|
return api_base
|
||||||
return api_base.rstrip("/") + "/v1"
|
return api_base.rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
validate_url_safe(url)
|
||||||
|
|
||||||
def _build_image_content(self, image: str) -> dict:
|
def _build_image_content(self, image: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Build the image_url content block.
|
Build the image_url content block.
|
||||||
@@ -661,6 +678,7 @@ class Vision(BaseTool):
|
|||||||
so every bot backend can consume them without extra downloads.
|
so every bot backend can consume them without extra downloads.
|
||||||
"""
|
"""
|
||||||
if image.startswith(("http://", "https://")):
|
if image.startswith(("http://", "https://")):
|
||||||
|
self._validate_url_safe(image)
|
||||||
return self._download_to_data_url(image)
|
return self._download_to_data_url(image)
|
||||||
|
|
||||||
if not os.path.isfile(image):
|
if not os.path.isfile(image):
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import requests
|
|||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from agent.tools.utils.truncate import truncate_head, format_size
|
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
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
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 = {
|
DEFAULT_HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"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"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
return ToolResult.fail("Error: Invalid URL (must start with http:// or 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):
|
if _is_document_url(url):
|
||||||
return self._fetch_document(url)
|
return self._fetch_document(url)
|
||||||
|
|
||||||
return self._fetch_webpage(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 ----
|
# ---- Web page fetching ----
|
||||||
|
|
||||||
def _fetch_webpage(self, url: str) -> ToolResult:
|
def _fetch_webpage(self, url: str) -> ToolResult:
|
||||||
"""Fetch and extract readable text from an HTML web page."""
|
"""Fetch and extract readable text from an HTML web page."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = self._safe_get(url)
|
||||||
url,
|
|
||||||
headers=DEFAULT_HEADERS,
|
|
||||||
timeout=DEFAULT_TIMEOUT,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
|
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}")
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
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:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error: Failed to fetch URL: {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}")
|
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = self._safe_get(url, stream=True)
|
||||||
url,
|
|
||||||
headers=DEFAULT_HEADERS,
|
|
||||||
timeout=DEFAULT_TIMEOUT,
|
|
||||||
stream=True,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
content_length = int(response.headers.get("Content-Length", 0))
|
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}")
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
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:
|
except Exception as e:
|
||||||
self._cleanup_file(local_path)
|
self._cleanup_file(local_path)
|
||||||
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
||||||
|
|||||||
@@ -524,6 +524,15 @@ class AgentBridge:
|
|||||||
session_id, query, context, clear_history
|
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:
|
try:
|
||||||
# Use agent's run_stream method with event handler
|
# Use agent's run_stream method with event handler
|
||||||
response = agent.run_stream(
|
response = agent.run_stream(
|
||||||
@@ -533,6 +542,13 @@ class AgentBridge:
|
|||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
finally:
|
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
|
# Restore original tools
|
||||||
if context and context.get("is_scheduled_task"):
|
if context and context.get("is_scheduled_task"):
|
||||||
agent.tools = original_tools
|
agent.tools = original_tools
|
||||||
|
|||||||
@@ -519,7 +519,10 @@ class ChatChannel(Channel):
|
|||||||
def cancel_session(self, session_id):
|
def cancel_session(self, session_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if session_id in self.sessions:
|
if session_id in self.sessions:
|
||||||
for future in self.futures[session_id]:
|
# futures[session_id] is only created in consume() when a task is
|
||||||
|
# dispatched, so it may be absent if cancel happens right after
|
||||||
|
# produce() but before the first dispatch. Default to [].
|
||||||
|
for future in self.futures.get(session_id, []):
|
||||||
future.cancel()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
|
|||||||
def cancel_all_session(self):
|
def cancel_all_session(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
for session_id in self.sessions:
|
for session_id in self.sessions:
|
||||||
for future in self.futures[session_id]:
|
for future in self.futures.get(session_id, []):
|
||||||
future.cancel()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Docs Link -->
|
<!-- Docs Link -->
|
||||||
<a href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
|
<a id="docs-link" href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
|
||||||
class="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
class="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||||
cursor-pointer transition-colors duration-150" title="Documentation">
|
cursor-pointer transition-colors duration-150" title="Documentation">
|
||||||
<i class="fas fa-book text-base"></i>
|
<i class="fas fa-book text-base"></i>
|
||||||
@@ -840,6 +840,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
||||||
|
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
|
||||||
|
<button onclick="createKnowledgeCategory()"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
|
||||||
|
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
||||||
|
</button>
|
||||||
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
||||||
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
||||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||||
@@ -995,6 +1000,14 @@
|
|||||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="task-refresh-btn" onclick="refreshTasksView()"
|
||||||
|
class="px-3 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||||
|
text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5
|
||||||
|
text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||||
|
<i class="fas fa-refresh text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
||||||
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
||||||
@@ -1068,6 +1081,34 @@
|
|||||||
</div><!-- /main-content -->
|
</div><!-- /main-content -->
|
||||||
</div><!-- /app -->
|
</div><!-- /app -->
|
||||||
|
|
||||||
|
<!-- Knowledge Action Dialog -->
|
||||||
|
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
|
||||||
|
<i id="knowledge-dialog-icon" class="fas fa-folder text-emerald-500"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 id="knowledge-dialog-title" class="font-semibold text-slate-800 dark:text-slate-100"></h3>
|
||||||
|
<p id="knowledge-dialog-subtitle" class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
|
||||||
|
<input id="knowledge-dialog-input" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
|
||||||
|
<select id="knowledge-dialog-select"
|
||||||
|
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select>
|
||||||
|
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p>
|
||||||
|
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
|
||||||
|
<button id="knowledge-dialog-cancel" class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5">取消</button>
|
||||||
|
<button id="knowledge-dialog-submit" class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium disabled:opacity-50">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Confirm Dialog -->
|
<!-- Confirm Dialog -->
|
||||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
@@ -1165,6 +1206,240 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Provider Modal (multiple OpenAI-compatible providers) -->
|
||||||
|
<div id="custom-provider-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
|
w-full max-w-md mx-4">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-sliders text-primary-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_name">名称</label>
|
||||||
|
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
|
||||||
|
<input id="custom-provider-base" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||||
|
placeholder="https://...../v1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||||
|
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||||
|
<button id="custom-provider-modal-delete"
|
||||||
|
class="px-3 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||||
|
cursor-pointer transition-colors duration-150 hidden"
|
||||||
|
data-i18n="models_custom_delete">删除</button>
|
||||||
|
<span id="custom-provider-modal-status"
|
||||||
|
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||||
|
<button id="custom-provider-modal-cancel"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||||
|
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||||
|
hover:bg-slate-50 dark:hover:bg-white/5
|
||||||
|
cursor-pointer transition-colors duration-150"
|
||||||
|
data-i18n="cancel">取消</button>
|
||||||
|
<button id="custom-provider-modal-save"
|
||||||
|
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-i18n="save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task Edit Modal -->
|
||||||
|
<div id="task-edit-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
|
w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-clock text-primary-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base" data-i18n="task_edit_title">编辑定时任务</h3>
|
||||||
|
<p id="task-edit-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 任务名称和启用状态 -->
|
||||||
|
<div class="flex gap-4 items-end">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_name">任务名称</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-name" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors"
|
||||||
|
placeholder="任务名称">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 pb-[2px]">
|
||||||
|
<label class="text-xs font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
<span data-i18n="task_enabled">启用</span>
|
||||||
|
</label>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" id="task-edit-enabled" class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 调度类型 + 调度值 -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_schedule_type">调度类型</span>
|
||||||
|
</label>
|
||||||
|
<select id="task-edit-schedule-type"
|
||||||
|
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
appearance-none bg-no-repeat bg-right"
|
||||||
|
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||||
|
<option value="cron" data-i18n="task_schedule_cron">Cron 表达式</option>
|
||||||
|
<option value="interval" data-i18n="task_schedule_interval">固定间隔</option>
|
||||||
|
<option value="once" data-i18n="task_schedule_once">一次性任务</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron 表达式 -->
|
||||||
|
<div id="task-edit-cron-wrap">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_cron_expression">Cron 表达式</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-cron-expression" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||||
|
placeholder="0 9 * * *">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 固定间隔 -->
|
||||||
|
<div id="task-edit-interval-wrap" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_interval_seconds">间隔秒数</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-interval-seconds" type="number" min="60"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors"
|
||||||
|
placeholder="3600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 一次性任务时间 -->
|
||||||
|
<div id="task-edit-once-wrap" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_once_time">执行时间</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-once-time" type="datetime-local" step="1"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
cursor-pointer"
|
||||||
|
onclick="this.showPicker && this.showPicker()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron/Interval 提示 -->
|
||||||
|
<p id="task-edit-cron-hint" class="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
<span data-i18n="task_cron_hint">格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00</span>
|
||||||
|
</p>
|
||||||
|
<p id="task-edit-interval-hint" class="text-xs text-slate-400 dark:text-slate-500 hidden">
|
||||||
|
<span data-i18n="task_interval_hint">最小 60 秒,例如 3600 表示每小时执行一次</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 动作类型 + 通道类型 -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_action_type">动作类型</span>
|
||||||
|
</label>
|
||||||
|
<select id="task-edit-action-type"
|
||||||
|
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
appearance-none bg-no-repeat bg-right"
|
||||||
|
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||||
|
<option value="send_message" data-i18n="task_action_send_message">发送消息</option>
|
||||||
|
<option value="agent_task" data-i18n="task_action_agent_task">AI 任务</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_channel_type">通道类型</span>
|
||||||
|
</label>
|
||||||
|
<select id="task-edit-channel-type"
|
||||||
|
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
appearance-none bg-no-repeat bg-right
|
||||||
|
disabled:opacity-100 disabled:text-slate-800 dark:disabled:text-slate-300 disabled:bg-slate-100 dark:disabled:bg-[#252525] disabled:cursor-not-allowed"
|
||||||
|
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||||
|
<span data-i18n="task_channel_hint">选择定时消息发送的通道</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 隐藏的接收者ID字段(自动填充) -->
|
||||||
|
<input id="task-edit-receiver" type="hidden" value="">
|
||||||
|
|
||||||
|
<!-- 消息内容/任务描述 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span id="task-edit-content-label" data-i18n="task_message_content">消息内容</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="task-edit-content" rows="3"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors resize-none"
|
||||||
|
placeholder="输入消息内容或任务描述"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||||
|
<button id="task-edit-modal-delete"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||||
|
cursor-pointer transition-colors duration-150 hidden"
|
||||||
|
data-i18n="task_delete_btn">删除任务</button>
|
||||||
|
<span id="task-edit-modal-status"
|
||||||
|
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||||
|
<button id="task-edit-modal-cancel"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||||
|
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||||
|
hover:bg-slate-50 dark:hover:bg-white/5
|
||||||
|
cursor-pointer transition-colors duration-150"
|
||||||
|
data-i18n="cancel">取消</button>
|
||||||
|
<button id="task-edit-modal-save"
|
||||||
|
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-i18n="save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script defer src="assets/js/console.js"></script>
|
<script defer src="assets/js/console.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -244,6 +244,52 @@
|
|||||||
}
|
}
|
||||||
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
.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 */
|
||||||
.context-divider {
|
.context-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -605,6 +651,18 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.tool-error-text { color: #f87171; }
|
.tool-error-text { color: #f87171; }
|
||||||
|
.tool-live-output:empty { display: none; }
|
||||||
|
.tool-streaming .tool-live-output:not(:empty)::after {
|
||||||
|
content: ' ';
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.45em;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 0.15em;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
background: currentColor;
|
||||||
|
animation: tool-cursor-blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes tool-cursor-blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
/* Log level highlighting */
|
/* Log level highlighting */
|
||||||
.log-line { display: block; }
|
.log-line { display: block; }
|
||||||
@@ -854,6 +912,14 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* "Custom" row is an add-new action: trailing + instead of ✓. */
|
||||||
|
.vendor-picker-add-mark {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Chat Input */
|
/* Chat Input */
|
||||||
#chat-input {
|
#chat-input {
|
||||||
@@ -1191,6 +1257,34 @@
|
|||||||
background: #EDFDF3;
|
background: #EDFDF3;
|
||||||
color: #228547;
|
color: #228547;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.knowledge-tree-file:hover .knowledge-actions,
|
||||||
|
.knowledge-tree-group-btn:hover .knowledge-actions,
|
||||||
|
.knowledge-tree-file:focus-within .knowledge-actions,
|
||||||
|
.knowledge-tree-group-btn:focus-within .knowledge-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.knowledge-action {
|
||||||
|
padding: 3px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.knowledge-action:hover {
|
||||||
|
color: #228547;
|
||||||
|
background: rgba(34, 133, 71, 0.08);
|
||||||
|
}
|
||||||
|
.knowledge-action.danger:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
.dark .knowledge-tree-file:hover {
|
.dark .knowledge-tree-file:hover {
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -1571,3 +1665,9 @@
|
|||||||
.delete-msg-btn:hover {
|
.delete-msg-btn:hover {
|
||||||
color: #ef4444 !important;
|
color: #ef4444 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-msg-btn:disabled,
|
||||||
|
.delete-msg-btn:disabled {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
opacity: 0.35 !important;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -432,7 +432,15 @@ class WebChannel(ChatChannel):
|
|||||||
elif event_type == "tool_execution_start":
|
elif event_type == "tool_execution_start":
|
||||||
tool_name = data.get("tool_name", "tool")
|
tool_name = data.get("tool_name", "tool")
|
||||||
arguments = data.get("arguments", {})
|
arguments = data.get("arguments", {})
|
||||||
q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments})
|
q.put({"type": "tool_start", "tool_call_id": data.get("tool_call_id"), "tool": tool_name, "arguments": arguments})
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_progress":
|
||||||
|
q.put({
|
||||||
|
"type": "tool_progress",
|
||||||
|
"tool_call_id": data.get("tool_call_id"),
|
||||||
|
"tool": data.get("tool_name", "tool"),
|
||||||
|
"content": str(data.get("message", ""))[-4 * 1024:],
|
||||||
|
})
|
||||||
|
|
||||||
elif event_type == "tool_execution_end":
|
elif event_type == "tool_execution_end":
|
||||||
tool_name = data.get("tool_name", "tool")
|
tool_name = data.get("tool_name", "tool")
|
||||||
@@ -445,6 +453,7 @@ class WebChannel(ChatChannel):
|
|||||||
result_str = result_str[:2000] + "…"
|
result_str = result_str[:2000] + "…"
|
||||||
q.put({
|
q.put({
|
||||||
"type": "tool_end",
|
"type": "tool_end",
|
||||||
|
"tool_call_id": data.get("tool_call_id"),
|
||||||
"tool": tool_name,
|
"tool": tool_name,
|
||||||
"status": status,
|
"status": status,
|
||||||
"result": result_str,
|
"result": result_str,
|
||||||
@@ -1145,7 +1154,11 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/knowledge/list', 'KnowledgeListHandler',
|
'/api/knowledge/list', 'KnowledgeListHandler',
|
||||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||||
|
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
|
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||||
|
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||||
|
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||||
'/api/sessions', 'SessionsHandler',
|
'/api/sessions', 'SessionsHandler',
|
||||||
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
||||||
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
||||||
@@ -1459,15 +1472,17 @@ class ChatHandler:
|
|||||||
class ConfigHandler:
|
class ConfigHandler:
|
||||||
|
|
||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
|
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
|
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude
|
||||||
|
# default) since it is often unavailable due to policy restrictions.
|
||||||
|
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.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.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.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
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.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,
|
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||||
]
|
]
|
||||||
@@ -1506,7 +1521,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "claude_api_base",
|
"api_base_key": "claude_api_base",
|
||||||
"api_base_default": "https://api.anthropic.com/v1",
|
"api_base_default": "https://api.anthropic.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET],
|
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
}),
|
}),
|
||||||
("gemini", {
|
("gemini", {
|
||||||
"label": "Gemini",
|
"label": "Gemini",
|
||||||
@@ -1530,7 +1545,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "zhipu_ai_api_base",
|
"api_base_key": "zhipu_ai_api_base",
|
||||||
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"api_base_placeholder": _PLACEHOLDER_ZHIPU,
|
"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", {
|
("dashscope", {
|
||||||
"label": {"zh": "通义千问", "en": "Qwen"},
|
"label": {"zh": "通义千问", "en": "Qwen"},
|
||||||
@@ -1554,7 +1569,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "moonshot_base_url",
|
"api_base_key": "moonshot_base_url",
|
||||||
"api_base_default": "https://api.moonshot.cn/v1",
|
"api_base_default": "https://api.moonshot.cn/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_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", {
|
("qianfan", {
|
||||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||||
@@ -1598,6 +1613,7 @@ class ConfigHandler:
|
|||||||
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
||||||
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
||||||
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
||||||
|
"custom_providers",
|
||||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||||
"enable_thinking", "self_evolution_enabled", "web_password",
|
"enable_thinking", "self_evolution_enabled", "web_password",
|
||||||
}
|
}
|
||||||
@@ -1639,6 +1655,32 @@ class ConfigHandler:
|
|||||||
"api_key_field": p.get("api_key_field"),
|
"api_key_field": p.get("api_key_field"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Expose user-defined custom providers as "custom:<id>" entries so
|
||||||
|
# the legacy config page can display and select them. Credentials
|
||||||
|
# are managed on the Models page, hence the null key/base fields.
|
||||||
|
# Mirrors the Models page: when expanded entries exist, the bare
|
||||||
|
# legacy "custom" entry is hidden — unless the flat single-provider
|
||||||
|
# custom config is still active or filled in.
|
||||||
|
try:
|
||||||
|
from models.custom_provider import get_custom_providers
|
||||||
|
custom_list = get_custom_providers()
|
||||||
|
legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config)
|
||||||
|
if custom_list and not legacy_custom_in_use:
|
||||||
|
providers.pop("custom", None)
|
||||||
|
for cp in custom_list:
|
||||||
|
cid = f"custom:{cp.get('id')}"
|
||||||
|
cname = cp.get("name") or cp.get("id")
|
||||||
|
providers[cid] = {
|
||||||
|
"label": {"zh": cname, "en": cname},
|
||||||
|
"models": [cp["model"]] if cp.get("model") else [],
|
||||||
|
"api_base_key": None,
|
||||||
|
"api_base_default": None,
|
||||||
|
"api_base_placeholder": "",
|
||||||
|
"api_key_field": None,
|
||||||
|
}
|
||||||
|
except Exception as cp_err:
|
||||||
|
logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}")
|
||||||
|
|
||||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||||
|
|
||||||
@@ -2128,13 +2170,99 @@ class ModelsHandler:
|
|||||||
def _is_real_key(value: str) -> bool:
|
def _is_real_key(value: str) -> bool:
|
||||||
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _custom_provider_cards(cls, local_config: dict) -> List[dict]:
|
||||||
|
"""Expand ``custom_providers`` into one card per provider.
|
||||||
|
|
||||||
|
Each user-defined OpenAI-compatible provider becomes its own card with
|
||||||
|
id ``custom:<id>`` so the frontend can render, edit, delete and
|
||||||
|
activate them independently. The card carries ``is_custom=True`` and
|
||||||
|
``active`` flags that the UI uses to render the extra controls.
|
||||||
|
|
||||||
|
Returns an empty list when no multi-providers are configured, in which
|
||||||
|
case the caller keeps the single legacy ``custom`` card untouched —
|
||||||
|
guaranteeing backward compatibility with the flat
|
||||||
|
``custom_api_key`` / ``custom_api_base`` config.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from models.custom_provider import get_custom_providers, parse_custom_bot_type
|
||||||
|
providers = get_custom_providers()
|
||||||
|
except Exception as e: # pragma: no cover - defensive
|
||||||
|
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
|
||||||
|
providers = []
|
||||||
|
if not providers:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Determine the currently active provider id from bot_type.
|
||||||
|
bot_type = local_config.get("bot_type") or ""
|
||||||
|
_, active_id = parse_custom_bot_type(bot_type)
|
||||||
|
|
||||||
|
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
|
||||||
|
cards = []
|
||||||
|
for p in providers:
|
||||||
|
pid = p.get("id") or ""
|
||||||
|
name = p.get("name") or pid
|
||||||
|
raw_key = p.get("api_key") or ""
|
||||||
|
raw_base = p.get("api_base") or ""
|
||||||
|
configured = cls._is_real_key(raw_key)
|
||||||
|
cards.append({
|
||||||
|
"id": f"custom:{pid}",
|
||||||
|
"label": {"zh": name, "en": name},
|
||||||
|
"configured": configured,
|
||||||
|
"is_custom": True,
|
||||||
|
"custom_id": pid,
|
||||||
|
"custom_name": name,
|
||||||
|
"active": (pid == active_id),
|
||||||
|
"model": p.get("model") or "",
|
||||||
|
# Custom cards are edited via the dedicated set_custom_provider
|
||||||
|
# action, not the field-based set_provider flow, so the field
|
||||||
|
# names are intentionally null.
|
||||||
|
"api_key_field": None,
|
||||||
|
"api_base_field": None,
|
||||||
|
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||||
|
"api_base": raw_base,
|
||||||
|
"api_base_default": "",
|
||||||
|
"api_base_placeholder": meta.get("api_base_placeholder") or "",
|
||||||
|
"models": [p.get("model")] if p.get("model") else [],
|
||||||
|
})
|
||||||
|
return cards
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _legacy_custom_in_use(cls, local_config: dict) -> bool:
|
||||||
|
"""True when the flat single-provider custom config is still relevant:
|
||||||
|
either it is the active bot_type, or its key/base fields are filled.
|
||||||
|
In that case the legacy "custom" card must stay visible even when
|
||||||
|
multi ``custom_providers`` entries exist."""
|
||||||
|
if (local_config.get("bot_type") or "") == "custom":
|
||||||
|
return True
|
||||||
|
return (cls._is_real_key(local_config.get("custom_api_key") or "")
|
||||||
|
or bool(local_config.get("custom_api_base")))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _provider_overview(cls) -> List[dict]:
|
def _provider_overview(cls) -> List[dict]:
|
||||||
"""All known providers (configured first, unconfigured after).
|
"""All known providers (configured first, unconfigured after).
|
||||||
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list."""
|
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.
|
||||||
|
|
||||||
|
When the user has defined multiple custom (OpenAI-compatible)
|
||||||
|
providers via ``custom_providers``, the single built-in ``custom``
|
||||||
|
card is replaced by one card per provider (see
|
||||||
|
``_custom_provider_cards``). Otherwise the legacy single ``custom``
|
||||||
|
card is shown unchanged.
|
||||||
|
"""
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
# Keep the legacy single "custom" card visible alongside the expanded
|
||||||
|
# ones when the flat custom_api_key/base config is active or filled,
|
||||||
|
# so existing single-provider setups never disappear from the UI.
|
||||||
|
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||||
items = []
|
items = []
|
||||||
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
||||||
|
if pid == "custom" and custom_cards:
|
||||||
|
# Multi-provider mode: emit the expanded cards, plus the
|
||||||
|
# legacy card when it is still in use.
|
||||||
|
items.extend(custom_cards)
|
||||||
|
if not keep_legacy_custom:
|
||||||
|
continue
|
||||||
key_field = p.get("api_key_field")
|
key_field = p.get("api_key_field")
|
||||||
base_field = p.get("api_base_key")
|
base_field = p.get("api_base_key")
|
||||||
raw_key = local_config.get(key_field, "") if key_field else ""
|
raw_key = local_config.get(key_field, "") if key_field else ""
|
||||||
@@ -2144,6 +2272,7 @@ class ModelsHandler:
|
|||||||
"id": pid,
|
"id": pid,
|
||||||
"label": p["label"],
|
"label": p["label"],
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
|
"is_custom": (pid == "custom"),
|
||||||
"api_key_field": key_field,
|
"api_key_field": key_field,
|
||||||
"api_base_field": base_field,
|
"api_base_field": base_field,
|
||||||
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||||
@@ -2152,7 +2281,19 @@ class ModelsHandler:
|
|||||||
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
||||||
"models": list(p.get("models") or []),
|
"models": list(p.get("models") or []),
|
||||||
})
|
})
|
||||||
items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"])))
|
|
||||||
|
def _sort_key(it):
|
||||||
|
pid = it["id"]
|
||||||
|
# Custom expanded cards share the sort weight of the base "custom"
|
||||||
|
# entry so they cluster where the single custom card used to be.
|
||||||
|
base_id = "custom" if it.get("is_custom") else pid
|
||||||
|
try:
|
||||||
|
order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id)
|
||||||
|
except ValueError:
|
||||||
|
order = len(ConfigHandler.PROVIDER_MODELS)
|
||||||
|
return (0 if it["configured"] else 1, order)
|
||||||
|
|
||||||
|
items.sort(key=_sort_key)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2160,13 +2301,28 @@ class ModelsHandler:
|
|||||||
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
||||||
bot_type = local_config.get("bot_type") or ""
|
bot_type = local_config.get("bot_type") or ""
|
||||||
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
||||||
if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"):
|
is_custom_id = provider_id.startswith("custom:")
|
||||||
|
if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id
|
||||||
|
and local_config.get("use_linkai")):
|
||||||
provider_id = "linkai"
|
provider_id = "linkai"
|
||||||
|
# In multi-provider mode, replace the single "custom" entry with the
|
||||||
|
# expanded "custom:<id>" ids so the chat dropdown matches the cards.
|
||||||
|
# The legacy "custom" entry stays when its flat config is still used.
|
||||||
|
provider_ids = []
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||||
|
for pid in ConfigHandler.PROVIDER_MODELS.keys():
|
||||||
|
if pid == "custom" and custom_cards:
|
||||||
|
provider_ids.extend(c["id"] for c in custom_cards)
|
||||||
|
if keep_legacy_custom:
|
||||||
|
provider_ids.append(pid)
|
||||||
|
else:
|
||||||
|
provider_ids.append(pid)
|
||||||
return {
|
return {
|
||||||
"editable": True,
|
"editable": True,
|
||||||
"current_provider": provider_id,
|
"current_provider": provider_id,
|
||||||
"current_model": local_config.get("model", ""),
|
"current_model": local_config.get("model", ""),
|
||||||
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
|
"providers": provider_ids,
|
||||||
"use_linkai": bool(local_config.get("use_linkai", False)),
|
"use_linkai": bool(local_config.get("use_linkai", False)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2600,6 +2756,12 @@ class ModelsHandler:
|
|||||||
return self._handle_set_provider(data)
|
return self._handle_set_provider(data)
|
||||||
if action == "delete_provider":
|
if action == "delete_provider":
|
||||||
return self._handle_delete_provider(data)
|
return self._handle_delete_provider(data)
|
||||||
|
if action == "set_custom_provider":
|
||||||
|
return self._handle_set_custom_provider(data)
|
||||||
|
if action == "delete_custom_provider":
|
||||||
|
return self._handle_delete_custom_provider(data)
|
||||||
|
if action == "set_active_custom_provider":
|
||||||
|
return self._handle_set_active_custom_provider(data)
|
||||||
if action == "set_capability":
|
if action == "set_capability":
|
||||||
return self._handle_set_capability(data)
|
return self._handle_set_capability(data)
|
||||||
if action == "set_voice_reply_mode":
|
if action == "set_voice_reply_mode":
|
||||||
@@ -2683,6 +2845,170 @@ class ModelsHandler:
|
|||||||
self._reset_bridge()
|
self._reset_bridge()
|
||||||
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Multiple custom (OpenAI-compatible) providers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# These actions manage the ``custom_providers`` list. Activation is done
|
||||||
|
# by setting ``bot_type`` to ``"custom:<id>"``. There is no separate
|
||||||
|
# ``custom_active_provider`` field — a single source of truth.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_custom_providers(raw) -> List[dict]:
|
||||||
|
"""Return a clean list of provider dicts (drops malformed entries)."""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for p in raw:
|
||||||
|
if isinstance(p, dict) and (p.get("id") or "").strip():
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None:
|
||||||
|
"""Write the providers list to both in-memory conf and the on-disk
|
||||||
|
config, then reset the bridge so bots rebuild.
|
||||||
|
|
||||||
|
If ``bot_type`` is given, also update ``bot_type``. When activating a
|
||||||
|
provider (bot_type is ``custom:<id>``), also write the provider's
|
||||||
|
``model`` into the global ``model`` field so that all paths (chat,
|
||||||
|
agent, vision) automatically use the correct model."""
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
local_config["custom_providers"] = providers
|
||||||
|
file_cfg["custom_providers"] = providers
|
||||||
|
if bot_type is not None:
|
||||||
|
local_config["bot_type"] = bot_type
|
||||||
|
file_cfg["bot_type"] = bot_type
|
||||||
|
# Sync the provider's model into the global model field.
|
||||||
|
_, pid = parse_custom_bot_type(bot_type)
|
||||||
|
if pid:
|
||||||
|
provider = next((p for p in providers if p.get("id") == pid), None)
|
||||||
|
if provider and provider.get("model"):
|
||||||
|
local_config["model"] = provider["model"]
|
||||||
|
file_cfg["model"] = provider["model"]
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
self._reset_bridge()
|
||||||
|
|
||||||
|
def _handle_set_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Add a new custom provider or update an existing one.
|
||||||
|
|
||||||
|
Payload::
|
||||||
|
|
||||||
|
{
|
||||||
|
"action": "set_custom_provider",
|
||||||
|
"id": "3f2a9c1b", # required for edit; omit for create
|
||||||
|
"name": "siliconflow", # required, display label
|
||||||
|
"api_base": "https://...", # required when creating
|
||||||
|
"api_key": "sk-...", # optional on edit (keep existing)
|
||||||
|
"model": "deepseek-ai/...", # optional default model
|
||||||
|
"make_active": true # optional, also activate it
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from models.custom_provider import generate_provider_id, parse_custom_bot_type
|
||||||
|
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return json.dumps({"status": "error", "message": "name is required"})
|
||||||
|
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
api_base = (data.get("api_base") or "").strip()
|
||||||
|
# api_key omitted/empty on edit => keep the existing one.
|
||||||
|
api_key_raw = data.get("api_key")
|
||||||
|
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
|
||||||
|
model = (data.get("model") or "").strip()
|
||||||
|
make_active = bool(data.get("make_active"))
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
|
||||||
|
existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None
|
||||||
|
if existing is None:
|
||||||
|
# Creating a new provider — api_base is mandatory.
|
||||||
|
if not api_base:
|
||||||
|
return json.dumps({"status": "error", "message": "api_base is required"})
|
||||||
|
provider_id = generate_provider_id()
|
||||||
|
entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base}
|
||||||
|
if model:
|
||||||
|
entry["model"] = model
|
||||||
|
providers.append(entry)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
existing["name"] = name
|
||||||
|
if api_base:
|
||||||
|
existing["api_base"] = api_base
|
||||||
|
if api_key:
|
||||||
|
existing["api_key"] = api_key
|
||||||
|
# Only touch model when explicitly provided in the payload; an
|
||||||
|
# explicit empty string clears it, a missing key keeps it (the
|
||||||
|
# UI modal no longer sends model, so manual config survives edits).
|
||||||
|
if "model" in data:
|
||||||
|
if model:
|
||||||
|
existing["model"] = model
|
||||||
|
else:
|
||||||
|
existing.pop("model", None)
|
||||||
|
created = False
|
||||||
|
|
||||||
|
# Decide bot_type — only switch when explicitly requested.
|
||||||
|
new_bot_type = None
|
||||||
|
if make_active:
|
||||||
|
new_bot_type = f"custom:{provider_id}"
|
||||||
|
|
||||||
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
|
logger.info(
|
||||||
|
f"[ModelsHandler] custom provider {name!r} (id={provider_id}) "
|
||||||
|
f"{'created' if created else 'updated'}"
|
||||||
|
)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"id": provider_id,
|
||||||
|
"name": name,
|
||||||
|
"created": created,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _handle_delete_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Remove a custom provider by id."""
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
if not provider_id:
|
||||||
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
remaining = [p for p in providers if p.get("id") != provider_id]
|
||||||
|
if len(remaining) == len(providers):
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
|
# If the deleted provider was active, fall back to the first remaining.
|
||||||
|
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
|
||||||
|
new_bot_type = None
|
||||||
|
if current_active_id == provider_id:
|
||||||
|
if remaining:
|
||||||
|
new_bot_type = f"custom:{remaining[0]['id']}"
|
||||||
|
else:
|
||||||
|
new_bot_type = "custom" # revert to legacy
|
||||||
|
|
||||||
|
self._persist_custom_providers(remaining, new_bot_type)
|
||||||
|
logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted")
|
||||||
|
return json.dumps({"status": "success", "id": provider_id})
|
||||||
|
|
||||||
|
def _handle_set_active_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Activate a custom provider by setting bot_type to 'custom:<id>'."""
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
if not provider_id:
|
||||||
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
if not any(p.get("id") == provider_id for p in providers):
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
|
new_bot_type = f"custom:{provider_id}"
|
||||||
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
|
logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}")
|
||||||
|
return json.dumps({"status": "success", "active_id": provider_id})
|
||||||
|
|
||||||
def _handle_set_capability(self, data: dict) -> str:
|
def _handle_set_capability(self, data: dict) -> str:
|
||||||
capability = (data.get("capability") or "").strip()
|
capability = (data.get("capability") or "").strip()
|
||||||
provider_id = (data.get("provider_id") or "").strip()
|
provider_id = (data.get("provider_id") or "").strip()
|
||||||
@@ -2747,13 +3073,28 @@ class ModelsHandler:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _set_chat(self, provider_id: str, model: str) -> str:
|
def _set_chat(self, provider_id: str, model: str) -> str:
|
||||||
if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
# Accept expanded custom provider ids ("custom:<id>") as well as the
|
||||||
|
# built-in vendors, so the chat capability card and the custom
|
||||||
|
# providers section behave consistently.
|
||||||
|
custom_provider = None
|
||||||
|
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}"})
|
||||||
|
elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
||||||
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
applied = {}
|
applied = {}
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
file_cfg = self._read_file_config()
|
file_cfg = self._read_file_config()
|
||||||
|
|
||||||
|
# Fall back to the custom provider's default model when none is given.
|
||||||
|
if not model and custom_provider:
|
||||||
|
model = custom_provider.get("model") or ""
|
||||||
|
|
||||||
if provider_id:
|
if provider_id:
|
||||||
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
||||||
local_config["bot_type"] = bot_type_value
|
local_config["bot_type"] = bot_type_value
|
||||||
@@ -3824,6 +4165,141 @@ class SchedulerHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
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:
|
class SessionsHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
_require_auth()
|
_require_auth()
|
||||||
@@ -4000,7 +4476,7 @@ class MessageDeleteHandler:
|
|||||||
# 2. Sync agent's in-memory context so its next turn sees the
|
# 2. Sync agent's in-memory context so its next turn sees the
|
||||||
# same history as the DB. Handled by the agent_bridge helper.
|
# same history as the DB. Handled by the agent_bridge helper.
|
||||||
try:
|
try:
|
||||||
from bridge import Bridge
|
from bridge.bridge import Bridge
|
||||||
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
|
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
|
||||||
except Exception as sync_err:
|
except Exception as sync_err:
|
||||||
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")
|
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")
|
||||||
@@ -4152,6 +4628,25 @@ class KnowledgeGraphHandler:
|
|||||||
return json.dumps({"nodes": [], "links": []})
|
return json.dumps({"nodes": [], "links": []})
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeActionHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data() or b"{}")
|
||||||
|
action = body.get("action", "")
|
||||||
|
payload = body.get("payload") or {}
|
||||||
|
from agent.knowledge.service import KnowledgeService
|
||||||
|
result = KnowledgeService(_get_workspace_root()).dispatch(action, payload)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success" if result["code"] < 300 else "error",
|
||||||
|
**result,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Knowledge action error: {e}")
|
||||||
|
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
||||||
|
|
||||||
|
|
||||||
class VersionHandler:
|
class VersionHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
|||||||
@@ -12,16 +12,19 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import web
|
||||||
import websocket
|
import websocket
|
||||||
|
|
||||||
from bridge.context import Context, ContextType
|
from bridge.context import Context, ContextType
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
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 channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -32,6 +35,9 @@ from config import conf
|
|||||||
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
||||||
HEARTBEAT_INTERVAL = 30
|
HEARTBEAT_INTERVAL = 30
|
||||||
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
|
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:
|
def _escape_control_chars_inside_json_strings(s: str) -> str:
|
||||||
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
|
|||||||
self._pending_lock = threading.Lock()
|
self._pending_lock = threading.Lock()
|
||||||
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
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()["group_name_white_list"] = ["ALL_GROUP"]
|
||||||
conf()["single_chat_prefix"] = [""]
|
conf()["single_chat_prefix"] = [""]
|
||||||
|
|
||||||
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def startup(self):
|
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_id = conf().get("wecom_bot_id", "")
|
||||||
self.bot_secret = conf().get("wecom_bot_secret", "")
|
self.bot_secret = conf().get("wecom_bot_secret", "")
|
||||||
|
|
||||||
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
|
|||||||
pass
|
pass
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._connected = False
|
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
|
# WebSocket connection
|
||||||
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
|
|||||||
def _gen_req_id(self) -> str:
|
def _gen_req_id(self) -> str:
|
||||||
return uuid.uuid4().hex[:16]
|
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
|
# Subscribe & heartbeat
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
|
|||||||
chattype = body.get("chattype", "single")
|
chattype = body.get("chattype", "single")
|
||||||
is_group = chattype == "group"
|
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:
|
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:
|
except NotImplementedError as e:
|
||||||
logger.warning(f"[WecomBot] {e}")
|
logger.warning(f"[WecomBot] {e}")
|
||||||
return
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
||||||
return
|
return None
|
||||||
|
|
||||||
wecom_msg.req_id = req_id
|
|
||||||
|
|
||||||
# File cache logic (same pattern as feishu)
|
# File cache logic (same pattern as feishu)
|
||||||
from channel.file_cache import get_file_cache
|
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:
|
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
||||||
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
||||||
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.FILE:
|
if wecom_msg.ctype == ContextType.FILE:
|
||||||
wecom_msg.prepare()
|
wecom_msg.prepare()
|
||||||
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
||||||
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.TEXT:
|
if wecom_msg.ctype == ContextType.TEXT:
|
||||||
cached_files = file_cache.get(session_id)
|
cached_files = file_cache.get(session_id)
|
||||||
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
|
|||||||
msg=wecom_msg,
|
msg=wecom_msg,
|
||||||
no_need_at=True,
|
no_need_at=True,
|
||||||
)
|
)
|
||||||
if context:
|
if not context:
|
||||||
if req_id:
|
return None
|
||||||
context["on_event"] = self._make_stream_callback(req_id)
|
return context, wecom_msg
|
||||||
self.produce(context)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Event callback
|
# Event callback
|
||||||
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
|
|||||||
|
|
||||||
return context
|
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"" 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
|
# Send reply
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def send(self, reply: Reply, context: Context):
|
def send(self, reply: Reply, context: Context):
|
||||||
|
if self.mode == "webhook":
|
||||||
|
self._callback_send(reply, context)
|
||||||
|
return
|
||||||
|
|
||||||
msg = context.get("msg")
|
msg = context.get("msg")
|
||||||
is_group = context.get("isgroup", False)
|
is_group = context.get("isgroup", False)
|
||||||
receiver = context.get("receiver", "")
|
receiver = context.get("receiver", "")
|
||||||
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.error("[WecomBot] Failed to get media_id from finish response")
|
logger.error("[WecomBot] Failed to get media_id from finish response")
|
||||||
return media_id
|
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)
|
||||||
|
|||||||
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
WeCom (企业微信) smart-bot callback message encryption/decryption.
|
||||||
|
|
||||||
|
Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the
|
||||||
|
AI bot callback (webhook) mode. The bot's receive-message callback delivers
|
||||||
|
AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the
|
||||||
|
same way before being returned in the HTTP response.
|
||||||
|
|
||||||
|
For an enterprise-internal smart bot, ``receive_id`` is always an empty string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
# Error codes (mirrors the official ierror.py)
|
||||||
|
WXBizMsgCrypt_OK = 0
|
||||||
|
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||||
|
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||||
|
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||||
|
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||||
|
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||||
|
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||||
|
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||||
|
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||||
|
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||||
|
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||||
|
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||||
|
|
||||||
|
|
||||||
|
class FormatException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_sha1(token, timestamp, nonce, encrypt):
|
||||||
|
"""Compute the WeCom message signature with SHA1 over the sorted parts."""
|
||||||
|
try:
|
||||||
|
if isinstance(encrypt, bytes):
|
||||||
|
encrypt = encrypt.decode("utf-8")
|
||||||
|
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
||||||
|
sortlist.sort()
|
||||||
|
sha = hashlib.sha1()
|
||||||
|
sha.update("".join(sortlist).encode("utf-8"))
|
||||||
|
return WXBizMsgCrypt_OK, sha.hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] compute signature error: {e}")
|
||||||
|
return WXBizMsgCrypt_ComputeSignature_Error, None
|
||||||
|
|
||||||
|
|
||||||
|
class _PKCS7Encoder:
|
||||||
|
"""PKCS#7 padding with a 32-byte block size (AES-256)."""
|
||||||
|
|
||||||
|
block_size = 32
|
||||||
|
|
||||||
|
def encode(self, text: bytes) -> bytes:
|
||||||
|
text_length = len(text)
|
||||||
|
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||||
|
if amount_to_pad == 0:
|
||||||
|
amount_to_pad = self.block_size
|
||||||
|
pad = bytes([amount_to_pad])
|
||||||
|
return text + pad * amount_to_pad
|
||||||
|
|
||||||
|
def decode(self, decrypted: bytes) -> bytes:
|
||||||
|
pad = decrypted[-1]
|
||||||
|
if pad < 1 or pad > 32:
|
||||||
|
pad = 0
|
||||||
|
return decrypted[:-pad] if pad else decrypted
|
||||||
|
|
||||||
|
|
||||||
|
class _Prpcrypt:
|
||||||
|
"""AES-256-CBC encrypt/decrypt for WeCom callback messages."""
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
self.key = key
|
||||||
|
self.mode = AES.MODE_CBC
|
||||||
|
|
||||||
|
def encrypt(self, text: str, receive_id: str):
|
||||||
|
text_bytes = text.encode()
|
||||||
|
# 16-byte random prefix + network-order length + body + receive_id
|
||||||
|
text_bytes = (
|
||||||
|
self._get_random_str()
|
||||||
|
+ struct.pack("I", socket.htonl(len(text_bytes)))
|
||||||
|
+ text_bytes
|
||||||
|
+ receive_id.encode()
|
||||||
|
)
|
||||||
|
text_bytes = _PKCS7Encoder().encode(text_bytes)
|
||||||
|
try:
|
||||||
|
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||||
|
ciphertext = cryptor.encrypt(text_bytes)
|
||||||
|
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] AES encrypt error: {e}")
|
||||||
|
return WXBizMsgCrypt_EncryptAES_Error, None
|
||||||
|
|
||||||
|
def decrypt(self, text, receive_id: str):
|
||||||
|
try:
|
||||||
|
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||||
|
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] AES decrypt error: {e}")
|
||||||
|
return WXBizMsgCrypt_DecryptAES_Error, None
|
||||||
|
try:
|
||||||
|
pad = plain_text[-1]
|
||||||
|
content = plain_text[16:-pad]
|
||||||
|
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||||
|
json_content = content[4 : json_len + 4].decode("utf-8")
|
||||||
|
from_receive_id = content[json_len + 4 :].decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] illegal buffer when decrypting: {e}")
|
||||||
|
return WXBizMsgCrypt_IllegalBuffer, None
|
||||||
|
if from_receive_id != receive_id:
|
||||||
|
logger.error(
|
||||||
|
f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}"
|
||||||
|
)
|
||||||
|
return WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||||
|
return WXBizMsgCrypt_OK, json_content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_random_str() -> bytes:
|
||||||
|
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotCrypt:
|
||||||
|
"""High-level helper for verifying URLs and (de)crypting callback messages."""
|
||||||
|
|
||||||
|
def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""):
|
||||||
|
try:
|
||||||
|
self.key = base64.b64decode(encoding_aes_key + "=")
|
||||||
|
assert len(self.key) == 32
|
||||||
|
except Exception:
|
||||||
|
raise FormatException("[WecomBot] invalid EncodingAESKey")
|
||||||
|
self.token = token
|
||||||
|
self.receive_id = receive_id
|
||||||
|
|
||||||
|
def verify_url(self, msg_signature, timestamp, nonce, echostr):
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
if signature != msg_signature:
|
||||||
|
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
return pc.decrypt(echostr, self.receive_id)
|
||||||
|
|
||||||
|
def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None):
|
||||||
|
"""Encrypt a passive-reply JSON string and return the full response JSON.
|
||||||
|
|
||||||
|
Returns (ret, response_dict). On success ret==0 and response_dict is a
|
||||||
|
dict with encrypt/msgsignature/timestamp/nonce fields.
|
||||||
|
"""
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
ret, encrypt = pc.encrypt(reply_msg, self.receive_id)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
encrypt = encrypt.decode("utf-8")
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
return WXBizMsgCrypt_OK, {
|
||||||
|
"encrypt": encrypt,
|
||||||
|
"msgsignature": signature,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"nonce": nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
def decrypt_msg(self, post_data, msg_signature, timestamp, nonce):
|
||||||
|
"""Verify signature and decrypt the encrypted callback payload.
|
||||||
|
|
||||||
|
``post_data`` may be the raw request body (bytes/str) containing
|
||||||
|
``{"encrypt": "..."}`` or the already-extracted encrypt string.
|
||||||
|
Returns (ret, plaintext_json_str).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
encrypt = None
|
||||||
|
if isinstance(post_data, (bytes, bytearray)):
|
||||||
|
post_data = post_data.decode("utf-8")
|
||||||
|
if isinstance(post_data, str):
|
||||||
|
try:
|
||||||
|
encrypt = json.loads(post_data).get("encrypt")
|
||||||
|
except Exception:
|
||||||
|
encrypt = post_data
|
||||||
|
elif isinstance(post_data, dict):
|
||||||
|
encrypt = post_data.get("encrypt")
|
||||||
|
if not encrypt:
|
||||||
|
return WXBizMsgCrypt_ParseJson_Error, None
|
||||||
|
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
if signature != msg_signature:
|
||||||
|
logger.error("[WecomBot] callback signature not match")
|
||||||
|
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
return pc.decrypt(encrypt, self.receive_id)
|
||||||
@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
|
|||||||
class WecomBotMessage(ChatMessage):
|
class WecomBotMessage(ChatMessage):
|
||||||
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
"""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)
|
super().__init__(msg_body)
|
||||||
self.msg_id = msg_body.get("msgid")
|
self.msg_id = msg_body.get("msgid")
|
||||||
self.create_time = msg_body.get("create_time")
|
self.create_time = msg_body.get("create_time")
|
||||||
self.is_group = is_group
|
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")
|
msg_type = msg_body.get("msgtype")
|
||||||
from_userid = msg_body.get("from", {}).get("userid", "")
|
from_userid = msg_body.get("from", {}).get("userid", "")
|
||||||
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.IMAGE
|
self.ctype = ContextType.IMAGE
|
||||||
image_info = msg_body.get("image", {})
|
image_info = msg_body.get("image", {})
|
||||||
image_url = image_info.get("url", "")
|
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()
|
tmp_dir = _get_tmp_dir()
|
||||||
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
elif item_type == "image":
|
elif item_type == "image":
|
||||||
img_info = item.get("image", {})
|
img_info = item.get("image", {})
|
||||||
img_url = img_info.get("url", "")
|
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")
|
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
||||||
try:
|
try:
|
||||||
img_data = _decrypt_media(img_url, img_aeskey)
|
img_data = _decrypt_media(img_url, img_aeskey)
|
||||||
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
file_info = msg_body.get("file", {})
|
file_info = msg_body.get("file", {})
|
||||||
file_url = file_info.get("url", "")
|
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()
|
tmp_dir = _get_tmp_dir()
|
||||||
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
||||||
self.content = base_path
|
self.content = base_path
|
||||||
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
video_info = msg_body.get("video", {})
|
video_info = msg_body.get("video", {})
|
||||||
video_url = video_info.get("url", "")
|
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()
|
tmp_dir = _get_tmp_dir()
|
||||||
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import click
|
import click
|
||||||
from cli import __version__
|
from cli import __version__
|
||||||
from cli.commands.skill import skill
|
from cli.commands.skill import skill
|
||||||
from cli.commands.process import start, stop, restart, update, status, logs
|
from cli.commands.process import start, stop, restart, self_restart, update, status, logs
|
||||||
from cli.commands.context import context
|
from cli.commands.context import context
|
||||||
from cli.commands.install import install_browser
|
from cli.commands.install import install_browser
|
||||||
from cli.commands.knowledge import knowledge
|
from cli.commands.knowledge import knowledge
|
||||||
@@ -68,6 +68,7 @@ main.add_command(skill)
|
|||||||
main.add_command(start)
|
main.add_command(start)
|
||||||
main.add_command(stop)
|
main.add_command(stop)
|
||||||
main.add_command(restart)
|
main.add_command(restart)
|
||||||
|
main.add_command(self_restart)
|
||||||
main.add_command(update)
|
main.add_command(update)
|
||||||
main.add_command(status)
|
main.add_command(status)
|
||||||
main.add_command(logs)
|
main.add_command(logs)
|
||||||
|
|||||||
@@ -78,12 +78,13 @@ def _is_china_network() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||||||
"""Install a package, retrying with --user on permission failure."""
|
"""Install a package, preferring prebuilt wheels; retry with --user on perm error."""
|
||||||
python = sys.executable
|
python = sys.executable
|
||||||
ret = subprocess.call([python, "-m", "pip", "install", package_spec])
|
base = [python, "-m", "pip", "install", "--prefer-binary"]
|
||||||
|
ret = subprocess.call(base + [package_spec])
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(" Retrying with --user flag...", "yellow")
|
stream(" Retrying with --user flag...", "yellow")
|
||||||
ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
|
ret = subprocess.call(base + ["--user", package_spec])
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +156,22 @@ def run_install_browser(
|
|||||||
|
|
||||||
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
||||||
|
|
||||||
|
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||||
|
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||||
|
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||||
|
ret = subprocess.call(
|
||||||
|
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
stream(
|
||||||
|
" Could not pre-install a prebuilt greenlet wheel.\n"
|
||||||
|
" playwright may try to build greenlet from source, which needs\n"
|
||||||
|
" Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
|
||||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||||
|
|||||||
@@ -195,6 +195,120 @@ def restart(ctx, no_logs):
|
|||||||
ctx.invoke(start, no_logs=no_logs)
|
ctx.invoke(start, no_logs=no_logs)
|
||||||
|
|
||||||
|
|
||||||
|
# Detached relay that survives the caller's process tree. Run via `python -c`
|
||||||
|
# with start_new_session=True so it keeps going after the agent's bash child
|
||||||
|
# (and the main app it kills) both die. Flow: self-check the new code FIRST
|
||||||
|
# (import app); abort without touching the old process if it fails. Only when
|
||||||
|
# the new code is loadable does it SIGTERM the old PID, wait for exit (SIGKILL
|
||||||
|
# fallback), then start a fresh app.py and write the pid.
|
||||||
|
_RELAY_SCRIPT = r"""
|
||||||
|
import os, sys, time, signal, subprocess
|
||||||
|
|
||||||
|
root, python, app_py, pid_file, log_file = sys.argv[1:6]
|
||||||
|
old_pid = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0
|
||||||
|
|
||||||
|
|
||||||
|
def alive(pid):
|
||||||
|
if not pid:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
try:
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
f.write("[self-restart] " + msg + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 0) Self-check: make sure the new code actually loads BEFORE killing anything.
|
||||||
|
# `import app` exercises top-level imports / syntax of the entry module. If it
|
||||||
|
# fails, abort and leave the running service untouched — never end up with the
|
||||||
|
# old process killed and the new one unable to start.
|
||||||
|
check = subprocess.run(
|
||||||
|
[python, "-c", "import app"], cwd=root,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
if check.returncode != 0:
|
||||||
|
detail = (check.stdout or b"").decode("utf-8", "replace").strip()
|
||||||
|
log("self-check FAILED, aborting restart; service left running:\n" + detail)
|
||||||
|
sys.exit(1)
|
||||||
|
log("self-check passed")
|
||||||
|
|
||||||
|
# 1) Ask the old process to exit gracefully (its SIGTERM handler persists state).
|
||||||
|
if alive(old_pid):
|
||||||
|
try:
|
||||||
|
os.kill(old_pid, signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# 2) Wait up to ~15s for it to go, then force-kill as a backstop.
|
||||||
|
for _ in range(150):
|
||||||
|
if not alive(old_pid):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.kill(old_pid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# 3) Start a fresh instance, detached, logging to the same file.
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[python, app_py], cwd=root,
|
||||||
|
stdout=f, stderr=f, start_new_session=True,
|
||||||
|
)
|
||||||
|
with open(pid_file, "w") as f:
|
||||||
|
f.write(str(proc.pid))
|
||||||
|
log("restarted, new pid=" + str(proc.pid))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@click.command(name="self-restart", hidden=True)
|
||||||
|
def self_restart():
|
||||||
|
"""Restart from inside the running agent (detached; survives parent death).
|
||||||
|
|
||||||
|
Intended to be invoked by the agent itself (e.g. via bash after editing its
|
||||||
|
own code), not by users — so it is hidden from `cow help`. Unlike `restart`,
|
||||||
|
the actual stop+start runs in a detached relay process that outlives the
|
||||||
|
agent's bash child, which would otherwise die together with the main app it
|
||||||
|
kills.
|
||||||
|
"""
|
||||||
|
if _IS_WIN:
|
||||||
|
click.echo("self-restart is not supported on Windows; use `cow restart`.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
root = get_project_root()
|
||||||
|
app_py = os.path.join(root, "app.py")
|
||||||
|
if not os.path.exists(app_py):
|
||||||
|
click.echo("Error: app.py not found in project root.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
python = sys.executable
|
||||||
|
pid = _read_pid() or 0
|
||||||
|
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
python, "-c", _RELAY_SCRIPT,
|
||||||
|
root, python, app_py, _get_pid_file(), _get_log_file(), str(pid),
|
||||||
|
],
|
||||||
|
cwd=root,
|
||||||
|
start_new_session=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
click.echo(click.style(
|
||||||
|
"✓ Restart scheduled. The service will stop and come back in a few seconds.",
|
||||||
|
fg="green",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def update(ctx):
|
def update(ctx):
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ class CloudClient(LinkAIClient):
|
|||||||
if key in available_setting and config.get(key) is not None:
|
if key in available_setting and config.get(key) is not None:
|
||||||
local_config[key] = config.get(key)
|
local_config[key] = config.get(key)
|
||||||
|
|
||||||
|
# Self-evolution switch: normalize remote value (bool / "Y"/"N" / "true")
|
||||||
|
# to a real bool so the evolution config parser reads it correctly.
|
||||||
|
if config.get("self_evolution_enabled") is not None:
|
||||||
|
local_config["self_evolution_enabled"] = self._to_bool(config.get("self_evolution_enabled"))
|
||||||
|
|
||||||
# Voice settings
|
# Voice settings
|
||||||
reply_voice_mode = config.get("reply_voice_mode")
|
reply_voice_mode = config.get("reply_voice_mode")
|
||||||
if reply_voice_mode:
|
if reply_voice_mode:
|
||||||
@@ -341,6 +346,20 @@ class CloudClient(LinkAIClient):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
|
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# value helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _to_bool(value) -> bool:
|
||||||
|
"""Normalize a remote config value to bool (bool / "Y"/"N" / "true"/"1")."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in ("y", "yes", "true", "1", "on")
|
||||||
|
return False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# channel credentials helpers
|
# channel credentials helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -855,6 +874,10 @@ def _build_config():
|
|||||||
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
||||||
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
||||||
"agent_max_steps": local_conf.get("agent_max_steps"),
|
"agent_max_steps": local_conf.get("agent_max_steps"),
|
||||||
|
# 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"),
|
||||||
"channelType": local_conf.get("channel_type"),
|
"channelType": local_conf.get("channel_type"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 厂商类型
|
# Provider types
|
||||||
OPEN_AI = "openAI"
|
OPEN_AI = "openAI"
|
||||||
OPENAI = "openai"
|
OPENAI = "openai"
|
||||||
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
||||||
@@ -8,48 +8,49 @@ XUNFEI = "xunfei"
|
|||||||
CHATGPTONAZURE = "chatGPTOnAzure"
|
CHATGPTONAZURE = "chatGPTOnAzure"
|
||||||
LINKAI = "linkai"
|
LINKAI = "linkai"
|
||||||
CLAUDEAPI= "claudeAPI"
|
CLAUDEAPI= "claudeAPI"
|
||||||
QWEN = "qwen" # 千问 (兼容旧配置,实际走 DashscopeBot)
|
QWEN = "qwen" # legacy alias, actually routed to DashscopeBot
|
||||||
QWEN_DASHSCOPE = "dashscope" # 千问 DashScope 接入
|
QWEN_DASHSCOPE = "dashscope" # Qwen via DashScope
|
||||||
GEMINI = "gemini"
|
GEMINI = "gemini"
|
||||||
ZHIPU_AI = "zhipu"
|
ZHIPU_AI = "zhipu"
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
MiniMax = "minimax"
|
MiniMax = "minimax"
|
||||||
DEEPSEEK = "deepseek"
|
DEEPSEEK = "deepseek"
|
||||||
MIMO = "mimo" # 小米 MiMo 大模型
|
MIMO = "mimo" # Xiaomi MiMo
|
||||||
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
|
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
|
||||||
MODELSCOPE = "modelscope"
|
MODELSCOPE = "modelscope"
|
||||||
|
|
||||||
# 模型列表
|
# Model list
|
||||||
# Claude (Anthropic)
|
# Claude (Anthropic)
|
||||||
CLAUDE3 = "claude-3-opus-20240229"
|
CLAUDE3 = "claude-3-opus-20240229"
|
||||||
CLAUDE_3_OPUS = "claude-3-opus-latest"
|
CLAUDE_3_OPUS = "claude-3-opus-latest"
|
||||||
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
|
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
|
||||||
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
|
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
|
||||||
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
||||||
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名称,会不断更新指向最新发布的模型
|
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to the newest release
|
||||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型
|
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent推荐模型
|
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
||||||
|
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||||
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent推荐模型
|
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model
|
||||||
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent推荐模型
|
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model
|
||||||
|
|
||||||
# Gemini (Google)
|
# Gemini (Google)
|
||||||
GEMINI_PRO = "gemini-1.0-pro"
|
GEMINI_PRO = "gemini-1.0-pro"
|
||||||
GEMINI_15_flash = "gemini-1.5-flash"
|
GEMINI_15_flash = "gemini-1.5-flash"
|
||||||
GEMINI_15_PRO = "gemini-1.5-pro"
|
GEMINI_15_PRO = "gemini-1.5-pro"
|
||||||
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持
|
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # "-exp" models are experimental and will be phased out
|
||||||
GEMINI_20_FLASH = "gemini-2.0-flash" # 正式版模型
|
GEMINI_20_FLASH = "gemini-2.0-flash" # stable release
|
||||||
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
||||||
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
||||||
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent推荐模型
|
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent recommended model
|
||||||
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview
|
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview
|
||||||
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent推荐模型
|
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent recommended model
|
||||||
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent推荐模型
|
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent recommended model
|
||||||
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent推荐模型
|
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent recommended model
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
GPT35 = "gpt-3.5-turbo"
|
GPT35 = "gpt-3.5-turbo"
|
||||||
@@ -85,10 +86,10 @@ TTS_1 = "tts-1"
|
|||||||
TTS_1_HD = "tts-1-hd"
|
TTS_1_HD = "tts-1-hd"
|
||||||
|
|
||||||
# DeepSeek
|
# DeepSeek
|
||||||
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3对话模型
|
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3 chat model
|
||||||
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型
|
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1 model
|
||||||
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思考模式 + 工具调用)
|
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - default recommendation (thinking + tool calls)
|
||||||
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用)
|
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - stronger on complex tasks (thinking + tool calls)
|
||||||
|
|
||||||
# Baidu Qianfan / ERNIE
|
# Baidu Qianfan / ERNIE
|
||||||
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
|
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
|
||||||
@@ -100,30 +101,31 @@ ERNIE_4_TURBO_8K = "ERNIE-4.0-Turbo-8K"
|
|||||||
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
|
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
|
||||||
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
|
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
|
||||||
|
|
||||||
# Qwen (通义千问 - 阿里云 DashScope)
|
# Qwen (Alibaba Cloud DashScope)
|
||||||
QWEN_TURBO = "qwen-turbo"
|
QWEN_TURBO = "qwen-turbo"
|
||||||
QWEN_PLUS = "qwen-plus"
|
QWEN_PLUS = "qwen-plus"
|
||||||
QWEN_MAX = "qwen-max"
|
QWEN_MAX = "qwen-max"
|
||||||
QWEN_LONG = "qwen-long"
|
QWEN_LONG = "qwen-long"
|
||||||
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
|
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent recommended model
|
||||||
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
|
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型
|
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent recommended model
|
||||||
QWQ_PLUS = "qwq-plus"
|
QWQ_PLUS = "qwq-plus"
|
||||||
|
|
||||||
# MiniMax
|
# MiniMax
|
||||||
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
|
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
|
||||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
|
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
|
||||||
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
||||||
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
|
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax multimodal (vision)
|
||||||
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
||||||
|
|
||||||
# GLM (智谱AI)
|
# 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_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
|
GLM_5_1 = "glm-5.1" # GLM-5.1
|
||||||
GLM_5 = "glm-5" # 智谱 GLM-5
|
GLM_5_TURBO = "glm-5-turbo" # GLM-5-Turbo
|
||||||
GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision)
|
GLM_5 = "glm-5" # GLM-5
|
||||||
|
GLM_5V_TURBO = "glm-5v-turbo" # Zhipu multimodal (vision)
|
||||||
GLM_4 = "glm-4"
|
GLM_4 = "glm-4"
|
||||||
GLM_4_PLUS = "glm-4-plus"
|
GLM_4_PLUS = "glm-4-plus"
|
||||||
GLM_4_flash = "glm-4-flash"
|
GLM_4_flash = "glm-4-flash"
|
||||||
@@ -132,20 +134,22 @@ GLM_4_ALLTOOLS = "glm-4-alltools"
|
|||||||
GLM_4_0520 = "glm-4-0520"
|
GLM_4_0520 = "glm-4-0520"
|
||||||
GLM_4_AIR = "glm-4-air"
|
GLM_4_AIR = "glm-4-air"
|
||||||
GLM_4_AIRX = "glm-4-airx"
|
GLM_4_AIRX = "glm-4-airx"
|
||||||
GLM_4_7 = "glm-4.7" # 智谱 GLM-4.7 - Agent推荐模型
|
GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT = "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 = "kimi-k2"
|
||||||
KIMI_K2_5 = "kimi-k2.5"
|
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"
|
||||||
|
|
||||||
# 小米 MiMo
|
# Xiaomi MiMo
|
||||||
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐)
|
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - flagship, long context (default recommendation)
|
||||||
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - 多模态(文/图/音/视频)
|
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - multimodal (text/image/audio/video)
|
||||||
MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro
|
MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro
|
||||||
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - 多模态
|
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - multimodal
|
||||||
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - 极速版
|
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
|
||||||
|
|
||||||
# Doubao (Volcengine Ark)
|
# Doubao (Volcengine Ark)
|
||||||
DOUBAO = "doubao"
|
DOUBAO = "doubao"
|
||||||
@@ -154,11 +158,11 @@ DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
|||||||
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
||||||
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
|
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
|
||||||
|
|
||||||
# ModelScope(魔搭社区)
|
# ModelScope
|
||||||
QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
||||||
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
|
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
|
||||||
|
|
||||||
# 其他模型
|
# Other models
|
||||||
WEN_XIN = "wenxin"
|
WEN_XIN = "wenxin"
|
||||||
WEN_XIN_4 = "wenxin-4"
|
WEN_XIN_4 = "wenxin-4"
|
||||||
XUNFEI = "xunfei"
|
XUNFEI = "xunfei"
|
||||||
@@ -189,11 +193,11 @@ MODEL_LIST = [
|
|||||||
# MiniMax
|
# MiniMax
|
||||||
MiniMax, MINIMAX_M3, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_ABAB6_5,
|
MiniMax, MINIMAX_M3, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_ABAB6_5,
|
||||||
|
|
||||||
# 小米 MiMo
|
# Xiaomi MiMo
|
||||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
@@ -211,19 +215,19 @@ MODEL_LIST = [
|
|||||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||||
O1, O1_MINI,
|
O1, O1_MINI,
|
||||||
|
|
||||||
# GLM (智谱AI)
|
# 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,
|
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
||||||
|
|
||||||
# Qwen (通义千问)
|
# Qwen
|
||||||
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||||
|
|
||||||
# Doubao (豆包)
|
# Doubao
|
||||||
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
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
|
||||||
MODELSCOPE,
|
MODELSCOPE,
|
||||||
@@ -231,11 +235,12 @@ MODEL_LIST = [
|
|||||||
# LinkAI
|
# LinkAI
|
||||||
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
|
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
|
||||||
|
|
||||||
# 其他模型
|
# Other models
|
||||||
WEN_XIN, WEN_XIN_4, XUNFEI,
|
WEN_XIN, WEN_XIN_4, XUNFEI,
|
||||||
]
|
]
|
||||||
|
|
||||||
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
||||||
|
|
||||||
# channel
|
# channel
|
||||||
FEISHU = "feishu"
|
FEISHU = "feishu"
|
||||||
DINGTALK = "dingtalk"
|
DINGTALK = "dingtalk"
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ def compress_imgfile(file, max_size):
|
|||||||
img = Image.open(file)
|
img = Image.open(file)
|
||||||
rgb_image = img.convert("RGB")
|
rgb_image = img.convert("RGB")
|
||||||
quality = 95
|
quality = 95
|
||||||
|
min_quality = 10
|
||||||
while True:
|
while True:
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
rgb_image.save(out_buf, "JPEG", quality=quality)
|
rgb_image.save(out_buf, "JPEG", quality=quality)
|
||||||
if fsize(out_buf) <= max_size:
|
if fsize(out_buf) <= max_size or quality <= min_quality:
|
||||||
|
# Stop at min_quality: further decrements would pass an invalid
|
||||||
|
# quality (<1) to PIL and the loop would otherwise never terminate
|
||||||
|
# for images that cannot be compressed below max_size.
|
||||||
return out_buf
|
return out_buf
|
||||||
quality -= 5
|
quality -= 5
|
||||||
|
|
||||||
|
|||||||
51
config.py
51
config.py
@@ -24,8 +24,11 @@ available_setting = {
|
|||||||
"open_ai_api_base": "https://api.openai.com/v1",
|
"open_ai_api_base": "https://api.openai.com/v1",
|
||||||
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
||||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
||||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom")
|
"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")
|
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
|
||||||
|
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
|
||||||
|
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
|
||||||
|
"custom_providers": [],
|
||||||
"proxy": "", # proxy used by openai
|
"proxy": "", # proxy used by openai
|
||||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
||||||
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
|
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
|
||||||
@@ -180,6 +183,11 @@ available_setting = {
|
|||||||
# WeCom smart bot config (long connection mode)
|
# WeCom smart bot config (long connection mode)
|
||||||
"wecom_bot_id": "", # WeCom smart bot BotID
|
"wecom_bot_id": "", # WeCom smart bot BotID
|
||||||
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
|
"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 config
|
||||||
"telegram_token": "", # Bot token from @BotFather
|
"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)
|
"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)
|
||||||
@@ -253,8 +261,8 @@ available_setting = {
|
|||||||
"knowledge": True, # whether to enable the knowledge base feature
|
"knowledge": True, # whether to enable the knowledge base feature
|
||||||
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
|
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
|
||||||
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
||||||
"self_evolution_idle_minutes": 15, # idle time before a session is reviewed
|
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
|
||||||
"self_evolution_min_turns": 8, # min user turns (or context pressure) to trigger
|
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
|
||||||
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
|
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
|
||||||
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
||||||
}
|
}
|
||||||
@@ -321,24 +329,37 @@ class Config(dict):
|
|||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_value(val):
|
||||||
|
"""Mask a sensitive string value, keeping first 3 and last 3 chars."""
|
||||||
|
if not isinstance(val, str) or len(val) <= 8:
|
||||||
|
return val
|
||||||
|
return val[0:3] + "*" * 5 + val[-3:]
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_sensitive_recursive(obj):
|
||||||
|
"""Recursively mask values whose keys contain 'key' or 'secret'."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
masked = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
if ("key" in k or "secret" in k) and isinstance(v, str):
|
||||||
|
masked[k] = _mask_value(v)
|
||||||
|
else:
|
||||||
|
masked[k] = _mask_sensitive_recursive(v)
|
||||||
|
return masked
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [_mask_sensitive_recursive(item) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def drag_sensitive(config):
|
def drag_sensitive(config):
|
||||||
try:
|
try:
|
||||||
if isinstance(config, str):
|
if isinstance(config, str):
|
||||||
conf_dict: dict = json.loads(config)
|
conf_dict: dict = json.loads(config)
|
||||||
conf_dict_copy = copy.deepcopy(conf_dict)
|
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
|
||||||
for key in conf_dict_copy:
|
|
||||||
if "key" in key or "secret" in key:
|
|
||||||
if isinstance(conf_dict_copy[key], str):
|
|
||||||
conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:]
|
|
||||||
return json.dumps(conf_dict_copy, indent=4)
|
return json.dumps(conf_dict_copy, indent=4)
|
||||||
|
|
||||||
elif isinstance(config, dict):
|
elif isinstance(config, dict):
|
||||||
config_copy = copy.deepcopy(config)
|
return _mask_sensitive_recursive(config)
|
||||||
for key in config:
|
|
||||||
if "key" in key or "secret" in key:
|
|
||||||
if isinstance(config_copy[key], str):
|
|
||||||
config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:]
|
|
||||||
return config_copy
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ services:
|
|||||||
DINGTALK_CLIENT_SECRET: ''
|
DINGTALK_CLIENT_SECRET: ''
|
||||||
WECOM_BOT_ID: ''
|
WECOM_BOT_ID: ''
|
||||||
WECOM_BOT_SECRET: ''
|
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_HOST: '127.0.0.1'
|
||||||
WEB_PASSWORD: ''
|
WEB_PASSWORD: ''
|
||||||
AGENT: 'True'
|
AGENT: 'True'
|
||||||
AGENT_MAX_CONTEXT_TOKENS: 50000
|
AGENT_MAX_CONTEXT_TOKENS: 50000
|
||||||
AGENT_MAX_CONTEXT_TURNS: 20
|
AGENT_MAX_CONTEXT_TURNS: 20
|
||||||
AGENT_MAX_STEPS: 20
|
AGENT_MAX_STEPS: 20
|
||||||
|
SELF_EVOLUTION_ENABLED: 'True'
|
||||||
volumes:
|
volumes:
|
||||||
- ./cow:/home/agent/cow
|
- ./cow:/home/agent/cow
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
"label": "官网",
|
"label": "官网",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "博客",
|
||||||
|
"href": "https://cowagent.ai/zh/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -51,6 +55,10 @@
|
|||||||
"label": "Website",
|
"label": "Website",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "Blog",
|
||||||
|
"href": "https://cowagent.ai/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -241,6 +249,7 @@
|
|||||||
"group": "Release Notes",
|
"group": "Release Notes",
|
||||||
"pages": [
|
"pages": [
|
||||||
"releases/overview",
|
"releases/overview",
|
||||||
|
"releases/v2.1.2",
|
||||||
"releases/v2.1.1",
|
"releases/v2.1.1",
|
||||||
"releases/v2.1.0",
|
"releases/v2.1.0",
|
||||||
"releases/v2.0.9",
|
"releases/v2.0.9",
|
||||||
@@ -267,6 +276,10 @@
|
|||||||
"label": "官网",
|
"label": "官网",
|
||||||
"href": "https://cowagent.ai/?lang=zh"
|
"href": "https://cowagent.ai/?lang=zh"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "博客",
|
||||||
|
"href": "https://cowagent.ai/zh/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -457,6 +470,7 @@
|
|||||||
"group": "发布记录",
|
"group": "发布记录",
|
||||||
"pages": [
|
"pages": [
|
||||||
"zh/releases/overview",
|
"zh/releases/overview",
|
||||||
|
"zh/releases/v2.1.2",
|
||||||
"zh/releases/v2.1.1",
|
"zh/releases/v2.1.1",
|
||||||
"zh/releases/v2.1.0",
|
"zh/releases/v2.1.0",
|
||||||
"zh/releases/v2.0.9",
|
"zh/releases/v2.0.9",
|
||||||
@@ -483,6 +497,10 @@
|
|||||||
"label": "ウェブサイト",
|
"label": "ウェブサイト",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "ブログ",
|
||||||
|
"href": "https://cowagent.ai/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -673,6 +691,7 @@
|
|||||||
"group": "リリースノート",
|
"group": "リリースノート",
|
||||||
"pages": [
|
"pages": [
|
||||||
"ja/releases/overview",
|
"ja/releases/overview",
|
||||||
|
"ja/releases/v2.1.2",
|
||||||
"ja/releases/v2.1.1",
|
"ja/releases/v2.1.1",
|
||||||
"ja/releases/v2.1.0",
|
"ja/releases/v2.1.0",
|
||||||
"ja/releases/v2.0.9",
|
"ja/releases/v2.0.9",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistan
|
|||||||
|
|
||||||
CowAgent's architecture consists of the following core modules:
|
CowAgent's architecture consists of the following core modules:
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" />
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" />
|
||||||
|
|
||||||
| Module | Description |
|
| Module | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
|
|||||||
|
|
||||||
## 🏗️ アーキテクチャ
|
## 🏗️ アーキテクチャ
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
|
||||||
|
|
||||||
CowAgent は完全な **Agent Harness** です:メッセージは各種**チャネル**から流入し、**Agent Core** が記憶・知識・利用可能なツール/Skill を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。
|
CowAgent は完全な **Agent Harness** です:メッセージは各種**チャネル**から流入し、**Agent Core** が記憶・知識・利用可能なツール/Skill を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。
|
||||||
|
|
||||||
@@ -103,14 +103,14 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [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 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [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 | ✅ | ✅ | | | ✅ | |
|
| [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.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)。
|
> **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)。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ CowAgent 2.0 は、シンプルなチャットボットから、自律的な思
|
|||||||
|
|
||||||
CowAgent のアーキテクチャは以下のコアモジュールで構成されています:
|
CowAgent のアーキテクチャは以下のコアモジュールで構成されています:
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" />
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" />
|
||||||
|
|
||||||
| モジュール | 説明 |
|
| モジュール | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ description: Self-Evolution — 会話がアイドル状態になった後に振
|
|||||||
|
|
||||||
自律進化は定時実行ではなく、**会話が自然に終わってアイドル状態になった後**にのみ起動するため、進行中のやり取りを妨げることはありません。次の 2 つの条件を同時に満たす必要があります:
|
自律進化は定時実行ではなく、**会話が自然に終わってアイドル状態になった後**にのみ起動するため、進行中のやり取りを妨げることはありません。次の 2 つの条件を同時に満たす必要があります:
|
||||||
|
|
||||||
- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 15 分)以上が経過している
|
- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 10 分)以上が経過している
|
||||||
- **振り返るだけの内容がある**:前回の進化から十分なターン数が蓄積されている、またはコンテキストが容量の上限に近づいている
|
- **振り返るだけの内容がある**:前回の進化から十分なターン数が蓄積されている、またはコンテキストが容量の上限に近づいている
|
||||||
|
|
||||||
両方の条件を満たしたときにのみ振り返りが始まります。これにより、振り返る価値のある内容を確保しつつ、会話の途中で邪魔をしないようにしています。
|
両方の条件を満たしたときにのみ振り返りが始まります。これにより、振り返る価値のある内容を確保しつつ、会話の途中で邪魔をしないようにしています。
|
||||||
@@ -41,8 +41,8 @@ description: Self-Evolution — 会話がアイドル状態になった後に振
|
|||||||
| パラメータ | 説明 | デフォルト値 |
|
| パラメータ | 説明 | デフォルト値 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `self_evolution_enabled` | 自律進化を有効にするかどうか(新規インストールはデフォルトで有効) | `false` |
|
| `self_evolution_enabled` | 自律進化を有効にするかどうか(新規インストールはデフォルトで有効) | `false` |
|
||||||
| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `15` |
|
| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `10` |
|
||||||
| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `8` |
|
| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `6` |
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Web コンソールでは有効・無効のスイッチのみを提供しています。アイドル時間やターン数のしきい値を変更したい場合は、設定ファイルを編集してください。変更は即時に反映され、再起動は不要です。
|
Web コンソールでは有効・無効のスイッチのみを提供しています。アイドル時間やターン数のしきい値を変更したい場合は、設定ファイルを編集してください。変更は即時に反映され、再起動は不要です。
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-8",
|
"model": "claude-fable-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
| `model` | `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
||||||
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
||||||
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
| モデル | 用途 |
|
| モデル | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-opus-4-8` | デフォルト推奨。最新フラッグシップ。複雑な推論や長いタスクチェーンに最適 |
|
| `claude-fable-5` | 最新フラッグシップ。複雑な推論や長いタスクチェーンに最適。価格はやや高め |
|
||||||
| `claude-opus-4-7` | 前世代の Opus フラッグシップ |
|
| `claude-opus-4-8` | 前世代フラッグシップ。性能とコストのバランスが良い |
|
||||||
|
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
||||||
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: カスタム
|
title: カスタム
|
||||||
description: カスタムベンダー設定。サードパーティ API プロキシやローカルモデル向け
|
description: サードパーティ API プロキシやローカルモデル向けのカスタムプロバイダー設定
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenAI 互換プロトコルで接続するサードパーティのモデルサービスや、ローカルにデプロイしたモデルに適しています。例えば:
|
OpenAI 互換プロトコルで接続するモデルサービス向けの設定です。例えば:
|
||||||
|
|
||||||
- **サードパーティ API プロキシ**:統一された API Base から複数のモデルを呼び出す
|
- **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す
|
||||||
- **ローカルモデル**:Ollama、vLLM、LocalAI などのツールでローカルにデプロイしたモデル
|
- **ローカルモデル**:Ollama、vLLM などのツールでローカルにデプロイしたモデル
|
||||||
- **プライベートデプロイ**:企業内部にデプロイしたモデルサービス
|
- **プライベートデプロイ**:企業内部にデプロイされたモデルサービス
|
||||||
|
|
||||||
<Note>
|
## Web コンソールでの設定
|
||||||
`openai` ベンダーとの違い:カスタムベンダーを選択した場合、`/config model` でモデルを切り替えてもベンダータイプは自動で切り替わらず、常にカスタムの API アドレスを使用します。
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## テキスト対話
|
推奨方法です。Web コンソールの「モデル」ページで「プロバイダーを追加」をクリックし、「カスタム」を選択して、名称・API Base・API Key を入力します。複数のカスタムプロバイダーを追加でき、追加後は「メインモデル」でプロバイダーとモデルを選択すると有効になります。
|
||||||
|
|
||||||
### サードパーティ API プロキシ
|
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
|
||||||
|
|
||||||
```json
|
主なローカルデプロイツールのデフォルトアドレス:
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "",
|
|
||||||
"custom_api_key": "YOUR_API_KEY",
|
|
||||||
"custom_api_base": "https://{your-proxy.com}/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `bot_type` | `custom` に設定する必要があります |
|
|
||||||
| `model` | モデル名。プロキシサービスがサポートする任意のモデル名を指定 |
|
|
||||||
| `custom_api_key` | API キー。プロキシサービスから提供されます |
|
|
||||||
| `custom_api_base` | API アドレス。プロキシサービスから提供され、OpenAI プロトコル互換である必要があります |
|
|
||||||
|
|
||||||
### ローカルモデル
|
|
||||||
|
|
||||||
ローカルモデルは通常 API Key が不要で、API Base のみ設定します:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "qwen3.5:27b",
|
|
||||||
"custom_api_base": "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
一般的なローカルデプロイツールとデフォルトアドレス:
|
|
||||||
|
|
||||||
| ツール | デフォルト API Base |
|
| ツール | デフォルト API Base |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -53,10 +23,40 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
|
|||||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||||
|
|
||||||
### モデル切り替え
|
## 設定ファイルでの設定
|
||||||
|
|
||||||
カスタムベンダーでモデルを切り替える際は `model` のみが変更され、`bot_type` と API アドレスは変わりません:
|
`config.json` を直接編集することもできます。`custom_providers` リストに複数のプロバイダーを定義し、`bot_type` を `"custom:<id>"` に設定していずれかを有効化します:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "custom:3f2a9c1b",
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
|
"name": "プロバイダーA",
|
||||||
|
"api_key": "YOUR_API_KEY_A",
|
||||||
|
"api_base": "https://api.a.com/v1",
|
||||||
|
"model": "deepseek-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
|
"name": "プロバイダーB",
|
||||||
|
"api_key": "YOUR_API_KEY_B",
|
||||||
|
"api_base": "https://api.b.com/v1",
|
||||||
|
"model": "qwen3-max"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
/config model qwen3.5:27b
|
|
||||||
```
|
| パラメータ | 説明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `custom_providers` | カスタムプロバイダーのリスト。各項目は `id`、`name`、`api_base`、`api_key`(任意)、`model`(任意)を含む |
|
||||||
|
| `bot_type` | `"custom:<id>"` に設定して対応するプロバイダーを有効化 |
|
||||||
|
| `id` | 一意の識別子(8 桁の 16 進数)。Web コンソールから追加すると自動生成され、手動設定の場合は重複しない任意の文字列でよい |
|
||||||
|
| `name` | 表示名。自由に変更可能 |
|
||||||
|
| `model` | このプロバイダーで使用するモデル。有効化時に適用される |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
従来の単一プロバイダー設定(`bot_type` を `"custom"` にし、`custom_api_key` / `custom_api_base` を使用)は引き続き互換性があり、変更なしでそのまま利用できます。
|
||||||
|
</Note>
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ Zhipu AI はテキスト対話、画像理解、音声認識(ASR)、ベク
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "glm-5.1",
|
"model": "glm-5.2",
|
||||||
"zhipu_ai_api_key": "YOUR_API_KEY"
|
"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_key` | [Zhipu AI コンソール](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) で作成 |
|
||||||
| `zhipu_ai_api_base` | 任意。デフォルトは `https://open.bigmodel.cn/api/paas/v4` |
|
| `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 ツールは自動的にこのモデルを使用するため、設定ファイルで明示的に指定する必要はありません。
|
||||||
|
|
||||||
## 音声認識
|
## 音声認識
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.7-plus、kimi-k2.6、ernie-5.1。
|
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
||||||
|
|
||||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -20,13 +20,13 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [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 | ✅ | ✅ | | | | |
|
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.6",
|
"model": "kimi-k2.7-code",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"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_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
||||||
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.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 など)、デプロイ・セキュリティ強化 |
|
| [2.0.9](/ja/releases/v2.0.9) | 2026.05.22 | モデル管理機能の追加、MCP プロトコル対応、ブラウザログイン状態の永続化、新モデル追加(gpt-5.5、gemini-3.5-flash、qwen3.7-max など)、デプロイ・セキュリティ強化 |
|
||||||
|
|||||||
67
docs/ja/releases/v2.1.2.mdx
Normal file
67
docs/ja/releases/v2.1.2.mdx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.2
|
||||||
|
description: CowAgent 2.1.2 - Web コンソールの管理機能強化、自己進化の改善、新モデル、企業微信スマートボットのコールバックモード、セキュリティ強化
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
|
||||||
|
|
||||||
|
## 💬 Web コンソールの改善
|
||||||
|
|
||||||
|
本リリースでは Web コンソールに複数の可視化管理機能を追加し、ファイルを編集せずに UI 上でより多くの設定を行えるようになりました:
|
||||||
|
|
||||||
|
- **定期タスク管理**:コンソール上で任意の定期タスクを直接表示・編集・有効化/無効化・削除できます。タスク一覧は有効状態を優先し、次回実行時刻の順にソートされます。Thanks @HnBigVolibear (#2892)
|
||||||
|
- **ナレッジベースのカテゴリと文書管理**:ナレッジベースをカテゴリで整理し、各カテゴリ配下の文書を UI で管理できるようになりました。Thanks @yangziyu-hhh (#2893)
|
||||||
|
- **複数のカスタムモデルプロバイダー**:複数の OpenAI 互換プロバイダーを設定し、有効なものをワンクリックで切り替えられます。既存の設定とも完全に互換です。Thanks @kirs-hi (#2877)
|
||||||
|
- **セッションのリネーム**:セッションを手動でリネームでき、並行する複数のタスクを区別しやすくなります (#2897)
|
||||||
|
- **Bash のストリーミング出力**:長時間実行される Bash コマンドの進捗をリアルタイムにストリーミング出力します。Thanks @yangziyu-hhh (#2879)
|
||||||
|
|
||||||
|
## 🧬 自己進化の改善
|
||||||
|
|
||||||
|
前バージョンで導入した自己進化を、本バージョンでさらに改善しました:
|
||||||
|
|
||||||
|
- **トリガー閾値の引き下げ**:デフォルトのレビュー閾値を引き下げ、日々の協働がより早く改善へとつながります
|
||||||
|
- **同時レビューの回避**:単一ターンの処理が長引いた場合に、アイドルレビューが誤って起動しなくなり、進行中の会話との干渉を回避します
|
||||||
|
- **レビュー要約の改善**:要約生成のプロンプトを改善し、要約を簡潔に保ちつつ情報密度を高め、会話の言語で出力します
|
||||||
|
|
||||||
|
ドキュメント:[自己進化](https://docs.cowagent.ai/ja/memory/self-evolution)
|
||||||
|
|
||||||
|
## 🤖 新モデル
|
||||||
|
|
||||||
|
- **kimi-k2.7-code**:追加して Kimi のデフォルトモデルに設定。高速版の `kimi-k2.7-code-highspeed` も利用できます
|
||||||
|
- **glm-5.2**:追加して GLM のデフォルトモデルに設定
|
||||||
|
|
||||||
|
ドキュメント:[モデル概要](https://docs.cowagent.ai/ja/models)
|
||||||
|
|
||||||
|
## 🏢 企業微信スマートボットのコールバックモード
|
||||||
|
|
||||||
|
企業微信スマートボットのチャネルに、既存のロングコネクションに加えて **HTTP コールバックモード** を追加しました。ロングコネクションを維持できない環境でも安定して接続できます:
|
||||||
|
|
||||||
|
- **モード切替**:`wecom_bot_mode` で `websocket`(ロングコネクション)と `webhook`(コールバック)を切り替えます
|
||||||
|
- **暗号化通信**:コールバックモードは URL 検証、メッセージ復号、受動応答の暗号化に完全対応します
|
||||||
|
- **安定性の修正**:応答の中断、ストリームの早期終了、一時画像ファイルのリークなどの問題を修正しました
|
||||||
|
|
||||||
|
Thanks @6vision (#2896 #2869)
|
||||||
|
|
||||||
|
ドキュメント:[企業微信スマートボット](https://docs.cowagent.ai/ja/channels/wecom-bot)
|
||||||
|
|
||||||
|
## 🔒 セキュリティ強化
|
||||||
|
|
||||||
|
- **Vision ツールの SSRF 対策**:画像 URL を解決する前に対象アドレスを検証し、内部・ループバック・クラウドサーバーのメタデータエンドポイントへのリクエストをブロックします。Thanks @kirs-hi (#2886)
|
||||||
|
- **Web フェッチの SSRF 対策**:`web_fetch` は取得前に対象アドレスを検証し、リダイレクトのたびに再検証することで、リダイレクト経由で検証を回避して内部アドレスへ到達することを防ぎます。Thanks @christop (#2900)
|
||||||
|
- **Skill インストールのパストラバーサル対策**:Skill のインストール時にパスを検証し、悪意ある Skill 名がパストラバーサルで `skills/` ディレクトリを抜け出して許可されない場所に書き込むことを防ぎます。Thanks @kirs-hi (#2886)
|
||||||
|
|
||||||
|
## 🛠 改善と修正
|
||||||
|
|
||||||
|
- **CLI セルフ再起動**:self-restart コマンドを追加し、Agent が自身のプロセスを再起動できるようになりました
|
||||||
|
- **Windows 互換性**:cow CLI のディレクトリをユーザー PATH に永続化。`python -c` の長いコマンドが `cmd.exe` の長さ制限を超える問題を修正。インストール時に greenlet をソースからビルドしないように修正
|
||||||
|
- **カスタムロール**:ロールプラグインが `roles/*.json` 配下の独立したプロンプトファイルによるカスタマイズに対応しました。Thanks @sufan721 (#2891)
|
||||||
|
- **安定性の修正**:`/cancel` 時の KeyError と画像圧縮の無限ループを修正(Thanks @kirs-hi #2888)
|
||||||
|
- **インストールの改善**:起動スクリプトとデフォルト設定を更新。ASR/TTS のデフォルト値、自己進化フラグ、インストール時のハングを修正
|
||||||
|
- **Vision ツールの安定性**:Vision ツールのタイムアウトと max_tokens を引き上げ
|
||||||
|
- **記憶の蒸留**:ディープドリーム蒸留の出力長制限を撤廃し、大きな `MEMORY.md` が切り詰められないようにしました
|
||||||
|
|
||||||
|
## 📦 アップグレード
|
||||||
|
|
||||||
|
ソースコードでデプロイしている場合は `cow update` でワンクリックアップグレードするか、最新コードを取得して手動で再起動してください。詳細は [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照してください。
|
||||||
|
|
||||||
|
**リリース日**:2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)
|
||||||
@@ -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.
|
Self-Evolution lets the Agent do more than finish one task at a time; it keeps improving as it works with you. After a conversation winds down, it quietly reviews what just happened: it saves anything worth remembering into long-term memory, fixes problems that surfaced in a skill, and picks up tasks that were left unfinished. Over time the Agent learns your preferences, repeats fewer mistakes, and gets better at wrapping things up on its own. All of this runs in the background, and it only tells you when it actually did something.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
For the full architecture and engineering behind the self-evolution mechanism, see the blog post: [A Five-Layer Self-Evolution Mechanism for AI Agents](https://cowagent.ai/blog/self-evolution/).
|
||||||
|
</Note>
|
||||||
|
|
||||||
> Self-Evolution complements [Deep Dream](/memory/deep-dream). Deep Dream organizes memory itself, while Self-Evolution goes a step further to improve skills and push unfinished tasks forward, sharpening the Agent's abilities through everyday use.
|
> Self-Evolution complements [Deep Dream](/memory/deep-dream). Deep Dream organizes memory itself, while Self-Evolution goes a step further to improve skills and push unfinished tasks forward, sharpening the Agent's abilities through everyday use.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
@@ -33,7 +37,7 @@ Once a review is done, if it actually changed something, the Agent tells you in
|
|||||||
|
|
||||||
Self-Evolution does not run on a fixed schedule. It only kicks in **after a conversation naturally ends and goes idle**, so it never interrupts an ongoing exchange. Two conditions must both hold:
|
Self-Evolution does not run on a fixed schedule. It only kicks in **after a conversation naturally ends and goes idle**, so it never interrupts an ongoing exchange. Two conditions must both hold:
|
||||||
|
|
||||||
- **The conversation is idle**: more time has passed since the last interaction than the configured idle window (15 minutes by default)
|
- **The conversation is idle**: more time has passed since the last interaction than the configured idle window (10 minutes by default)
|
||||||
- **There is enough to review**: enough turns have accumulated since the last evolution, or the context is close to its capacity
|
- **There is enough to review**: enough turns have accumulated since the last evolution, or the context is close to its capacity
|
||||||
|
|
||||||
Only when both are met does a review begin. This makes sure there is something worth reviewing while keeping it from bothering you mid-conversation.
|
Only when both are met does a review begin. This makes sure there is something worth reviewing while keeping it from bothering you mid-conversation.
|
||||||
@@ -45,8 +49,8 @@ You can toggle Self-Evolution in the Web console under **Settings → Agent Conf
|
|||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `self_evolution_enabled` | Whether Self-Evolution is enabled (on by default for new installs) | `false` |
|
| `self_evolution_enabled` | Whether Self-Evolution is enabled (on by default for new installs) | `false` |
|
||||||
| `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `15` |
|
| `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `10` |
|
||||||
| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `8` |
|
| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `6` |
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-config.png" alt="Enable Self-Evolution in the Web console" />
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-config.png" alt="Enable Self-Evolution in the Web console" />
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-8",
|
"model": "claude-fable-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Supports `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | Supports `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
||||||
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
| Model | Use Case |
|
| Model | Use Case |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-opus-4-8` | Default recommended, latest flagship; best for complex reasoning and long-running tasks |
|
| `claude-fable-5` | Latest flagship; best for complex reasoning and long-running tasks, at a higher price |
|
||||||
| `claude-opus-4-7` | Previous-generation Opus flagship |
|
| `claude-opus-4-8` | Previous flagship with balanced quality and cost |
|
||||||
|
| `claude-opus-4-7` | Earlier Opus flagship |
|
||||||
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: Custom
|
title: Custom
|
||||||
description: Custom vendor configuration for third-party API proxies and local models
|
description: Custom provider configuration for third-party API proxies and local models
|
||||||
---
|
---
|
||||||
|
|
||||||
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
|
For model services accessed via the OpenAI-compatible protocol, such as:
|
||||||
|
|
||||||
- **Third-party API proxies**: call multiple models through a unified API base
|
- **Third-party API proxies**: call multiple models through a unified API base
|
||||||
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
|
- **Local models**: models deployed locally with tools like Ollama, vLLM
|
||||||
- **Private deployments**: model services deployed inside an enterprise
|
- **Private deployments**: model services deployed inside an enterprise
|
||||||
|
|
||||||
<Note>
|
## Web Console
|
||||||
Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Text Chat
|
Recommended. On the "Models" page of the Web console, click "Add Provider" and pick "Custom", then fill in the name, API Base and API Key. Multiple custom providers can be added; after adding one, select it together with a model in the "Main Model" card to enable it.
|
||||||
|
|
||||||
### Third-party API proxy
|
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
|
||||||
|
|
||||||
```json
|
Default endpoints of common local deployment tools:
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "",
|
|
||||||
"custom_api_key": "YOUR_API_KEY",
|
|
||||||
"custom_api_base": "https://{your-proxy.com}/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parameter | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `bot_type` | Must be set to `custom` |
|
|
||||||
| `model` | Model name; any model name supported by the proxy service |
|
|
||||||
| `custom_api_key` | API key provided by the proxy service |
|
|
||||||
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
|
|
||||||
|
|
||||||
### Local models
|
|
||||||
|
|
||||||
Local models usually do not require an API key — only the API base needs to be filled in:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "qwen3.5:27b",
|
|
||||||
"custom_api_base": "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Common local deployment tools and their default endpoints:
|
|
||||||
|
|
||||||
| Tool | Default API Base |
|
| Tool | Default API Base |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -53,10 +23,40 @@ Common local deployment tools and their default endpoints:
|
|||||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||||
|
|
||||||
### Switching Models
|
## Configuration File
|
||||||
|
|
||||||
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged:
|
You can also edit `config.json` directly: define multiple providers in the `custom_providers` list and set `bot_type` to `"custom:<id>"` to activate one of them:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "custom:3f2a9c1b",
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
|
"name": "ProviderA",
|
||||||
|
"api_key": "YOUR_API_KEY_A",
|
||||||
|
"api_base": "https://api.a.com/v1",
|
||||||
|
"model": "deepseek-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
|
"name": "ProviderB",
|
||||||
|
"api_key": "YOUR_API_KEY_B",
|
||||||
|
"api_base": "https://api.b.com/v1",
|
||||||
|
"model": "qwen3-max"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
/config model qwen3.5:27b
|
|
||||||
```
|
| Parameter | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_base`, `api_key` (optional) and `model` (optional) |
|
||||||
|
| `bot_type` | Set to `"custom:<id>"` to activate the corresponding provider |
|
||||||
|
| `id` | Unique identifier (8-char hex); auto-generated when adding via the Web console, or any unique string when editing manually |
|
||||||
|
| `name` | Display label, can be renamed freely |
|
||||||
|
| `model` | Model used by this provider, takes effect when activated |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The legacy single-provider configuration (`bot_type` set to `"custom"` with `custom_api_key` / `custom_api_base`) remains fully compatible and keeps working without any changes.
|
||||||
|
</Note>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: DeepSeek
|
|||||||
description: DeepSeek model configuration (Text Chat + Thinking Mode)
|
description: DeepSeek model configuration (Text Chat + Thinking Mode)
|
||||||
---
|
---
|
||||||
|
|
||||||
DeepSeek is one of the default recommended vendors in Agent mode, focused on cost-effective text chat and task planning.
|
DeepSeek is one of the default recommended providers in Agent mode, focused on cost-effective text chat and task planning.
|
||||||
|
|
||||||
## Text Chat
|
## Text Chat
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ Zhipu AI supports text chat, image understanding, speech-to-text (ASR), and embe
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "glm-5.1",
|
"model": "glm-5.2",
|
||||||
"zhipu_ai_api_key": "YOUR_API_KEY"
|
"zhipu_ai_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| 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_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` |
|
| `zhipu_ai_api_base` | Optional, defaults to `https://open.bigmodel.cn/api/paas/v4` |
|
||||||
|
|
||||||
## Image Understanding
|
## 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)
|
## Speech-to-Text (ASR)
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
---
|
---
|
||||||
title: Models Overview
|
title: Models Overview
|
||||||
description: Model vendors supported by CowAgent and their capability matrix
|
description: Model providers supported by CowAgent and their capability matrix
|
||||||
---
|
---
|
||||||
|
|
||||||
CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several vendors also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow.
|
CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several providers also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow.
|
||||||
|
|
||||||
## Capability Matrix
|
## Capability Matrix
|
||||||
|
|
||||||
A snapshot of each vendor's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the vendor can power.
|
A snapshot of each provider's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the provider can power.
|
||||||
|
|
||||||
| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding |
|
| Provider | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [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 | ✅ | ✅ | | | | |
|
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | |
|
| [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | |
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own vendor and model — there is no forced binding between them.
|
Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own provider and model — there is no forced binding between them.
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
## How to Configure
|
## How to Configure
|
||||||
@@ -35,4 +35,4 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model;
|
|||||||
|
|
||||||
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-models-config.png" />
|
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-models-config.png" />
|
||||||
|
|
||||||
**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected vendor. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`.
|
**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected provider. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`.
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.6",
|
"model": "kimi-k2.7-code",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| 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_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` |
|
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: LinkAI
|
|||||||
description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform
|
description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform
|
||||||
---
|
---
|
||||||
|
|
||||||
A single `linkai_api_key` gives you access to all capabilities of mainstream vendors such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao.
|
A single `linkai_api_key` gives you access to all capabilities of mainstream providers such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao.
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Use the global `enable_thinking` flag to toggle visibility (also switchable from
|
|||||||
Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models:
|
Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models:
|
||||||
|
|
||||||
- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup.
|
- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup.
|
||||||
- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order.
|
- When the main model belongs to another provider, the Vision tool falls back to `mimo-v2.5-pro` in order.
|
||||||
|
|
||||||
To force a specific Vision model, set it explicitly in the configuration:
|
To force a specific Vision model, set it explicitly in the configuration:
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
|
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
|
||||||
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
||||||
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
||||||
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other vendors via the compatible protocol |
|
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
||||||
|
|
||||||
## Image Understanding
|
## Image Understanding
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Qwen
|
|||||||
description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding)
|
description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding)
|
||||||
---
|
---
|
||||||
|
|
||||||
Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`.
|
Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured providers. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`.
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: CowAgent version history
|
|||||||
|
|
||||||
| Version | Date | Description |
|
| 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.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.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 |
|
| [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 |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b)
|
|||||||
|
|
||||||
## 💰 Coding Plan Support
|
## 💰 Coding Plan Support
|
||||||
|
|
||||||
Added integration with vendor Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported vendors include Aliyun, MiniMax, GLM, Kimi, and Volcengine.
|
Added integration with provider Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported providers include Aliyun, MiniMax, GLM, Kimi, and Volcengine.
|
||||||
|
|
||||||
See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration.
|
See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generati
|
|||||||
- **Claude Opus 4.7**: Added `claude-opus-4-7` model support
|
- **Claude Opus 4.7**: Added `claude-opus-4-7` model support
|
||||||
- **GLM 5.1**: Added `glm-5.1` model support
|
- **GLM 5.1**: Added `glm-5.1` model support
|
||||||
- **Kimi Coding Plan**: Support for Kimi Coding Plan mode
|
- **Kimi Coding Plan**: Support for Kimi Coding Plan mode
|
||||||
- **Custom model providers**: New custom model provider configuration for easier integration with additional vendors
|
- **Custom model providers**: New custom model provider configuration for easier integration with additional providers
|
||||||
|
|
||||||
## 💬 Web Console Improvements
|
## 💬 Web Console Improvements
|
||||||
|
|
||||||
|
|||||||
67
docs/releases/v2.1.2.mdx
Normal file
67
docs/releases/v2.1.2.mdx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.2
|
||||||
|
description: CowAgent 2.1.2 - Web Console management upgrades, Self-Evolution improvements, new models, WeCom smart-bot callback mode, and security hardening
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
|
||||||
|
|
||||||
|
## 💬 Web Console Improvements
|
||||||
|
|
||||||
|
This release adds several visual management capabilities to the Web Console, so more configuration can be done in the UI without editing files:
|
||||||
|
|
||||||
|
- **Scheduled task management**: View, edit, enable/disable, and delete any scheduled task directly in the console. The task list is sorted by enabled status first, then by next run time. Thanks @HnBigVolibear (#2892)
|
||||||
|
- **Knowledge base categories and document management**: The knowledge base can now be organized by category, with documents under each category managed in the UI. Thanks @yangziyu-hhh (#2893)
|
||||||
|
- **Multiple custom model providers**: Configure multiple OpenAI-compatible providers and switch the active one with a single click, fully compatible with existing configuration. Thanks @kirs-hi (#2877)
|
||||||
|
- **Session renaming**: Rename sessions manually to tell parallel tasks apart (#2897)
|
||||||
|
- **Bash streaming output**: Long-running Bash commands now stream their progress in real time. Thanks @yangziyu-hhh (#2879)
|
||||||
|
|
||||||
|
## 🧬 Self-Evolution Improvements
|
||||||
|
|
||||||
|
Building on the Self-Evolution introduced in the previous release, this version refines it further:
|
||||||
|
|
||||||
|
- **Lower trigger thresholds**: The default review thresholds are lowered, so everyday collaboration turns into improvements sooner
|
||||||
|
- **No concurrent reviews**: When a single turn runs long, the idle review no longer fires by mistake, avoiding interference with the active conversation
|
||||||
|
- **Better review summary**: Refined the summary prompt to keep summaries concise, raise their information density, and output them in the conversation language
|
||||||
|
|
||||||
|
Documentation: [Self-Evolution](https://docs.cowagent.ai/memory/self-evolution)
|
||||||
|
|
||||||
|
## 🤖 New Models
|
||||||
|
|
||||||
|
- **kimi-k2.7-code**: Added and set as the default Kimi model, with `kimi-k2.7-code-highspeed` also available
|
||||||
|
- **glm-5.2**: Added and set as the default GLM model
|
||||||
|
|
||||||
|
Documentation: [Models Overview](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🏢 WeCom Smart-Bot Callback Mode
|
||||||
|
|
||||||
|
The WeCom smart-bot channel adds an **HTTP callback mode** alongside the existing long connection, so deployments that cannot keep a long connection open can still connect reliably:
|
||||||
|
|
||||||
|
- **Mode switching**: Switch between `websocket` (long connection) and `webhook` (callback) via `wecom_bot_mode`
|
||||||
|
- **Encrypted transport**: Callback mode fully supports URL verification, message decryption, and passive-reply encryption
|
||||||
|
- **Stability fixes**: Fixed reply interruption, premature stream termination, and temporary image file leaks
|
||||||
|
|
||||||
|
Thanks @6vision (#2896 #2869)
|
||||||
|
|
||||||
|
Documentation: [WeCom Smart Bot](https://docs.cowagent.ai/channels/wecom-bot)
|
||||||
|
|
||||||
|
## 🔒 Security Hardening
|
||||||
|
|
||||||
|
- **Vision tool SSRF protection**: Validates the target address before resolving an image URL, blocking requests to internal, loopback, and cloud server metadata endpoints. Thanks @kirs-hi (#2886)
|
||||||
|
- **Web fetch SSRF protection**: `web_fetch` validates the target address before fetching and re-validates every redirect hop, preventing redirects from bypassing the check to reach internal addresses. Thanks @christop (#2900)
|
||||||
|
- **Skill install path traversal protection**: Validates the path when installing a skill, preventing a malicious skill name from escaping the `skills/` directory through path traversal and writing to an unauthorized location. Thanks @kirs-hi (#2886)
|
||||||
|
|
||||||
|
## 🛠 Improvements & Fixes
|
||||||
|
|
||||||
|
- **CLI self-restart**: Added the self-restart command so the agent can restart its own process
|
||||||
|
- **Windows compatibility**: Persist the cow CLI directory to the user PATH; fixed `python -c` long commands exceeding the `cmd.exe` length limit; avoid building greenlet from source during install
|
||||||
|
- **Custom roles**: The role plugin supports customization via standalone prompt files under `roles/*.json`. Thanks @sufan721 (#2891)
|
||||||
|
- **Stability fixes**: Fixed a KeyError on `/cancel` and an infinite loop in image compression (Thanks @kirs-hi #2888)
|
||||||
|
- **Install improvements**: Updated the startup script and default config; fixed ASR/TTS defaults, the self-evolution flag, and install hangs
|
||||||
|
- **Vision tool stability**: Increased the vision tool timeout and max_tokens
|
||||||
|
- **Memory distillation**: Removed the output length cap in deep-dream distillation to avoid truncating a large `MEMORY.md`
|
||||||
|
|
||||||
|
## 📦 Upgrade
|
||||||
|
|
||||||
|
Source-code deployments can run `cow update` for a one-click upgrade, or pull the latest code and restart manually. See the [Upgrade Guide](https://docs.cowagent.ai/guide/upgrade) for details.
|
||||||
|
|
||||||
|
**Release Date**: 2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)
|
||||||
@@ -105,13 +105,13 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 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 | ✅ | ✅ | | | | |
|
| [百度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 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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.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)
|
> **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)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处
|
|||||||
|
|
||||||
自主进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。在每段对话告一段落后,它会自动"回头复盘"一次:把使用中暴露的问题修进技能、把没做完的事情接着推进,并把值得记住的沉淀进记忆与知识库。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,当真正做了事情时才会主动地告诉你。
|
自主进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。在每段对话告一段落后,它会自动"回头复盘"一次:把使用中暴露的问题修进技能、把没做完的事情接着推进,并把值得记住的沉淀进记忆与知识库。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,当真正做了事情时才会主动地告诉你。
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
想了解自进化机制完整的架构设计与工程实现,可阅读博客文章:[让 Agent 在对话中成长:自进化机制的五层实现](https://cowagent.ai/zh/blog/self-evolution/)。
|
||||||
|
</Note>
|
||||||
|
|
||||||
> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。
|
> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
@@ -34,8 +38,8 @@ description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处
|
|||||||
|
|
||||||
自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足:
|
自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足:
|
||||||
|
|
||||||
- **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟)
|
- **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 10 分钟)
|
||||||
- **对话有足够内容** — 自上次进化以来累积了足够轮次(默认 8 轮),或上下文已接近容量上限
|
- **对话有足够内容** — 自上次进化以来累积了足够轮次(默认 6 轮),或上下文已接近容量上限
|
||||||
|
|
||||||
只有两个条件都满足,才会启动一次复盘。这样既保证有足够的内容值得复盘,又不会在你还在对话时打扰你。
|
只有两个条件都满足,才会启动一次复盘。这样既保证有足够的内容值得复盘,又不会在你还在对话时打扰你。
|
||||||
|
|
||||||
@@ -46,8 +50,8 @@ description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处
|
|||||||
| 参数 | 说明 | 默认值 |
|
| 参数 | 说明 | 默认值 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `self_evolution_enabled` | 是否启用自主进化 | `false` |
|
| `self_evolution_enabled` | 是否启用自主进化 | `false` |
|
||||||
| `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` |
|
| `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `10` |
|
||||||
| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `8` |
|
| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` |
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-evolution-config-zh.png" alt="在 Web 控制台开启自主进化" />
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-evolution-config-zh.png" alt="在 Web 控制台开启自主进化" />
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-8",
|
"model": "claude-fable-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 支持 `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | 支持 `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
||||||
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
|
|
||||||
| 模型 | 适用场景 |
|
| 模型 | 适用场景 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-opus-4-8` | 默认推荐,最新旗舰,复杂推理与长链路任务效果最佳 |
|
| `claude-fable-5` | 最新旗舰,复杂推理与长链路任务效果最佳,价格较高 |
|
||||||
| `claude-opus-4-7` | 上一代 Opus 旗舰 |
|
| `claude-opus-4-8` | 上一代 Opus 旗舰,综合表现与成本均衡 |
|
||||||
|
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
||||||
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: 自定义
|
title: 自定义
|
||||||
description: 自定义厂商配置,适用于第三方 API 代理和本地模型
|
description: 自定义厂商配置,用于第三方 API 代理与本地模型
|
||||||
---
|
---
|
||||||
|
|
||||||
适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如:
|
适用于通过 OpenAI 兼容协议接入的模型服务,例如:
|
||||||
|
|
||||||
- **第三方 API 代理**:使用统一的 API Base 调用多种模型
|
- **第三方 API 代理**:通过统一的 API 地址调用多种模型
|
||||||
- **本地模型**:通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型
|
- **本地模型**:使用 Ollama、vLLM 等工具在本地部署的模型
|
||||||
- **私有化部署**:企业内部部署的模型服务
|
- **私有化部署**:企业内部部署的模型服务
|
||||||
|
|
||||||
<Note>
|
## Web 端配置
|
||||||
与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## 文本对话
|
推荐方式。在 Web 控制台「模型」页面点击「添加厂商」,选择「自定义」,填写名称、API Base 和 API Key 即可。支持添加多个自定义厂商,添加后在「主模型」中选择对应厂商和模型即可启用。
|
||||||
|
|
||||||
### 第三方 API 代理
|
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
|
||||||
|
|
||||||
```json
|
本地部署工具的默认地址:
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "",
|
|
||||||
"custom_api_key": "YOUR_API_KEY",
|
|
||||||
"custom_api_base": "https://{your-proxy.com}/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `bot_type` | 必须设为 `custom` |
|
|
||||||
| `model` | 模型名称,填写代理服务支持的任意模型名 |
|
|
||||||
| `custom_api_key` | API 密钥,由代理服务提供 |
|
|
||||||
| `custom_api_base` | API 地址,由代理服务提供,需兼容 OpenAI 协议 |
|
|
||||||
|
|
||||||
### 本地模型
|
|
||||||
|
|
||||||
本地模型通常不需要 API Key,只需填写 API Base:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "qwen3.5:27b",
|
|
||||||
"custom_api_base": "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
常见的本地部署工具及默认地址:
|
|
||||||
|
|
||||||
| 工具 | 默认 API Base |
|
| 工具 | 默认 API Base |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -53,10 +23,40 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
|
|||||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||||
|
|
||||||
### 切换模型
|
## 配置文件配置
|
||||||
|
|
||||||
自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址:
|
也可以直接编辑 `config.json`,在 `custom_providers` 列表中定义多个厂商,并将 `bot_type` 设置为 `"custom:<id>"` 来激活其中一个:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "custom:3f2a9c1b",
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
|
"name": "厂商A",
|
||||||
|
"api_key": "YOUR_API_KEY_A",
|
||||||
|
"api_base": "https://api.a.com/v1",
|
||||||
|
"model": "deepseek-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
|
"name": "厂商B",
|
||||||
|
"api_key": "YOUR_API_KEY_B",
|
||||||
|
"api_base": "https://api.b.com/v1",
|
||||||
|
"model": "qwen3-max"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
/config model qwen3.5:27b
|
|
||||||
```
|
| 参数 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `custom_providers` | 自定义厂商列表,每项包含 `id`、`name`、`api_base`、`api_key`(可选)、`model`(可选) |
|
||||||
|
| `bot_type` | 设置为 `"custom:<id>"` 激活对应厂商 |
|
||||||
|
| `id` | 厂商唯一标识(8 位十六进制),在 Web 端添加时自动生成,手动配置时填写任意不重复的标识即可 |
|
||||||
|
| `name` | 展示名称,可随意修改 |
|
||||||
|
| `model` | 该厂商使用的模型,激活时生效 |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
历史的单厂商配置(`bot_type` 为 `"custom"`,配合 `custom_api_key` / `custom_api_base`)仍然兼容,无需改动即可继续使用。
|
||||||
|
</Note>
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 /
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "glm-5.1",
|
"model": "glm-5.2",
|
||||||
"zhipu_ai_api_key": "YOUR_API_KEY"
|
"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_key` | 在 [智谱 AI 控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建 |
|
||||||
| `zhipu_ai_api_base` | 可选,默认为 `https://open.bigmodel.cn/api/paas/v4` |
|
| `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 工具会自动使用该模型,无需在配置文件中显式指定。
|
||||||
|
|
||||||
## 语音识别
|
## 语音识别
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 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 | ✅ | ✅ | | | | |
|
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.6",
|
"model": "kimi-k2.7-code",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"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_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
|
||||||
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 |
|
||||||
| [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 |
|
| [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 |
|
||||||
|
|||||||
67
docs/zh/releases/v2.1.2.mdx
Normal file
67
docs/zh/releases/v2.1.2.mdx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.2
|
||||||
|
description: CowAgent 2.1.2:Web 控制台多项管理能力升级、自主进化体验优化、模型新增支持、企业微信智能机器人回调模式、安全加固和体验优化
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
|
||||||
|
|
||||||
|
## 💬 Web 控制台优化
|
||||||
|
|
||||||
|
本次 Web 控制台围绕「可视化管理」做了多项增强,让更多配置无需改文件即可在界面完成:
|
||||||
|
|
||||||
|
- **定时任务管理**:支持在控制台直接查看、编辑、启用/停用、删除任意定时任务,任务列表按启用状态与下次执行时间排序。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)
|
||||||
@@ -29,7 +29,7 @@ def create_bot(bot_type):
|
|||||||
from models.mimo.mimo_bot import MimoBot
|
from models.mimo.mimo_bot import MimoBot
|
||||||
return MimoBot()
|
return MimoBot()
|
||||||
|
|
||||||
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API
|
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM) or bot_type.startswith("custom:"): # OpenAI-compatible API
|
||||||
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
||||||
return ChatGPTBot()
|
return ChatGPTBot()
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from common import const
|
|||||||
from common.i18n import t as _t
|
from common.i18n import t as _t
|
||||||
from models.bot import Bot
|
from models.bot import Bot
|
||||||
from models.openai_compatible_bot import OpenAICompatibleBot
|
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||||
|
from models.custom_provider import resolve_custom_credentials, parse_custom_bot_type
|
||||||
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
||||||
from models.openai.open_ai_image import OpenAIImage
|
from models.openai.open_ai_image import OpenAIImage
|
||||||
from models.session_manager import SessionManager
|
from models.session_manager import SessionManager
|
||||||
@@ -32,9 +33,12 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Resolve api key / base from config (no global SDK state anymore).
|
# Resolve api key / base from config (no global SDK state anymore).
|
||||||
if conf().get("bot_type") == "custom":
|
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||||
self._api_key = conf().get("custom_api_key", "")
|
custom_model = None
|
||||||
self._api_base = conf().get("custom_api_base") or None
|
if is_custom:
|
||||||
|
# Supports multiple custom providers via bot_type "custom:<id>"
|
||||||
|
# with automatic fallback to the legacy custom_api_key/base fields.
|
||||||
|
self._api_key, self._api_base, custom_model = resolve_custom_credentials()
|
||||||
else:
|
else:
|
||||||
self._api_key = conf().get("open_ai_api_key")
|
self._api_key = conf().get("open_ai_api_key")
|
||||||
self._api_base = conf().get("open_ai_api_base") or None
|
self._api_base = conf().get("open_ai_api_base") or None
|
||||||
@@ -46,8 +50,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
)
|
)
|
||||||
if conf().get("rate_limit_chatgpt"):
|
if conf().get("rate_limit_chatgpt"):
|
||||||
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
|
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
|
||||||
conf_model = conf().get("model") or "gpt-3.5-turbo"
|
# Per-provider model takes precedence over global model.
|
||||||
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
|
conf_model = custom_model or conf().get("model") or "gpt-3.5-turbo"
|
||||||
|
self.sessions = SessionManager(ChatGPTSession, model=conf_model)
|
||||||
# o1相关模型不支持system prompt,暂时用文心模型的session
|
# o1相关模型不支持system prompt,暂时用文心模型的session
|
||||||
|
|
||||||
self.args = {
|
self.args = {
|
||||||
@@ -70,11 +75,20 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
|
|
||||||
def get_api_config(self):
|
def get_api_config(self):
|
||||||
"""Get API configuration for OpenAI-compatible base class"""
|
"""Get API configuration for OpenAI-compatible base class"""
|
||||||
is_custom = conf().get("bot_type") == "custom"
|
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||||
|
if is_custom:
|
||||||
|
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||||
|
api_key = custom_key
|
||||||
|
api_base = custom_base
|
||||||
|
model = custom_model or conf().get("model", "gpt-3.5-turbo")
|
||||||
|
else:
|
||||||
|
api_key = conf().get("open_ai_api_key")
|
||||||
|
api_base = conf().get("open_ai_api_base")
|
||||||
|
model = conf().get("model", "gpt-3.5-turbo")
|
||||||
return {
|
return {
|
||||||
'api_key': conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"),
|
'api_key': api_key,
|
||||||
'api_base': conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base"),
|
'api_base': api_base,
|
||||||
'model': conf().get("model", "gpt-3.5-turbo"),
|
'model': model,
|
||||||
'default_temperature': conf().get("temperature", 0.9),
|
'default_temperature': conf().get("temperature", 0.9),
|
||||||
'default_top_p': conf().get("top_p", 1.0),
|
'default_top_p': conf().get("top_p", 1.0),
|
||||||
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
|
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
|
||||||
@@ -185,10 +199,16 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
mime_type = mime_type_map.get(extension, "image/jpeg")
|
mime_type = mime_type_map.get(extension, "image/jpeg")
|
||||||
|
|
||||||
# Get model and API config
|
# Get model and API config
|
||||||
is_custom = conf().get("bot_type") == "custom"
|
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||||
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
|
if is_custom:
|
||||||
api_key = context.get("openai_api_key") or (conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"))
|
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||||
api_base = conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base")
|
model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o")
|
||||||
|
api_key = context.get("openai_api_key") or custom_key
|
||||||
|
api_base = custom_base
|
||||||
|
else:
|
||||||
|
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
|
||||||
|
api_key = context.get("openai_api_key") or conf().get("open_ai_api_key")
|
||||||
|
api_base = conf().get("open_ai_api_base")
|
||||||
|
|
||||||
# Build vision request
|
# Build vision request
|
||||||
messages = [
|
messages = [
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ class ClaudeAPIBot(Bot, OpenAIImage):
|
|||||||
return 8192
|
return 8192
|
||||||
elif model and model.startswith("claude-3") and "opus" in model:
|
elif model and model.startswith("claude-3") and "opus" in model:
|
||||||
return 4096
|
return 4096
|
||||||
elif model and (model.startswith("claude-sonnet-4") or model.startswith("claude-opus-4")):
|
elif model and (model.startswith("claude-sonnet-4") or model.startswith("claude-opus-4") or model.startswith("claude-fable")):
|
||||||
return 64000
|
return 64000
|
||||||
return 8192
|
return 8192
|
||||||
|
|
||||||
|
|||||||
125
models/custom_provider.py
Normal file
125
models/custom_provider.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
|
||||||
|
"""
|
||||||
|
Centralized resolver for custom (OpenAI-compatible) provider credentials.
|
||||||
|
|
||||||
|
CowAgent historically supported only a *single* custom provider via the flat
|
||||||
|
config keys ``custom_api_key`` / ``custom_api_base``. This module adds support
|
||||||
|
for *multiple* custom providers (see issue #2838) while remaining 100%
|
||||||
|
backward compatible.
|
||||||
|
|
||||||
|
Config model
|
||||||
|
------------
|
||||||
|
- ``custom_providers``: list of dicts, each describing one custom provider::
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "3f2a9c1b", # server-generated short uuid (primary key)
|
||||||
|
"name": "siliconflow", # 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
|
||||||
|
}
|
||||||
|
|
||||||
|
Routing
|
||||||
|
-------
|
||||||
|
- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``.
|
||||||
|
- ``bot_type: "custom:<id>"`` (multi-provider): looks up the provider by id in
|
||||||
|
``custom_providers``. There is a single source of truth — no separate
|
||||||
|
``custom_active_provider`` field.
|
||||||
|
|
||||||
|
Backward-compatibility contract
|
||||||
|
-------------------------------
|
||||||
|
When ``bot_type`` is exactly ``"custom"`` (no colon suffix), behaviour is
|
||||||
|
unchanged: we return ``custom_api_key`` / ``custom_api_base`` values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from config import conf
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def generate_provider_id() -> str:
|
||||||
|
"""Generate a short random id for a new custom provider."""
|
||||||
|
return uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def get_custom_providers():
|
||||||
|
"""Return the list of configured custom providers (always a list)."""
|
||||||
|
providers = conf().get("custom_providers")
|
||||||
|
if not isinstance(providers, list):
|
||||||
|
return []
|
||||||
|
# Keep only well-formed entries with an id.
|
||||||
|
return [p for p in providers if isinstance(p, dict) and p.get("id")]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_provider_by_id(providers, provider_id):
|
||||||
|
"""Look up a provider by its id, or None if not found."""
|
||||||
|
if not providers or not provider_id:
|
||||||
|
return None
|
||||||
|
for p in providers:
|
||||||
|
if p.get("id") == provider_id:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_custom_bot_type(bot_type):
|
||||||
|
"""Parse bot_type to extract custom provider id.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_custom, provider_id) where:
|
||||||
|
- is_custom: True if bot_type starts with "custom"
|
||||||
|
- provider_id: the id suffix (e.g. "3f2a9c1b") or empty string for legacy mode
|
||||||
|
"""
|
||||||
|
if not bot_type or not isinstance(bot_type, str):
|
||||||
|
return False, ""
|
||||||
|
if bot_type == "custom":
|
||||||
|
return True, ""
|
||||||
|
if bot_type.startswith("custom:"):
|
||||||
|
return True, bot_type[7:] # len("custom:") == 7
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_custom_credentials():
|
||||||
|
"""Resolve the effective (api_key, api_base, model) for custom mode.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. If ``bot_type`` is ``"custom:<id>"``, look up that id in
|
||||||
|
``custom_providers``.
|
||||||
|
2. If ``bot_type`` is exactly ``"custom"`` (legacy), return the flat
|
||||||
|
``custom_api_key`` / ``custom_api_base``.
|
||||||
|
|
||||||
|
:return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model``
|
||||||
|
may be ``None`` / empty when not configured.
|
||||||
|
"""
|
||||||
|
bot_type = conf().get("bot_type", "")
|
||||||
|
is_custom, provider_id = parse_custom_bot_type(bot_type)
|
||||||
|
|
||||||
|
if not is_custom:
|
||||||
|
# Not custom at all — should not happen but be defensive.
|
||||||
|
return (
|
||||||
|
conf().get("open_ai_api_key", ""),
|
||||||
|
conf().get("open_ai_api_base") or None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider_id:
|
||||||
|
# Multi-provider mode: look up by id.
|
||||||
|
providers = get_custom_providers()
|
||||||
|
provider = _find_provider_by_id(providers, provider_id)
|
||||||
|
if provider is not None:
|
||||||
|
return (
|
||||||
|
provider.get("api_key", ""),
|
||||||
|
provider.get("api_base") or None,
|
||||||
|
provider.get("model") or None,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"[CUSTOM] provider id '%s' not found in custom_providers, "
|
||||||
|
"falling back to legacy fields", provider_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy single-provider fallback — unchanged behavior.
|
||||||
|
return (
|
||||||
|
conf().get("custom_api_key", ""),
|
||||||
|
conf().get("custom_api_base") or None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
@@ -657,7 +657,7 @@ class DeepSeekBot(Bot, OpenAICompatibleBot):
|
|||||||
headers = self._build_headers()
|
headers = self._build_headers()
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{self.api_base}/chat/completions",
|
f"{self.api_base}/chat/completions",
|
||||||
headers=headers, json=payload, timeout=60,
|
headers=headers, json=payload, timeout=180,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ class DoubaoBot(Bot):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
resp = requests.post(f"{self.base_url}/chat/completions",
|
resp = requests.post(f"{self.base_url}/chat/completions",
|
||||||
headers=headers, json=payload, timeout=60)
|
headers=headers, json=payload, timeout=180)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ class GoogleGeminiBot(Bot):
|
|||||||
}
|
}
|
||||||
endpoint = f"{self.api_base}/v1beta/models/{model_name}:generateContent"
|
endpoint = f"{self.api_base}/v1beta/models/{model_name}:generateContent"
|
||||||
headers = {"x-goog-api-key": self.api_key, "Content-Type": "application/json"}
|
headers = {"x-goog-api-key": self.api_key, "Content-Type": "application/json"}
|
||||||
resp = requests.post(endpoint, headers=headers, json=payload, timeout=60)
|
resp = requests.post(endpoint, headers=headers, json=payload, timeout=180)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ class MimoBot(Bot, OpenAICompatibleBot):
|
|||||||
headers = self._build_headers()
|
headers = self._build_headers()
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{self.api_base}/chat/completions",
|
f"{self.api_base}/chat/completions",
|
||||||
headers=headers, json=payload, timeout=60,
|
headers=headers, json=payload, timeout=180,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ class MinimaxBot(Bot):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
resp = requests.post(f"{self.api_base}/chat/completions",
|
resp = requests.post(f"{self.api_base}/chat/completions",
|
||||||
headers=headers, json=payload, timeout=60)
|
headers=headers, json=payload, timeout=180)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|||||||
@@ -47,9 +47,20 @@ class MoonshotBot(Bot):
|
|||||||
return model == "kimi-for-coding" or "api.kimi.com/coding" in base
|
return model == "kimi-for-coding" or "api.kimi.com/coding" in base
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _model_supports_thinking(model_name: str) -> bool:
|
def _is_builtin_reasoning_model(model_name: str) -> bool:
|
||||||
"""Return True if the model supports the ``thinking`` request parameter."""
|
"""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()
|
m = model_name.lower()
|
||||||
|
if cls._is_builtin_reasoning_model(m):
|
||||||
|
return False
|
||||||
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
||||||
|
|
||||||
def _build_headers(self) -> dict:
|
def _build_headers(self) -> dict:
|
||||||
@@ -195,7 +206,7 @@ class MoonshotBot(Bot):
|
|||||||
}
|
}
|
||||||
headers = self._build_headers()
|
headers = self._build_headers()
|
||||||
resp = requests.post(f"{self.base_url}/chat/completions",
|
resp = requests.post(f"{self.base_url}/chat/completions",
|
||||||
headers=headers, json=payload, timeout=60)
|
headers=headers, json=payload, timeout=180)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ class OpenAICompatibleBot:
|
|||||||
}
|
}
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{api_base}/chat/completions",
|
f"{api_base}/chat/completions",
|
||||||
headers=headers, json=payload, timeout=60,
|
headers=headers, json=payload, timeout=180,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
body = resp.text[:500]
|
body = resp.text[:500]
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ class CowCliPlugin(Plugin):
|
|||||||
else: # "typo"
|
else: # "typo"
|
||||||
reply_text = self._typo_hint(token, result[1])
|
reply_text = self._typo_hint(token, result[1])
|
||||||
|
|
||||||
|
reply_text = self._harden_line_breaks(reply_text, e_context)
|
||||||
e_context["reply"] = Reply(ReplyType.TEXT, reply_text)
|
e_context["reply"] = Reply(ReplyType.TEXT, reply_text)
|
||||||
e_context.action = EventAction.BREAK_PASS
|
e_context.action = EventAction.BREAK_PASS
|
||||||
|
|
||||||
@@ -606,6 +607,9 @@ class CowCliPlugin(Plugin):
|
|||||||
"agent_max_steps",
|
"agent_max_steps",
|
||||||
"knowledge",
|
"knowledge",
|
||||||
"enable_thinking",
|
"enable_thinking",
|
||||||
|
"self_evolution_enabled",
|
||||||
|
"self_evolution_idle_minutes",
|
||||||
|
"self_evolution_min_turns",
|
||||||
}
|
}
|
||||||
|
|
||||||
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
|
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
|
||||||
@@ -1516,6 +1520,43 @@ class CowCliPlugin(Plugin):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _harden_line_breaks(text: str, e_context) -> str:
|
||||||
|
"""WeChat PC renders bot messages as Markdown, where a lone '\\n' is
|
||||||
|
collapsed into a space, so plain-text CLI output gets squashed onto
|
||||||
|
one line. Prefix consecutive text lines with '- ' so the Markdown
|
||||||
|
list keeps each on its own line (the only form WeChat respects).
|
||||||
|
WeChat-only; other channels are untouched. Blank lines, code fences,
|
||||||
|
and lines that are already list items are left intact."""
|
||||||
|
if e_context is None or not text or "\n" not in text:
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
if e_context["context"].kwargs.get("channel_type") != "weixin":
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
return text
|
||||||
|
|
||||||
|
out = []
|
||||||
|
in_code = False
|
||||||
|
lines = text.split("\n")
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.lstrip().startswith("```"):
|
||||||
|
in_code = not in_code
|
||||||
|
out.append(line)
|
||||||
|
continue
|
||||||
|
stripped = line.lstrip()
|
||||||
|
prev_packed = i > 0 and lines[i - 1].strip() != ""
|
||||||
|
next_packed = i < len(lines) - 1 and lines[i + 1].strip() != ""
|
||||||
|
# Only convert lines inside a multi-line block (a neighbour line is
|
||||||
|
# non-blank); standalone paragraphs separated by blank lines, code
|
||||||
|
# blocks, blank lines, and existing list items are left intact.
|
||||||
|
if (in_code or not stripped or stripped.startswith(("- ", "* ", "+ "))
|
||||||
|
or not (prev_packed or next_packed)):
|
||||||
|
out.append(line)
|
||||||
|
else:
|
||||||
|
out.append("- " + stripped)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_dream_result(flush_mgr, is_web: bool) -> str:
|
def _build_dream_result(flush_mgr, is_web: bool) -> str:
|
||||||
"""Build dream completion message with diary content."""
|
"""Build dream completion message with diary content."""
|
||||||
|
|||||||
@@ -4,19 +4,41 @@
|
|||||||
- `$角色/$role <角色名>` - 让AI扮演该角色,角色名支持模糊匹配。
|
- `$角色/$role <角色名>` - 让AI扮演该角色,角色名支持模糊匹配。
|
||||||
- `$停止扮演` - 停止角色扮演。
|
- `$停止扮演` - 停止角色扮演。
|
||||||
|
|
||||||
添加自定义角色请在`roles/roles.json`中添加。
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/role/
|
||||||
|
├── README.md
|
||||||
|
├── __init__.py
|
||||||
|
├── role.py # 插件主逻辑
|
||||||
|
├── roles.json # 内置角色库(所有默认角色)
|
||||||
|
├── schema/
|
||||||
|
│ └── role_template.json # 角色模板,新增角色时复制此文件
|
||||||
|
└── roles/ # 可选:放自定义角色文件,自动加载
|
||||||
|
└── 我的角色.json # 同title会覆盖内置角色,不同则追加
|
||||||
|
```
|
||||||
|
|
||||||
|
## 添加自定义角色
|
||||||
|
|
||||||
|
1. 新建roles目录
|
||||||
|
2. 复制 下面模板 到 `roles/` 目录并重命名(如 `我的角色.json`)。
|
||||||
|
3. 编辑文件内容,填写角色信息。
|
||||||
|
4. 重启Bot即可生效,插件会自动扫描 `roles/` 目录下的所有 `.json` 文件。
|
||||||
|
|
||||||
|
> **覆盖规则**:如果自定义角色的 `title` 与内置角色同名,则覆盖内置角色;否则作为新角色追加。无需修改任何映射文件。
|
||||||
|
|
||||||
(大部分prompt来自https://github.com/rockbenben/ChatGPT-Shortcut/blob/main/src/data/users.tsx)
|
(大部分prompt来自https://github.com/rockbenben/ChatGPT-Shortcut/blob/main/src/data/users.tsx)
|
||||||
|
|
||||||
以下为例子:
|
以下为角色文件内容例子:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "写作助理",
|
"title": "写作助理",
|
||||||
"description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.",
|
"description": "As a writing improvement assistant...",
|
||||||
"descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。",
|
"descn": "作为一名中文写作改进助理...",
|
||||||
"wrapper": "内容是:\n\"%s\"",
|
"wrapper": "内容是:\n\"%s\"",
|
||||||
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。"
|
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。",
|
||||||
}
|
"tags": ["favorite", "write"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `title`: 角色名。
|
- `title`: 角色名。
|
||||||
@@ -24,3 +46,16 @@
|
|||||||
- `descn`: 使用`$角色`触发时,使用中文prompt。
|
- `descn`: 使用`$角色`触发时,使用中文prompt。
|
||||||
- `wrapper`: 用于包装用户消息,可起到强调作用,避免回复离题。
|
- `wrapper`: 用于包装用户消息,可起到强调作用,避免回复离题。
|
||||||
- `remark`: 简短描述该角色,在打印帮助文档时显示。
|
- `remark`: 简短描述该角色,在打印帮助文档时显示。
|
||||||
|
- `tags`: 角色分类标签,可用`$角色类型 <标签>`查看。
|
||||||
|
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"description": "",
|
||||||
|
"descn": "",
|
||||||
|
"wrapper": "",
|
||||||
|
"remark": "",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -57,6 +57,14 @@ class Role(Plugin):
|
|||||||
logger.warning(f"[Role] unknown tag {tag} ")
|
logger.warning(f"[Role] unknown tag {tag} ")
|
||||||
self.tags[tag] = (tag, [])
|
self.tags[tag] = (tag, [])
|
||||||
self.tags[tag][1].append(role)
|
self.tags[tag][1].append(role)
|
||||||
|
|
||||||
|
# 扫描 roles/ 目录,加载可选的扩展/覆盖角色文件
|
||||||
|
roles_dir = os.path.join(curdir, "roles")
|
||||||
|
if os.path.isdir(roles_dir):
|
||||||
|
for fname in sorted(os.listdir(roles_dir)):
|
||||||
|
if fname.endswith(".json"):
|
||||||
|
self._load_role_file(os.path.join(roles_dir, fname))
|
||||||
|
|
||||||
for tag in list(self.tags.keys()):
|
for tag in list(self.tags.keys()):
|
||||||
if len(self.tags[tag][1]) == 0:
|
if len(self.tags[tag][1]) == 0:
|
||||||
logger.debug(f"[Role] no role found for tag {tag} ")
|
logger.debug(f"[Role] no role found for tag {tag} ")
|
||||||
@@ -74,6 +82,27 @@ class Role(Plugin):
|
|||||||
logger.warn("[Role] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .")
|
logger.warn("[Role] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
def _load_role_file(self, filepath):
|
||||||
|
"""从roles/目录加载单个角色文件,同title则覆盖内置角色,否则追加"""
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
role = json.load(f)
|
||||||
|
title_lower = role["title"].lower()
|
||||||
|
if title_lower in self.roles:
|
||||||
|
old_tags = self.roles[title_lower].get("tags", [])
|
||||||
|
for tag in old_tags:
|
||||||
|
if tag in self.tags:
|
||||||
|
self.tags[tag][1] = [r for r in self.tags[tag][1] if r["title"].lower() != title_lower]
|
||||||
|
logger.info(f"[Role] overridden: {role['title']}")
|
||||||
|
self.roles[title_lower] = role
|
||||||
|
for tag in role.get("tags", []):
|
||||||
|
if tag not in self.tags:
|
||||||
|
self.tags[tag] = (tag, [])
|
||||||
|
self.tags[tag][1].append(role)
|
||||||
|
logger.debug(f"[Role] loaded: {role['title']} from {os.path.basename(filepath)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warn(f"[Role] failed to load {filepath}: {e}")
|
||||||
|
|
||||||
def get_role(self, name, find_closest=True, min_sim=0.35):
|
def get_role(self, name, find_closest=True, min_sim=0.35):
|
||||||
name = name.lower()
|
name = name.lower()
|
||||||
found_role = None
|
found_role = None
|
||||||
|
|||||||
29
run.sh
29
run.sh
@@ -359,7 +359,7 @@ detect_python_command() {
|
|||||||
FOUND_NEWER_VERSION=""
|
FOUND_NEWER_VERSION=""
|
||||||
|
|
||||||
# Try to find Python command in order of preference
|
# Try to find Python command in order of preference
|
||||||
for cmd in python3 python python3.13 python3.12 python3.11 python3.10 python3.9 python3.8 python3.7; do
|
for cmd in python3 python python3.12 python3.11 python3.10 python3.9 python3.8 python3.7 python3.13; do
|
||||||
if command -v $cmd &> /dev/null; then
|
if command -v $cmd &> /dev/null; then
|
||||||
# Check Python version
|
# Check Python version
|
||||||
major_version=$($cmd -c 'import sys; print(sys.version_info[0])' 2>/dev/null)
|
major_version=$($cmd -c 'import sys; print(sys.version_info[0])' 2>/dev/null)
|
||||||
@@ -378,7 +378,7 @@ detect_python_command() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$PYTHON_CMD" ]; then
|
if [ -z "$PYTHON_CMD" ]; then
|
||||||
echo -e "${YELLOW}Tried: python3, python, python3.13, python3.12, python3.11, python3.10, python3.9, python3.8, python3.7${NC}"
|
echo -e "${YELLOW}Tried: python3, python, python3.12, python3.11, python3.10, python3.9, python3.8, python3.7, python3.13${NC}"
|
||||||
echo -e "${RED}❌ No suitable Python found. Please install Python 3.7 or newer${NC}"
|
echo -e "${RED}❌ No suitable Python found. Please install Python 3.7 or newer${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -597,14 +597,14 @@ select_model() {
|
|||||||
# The 12th option is "skip" -> configure later in the web console.
|
# The 12th option is "skip" -> configure later in the web console.
|
||||||
select_menu sel "$title" \
|
select_menu sel "$title" \
|
||||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
||||||
"Claude (claude-opus-4-8, claude-opus-4-7, etc.)" \
|
"Claude (claude-opus-4-8, claude-fable-5, etc.)" \
|
||||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
||||||
"OpenAI (gpt-5.5, etc.)" \
|
"OpenAI (gpt-5.5, etc.)" \
|
||||||
"MiniMax (MiniMax-M3, etc.)" \
|
"MiniMax (MiniMax-M3, etc.)" \
|
||||||
"GLM (glm-5.1, etc.)" \
|
"GLM (glm-5.2, etc.)" \
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
||||||
"Doubao (doubao-seed-2.0, etc.)" \
|
"Doubao (doubao-seed-2.0, etc.)" \
|
||||||
"Kimi (kimi-k2.6, etc.)" \
|
"Kimi (kimi-k2.7-code, etc.)" \
|
||||||
"MiMo (mimo-v2.5-pro, etc.)" \
|
"MiMo (mimo-v2.5-pro, etc.)" \
|
||||||
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
||||||
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
|
"$(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" ;;
|
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
|
||||||
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
|
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
|
||||||
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_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" ;;
|
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" ;;
|
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" ;;
|
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
|
||||||
11)
|
11)
|
||||||
# Show where to obtain a LinkAI key (zh users -> console page).
|
# Show where to obtain a LinkAI key (zh users -> console page).
|
||||||
@@ -869,8 +869,10 @@ base = {
|
|||||||
'mimo_api_key': e('MIMO_KEY', ''),
|
'mimo_api_key': e('MIMO_KEY', ''),
|
||||||
'deepseek_api_key': e('DEEPSEEK_KEY', ''),
|
'deepseek_api_key': e('DEEPSEEK_KEY', ''),
|
||||||
'deepseek_api_base': e('DEEPSEEK_BASE'),
|
'deepseek_api_base': e('DEEPSEEK_BASE'),
|
||||||
'voice_to_text': 'openai',
|
# Leave ASR/TTS provider empty so the web console auto-suggests the vendor
|
||||||
'text_to_voice': 'openai',
|
# whose API key is actually configured (e.g. LinkAI), not always OpenAI.
|
||||||
|
'voice_to_text': '',
|
||||||
|
'text_to_voice': '',
|
||||||
'voice_reply_voice': False,
|
'voice_reply_voice': False,
|
||||||
'speech_recognition': True,
|
'speech_recognition': True,
|
||||||
'group_speech_recognition': False,
|
'group_speech_recognition': False,
|
||||||
@@ -878,9 +880,9 @@ base = {
|
|||||||
'linkai_api_key': e('LINKAI_KEY', ''),
|
'linkai_api_key': e('LINKAI_KEY', ''),
|
||||||
'linkai_app_code': '',
|
'linkai_app_code': '',
|
||||||
'agent': True,
|
'agent': True,
|
||||||
'agent_max_context_tokens': 40000,
|
'agent_max_context_tokens': 50000,
|
||||||
'agent_max_context_turns': 30,
|
'agent_max_context_turns': 20,
|
||||||
'agent_max_steps': 15,
|
'agent_max_steps': 20,
|
||||||
# New installs opt into self-evolution; existing users (no key) keep the
|
# New installs opt into self-evolution; existing users (no key) keep the
|
||||||
# code default (off) so an upgrade never silently changes their behavior.
|
# code default (off) so an upgrade never silently changes their behavior.
|
||||||
'self_evolution_enabled': True,
|
'self_evolution_enabled': True,
|
||||||
@@ -978,7 +980,10 @@ start_project() {
|
|||||||
echo -e " ${GREEN}./run.sh status${NC} $(t "查看状态" "Check status")"
|
echo -e " ${GREEN}./run.sh status${NC} $(t "查看状态" "Check status")"
|
||||||
echo -e " ${GREEN}./run.sh logs${NC} $(t "查看日志" "View logs")"
|
echo -e " ${GREEN}./run.sh logs${NC} $(t "查看日志" "View logs")"
|
||||||
echo -e " ${GREEN}./run.sh update${NC} $(t "更新并重启" "Update and restart")"
|
echo -e " ${GREEN}./run.sh update${NC} $(t "更新并重启" "Update and restart")"
|
||||||
|
echo -e " ${GREEN}cow install-browser${NC} $(t "安装浏览器工具" "Install browser tool")"
|
||||||
fi
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}$(t "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool")${NC}"
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|||||||
166
scripts/run.ps1
166
scripts/run.ps1
@@ -32,6 +32,44 @@ try {
|
|||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
$env:PYTHONIOENCODING = "utf-8"
|
$env:PYTHONIOENCODING = "utf-8"
|
||||||
|
|
||||||
|
# ── disable console QuickEdit mode ───────────────────────────────
|
||||||
|
# On Windows, QuickEdit is ON by default: a stray click/selection in the
|
||||||
|
# window FREEZES all output until the user presses a key. During long steps
|
||||||
|
# (git clone, pip install) this looks like a hang, and the wake-up key gets
|
||||||
|
# buffered and later auto-confirms a menu default. Turning QuickEdit off
|
||||||
|
# removes both the freeze and the phantom keystrokes.
|
||||||
|
try {
|
||||||
|
if (-not ([Console]::IsInputRedirected)) {
|
||||||
|
$quickEditType = @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class ConsoleQuickEdit {
|
||||||
|
const int STD_INPUT_HANDLE = -10;
|
||||||
|
const uint ENABLE_QUICK_EDIT = 0x0040;
|
||||||
|
const uint ENABLE_EXTENDED = 0x0080;
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
static extern IntPtr GetStdHandle(int nStdHandle);
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
|
||||||
|
public static void Disable() {
|
||||||
|
IntPtr handle = GetStdHandle(STD_INPUT_HANDLE);
|
||||||
|
uint mode;
|
||||||
|
if (!GetConsoleMode(handle, out mode)) { return; }
|
||||||
|
mode &= ~ENABLE_QUICK_EDIT; // clear QuickEdit
|
||||||
|
mode |= ENABLE_EXTENDED; // keep extended flags valid
|
||||||
|
SetConsoleMode(handle, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
if (-not ([System.Management.Automation.PSTypeName]'ConsoleQuickEdit').Type) {
|
||||||
|
Add-Type -TypeDefinition $quickEditType -ErrorAction Stop
|
||||||
|
}
|
||||||
|
[ConsoleQuickEdit]::Disable()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
# ── colours ──────────────────────────────────────────────────────
|
# ── colours ──────────────────────────────────────────────────────
|
||||||
function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green }
|
function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green }
|
||||||
function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow }
|
function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow }
|
||||||
@@ -91,6 +129,17 @@ function Initialize-UiLang {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Drain any buffered console keystrokes. During long steps (git clone, pip
|
||||||
|
# install) users often hit Enter to "wake up" a seemingly stuck console; those
|
||||||
|
# keys sit in the input buffer and would otherwise be consumed by the next
|
||||||
|
# ReadKey, auto-confirming a menu's default. Flush them before reading input.
|
||||||
|
function Clear-InputBuffer {
|
||||||
|
try {
|
||||||
|
if ([Console]::IsInputRedirected) { return }
|
||||||
|
while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
# ── arrow-key selectable menu with number fallback ───────────────
|
# ── arrow-key selectable menu with number fallback ───────────────
|
||||||
# Usage: $idx = Select-Menu -Title "..." -Options @("a","b") [-Default 1]
|
# Usage: $idx = Select-Menu -Title "..." -Options @("a","b") [-Default 1]
|
||||||
# Returns the selected 1-based index.
|
# Returns the selected 1-based index.
|
||||||
@@ -125,6 +174,10 @@ function Select-Menu {
|
|||||||
Write-Info $Title
|
Write-Info $Title
|
||||||
Write-Host (T "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select") -ForegroundColor Cyan
|
Write-Host (T "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select") -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Discard keystrokes buffered during the previous (possibly long-running)
|
||||||
|
# step so a leftover Enter doesn't instantly confirm the default option.
|
||||||
|
Clear-InputBuffer
|
||||||
|
|
||||||
[Console]::CursorVisible = $false
|
[Console]::CursorVisible = $false
|
||||||
$firstDraw = $true
|
$firstDraw = $true
|
||||||
try {
|
try {
|
||||||
@@ -194,6 +247,8 @@ function Select-Language {
|
|||||||
|
|
||||||
# ── Python detection ─────────────────────────────────────────────
|
# ── Python detection ─────────────────────────────────────────────
|
||||||
function Find-Python {
|
function Find-Python {
|
||||||
|
# 3.13 compatibility is not great, so prefer 3.7-3.12 and only fall back to 3.13.
|
||||||
|
$fallback = $null
|
||||||
foreach ($cmd in @("python3", "python")) {
|
foreach ($cmd in @("python3", "python")) {
|
||||||
$bin = Get-Command $cmd -ErrorAction SilentlyContinue
|
$bin = Get-Command $cmd -ErrorAction SilentlyContinue
|
||||||
if (-not $bin) { continue }
|
if (-not $bin) { continue }
|
||||||
@@ -201,12 +256,15 @@ function Find-Python {
|
|||||||
$ver = & $bin.Source -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}')" 2>$null
|
$ver = & $bin.Source -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}')" 2>$null
|
||||||
$parts = $ver -split '\.'
|
$parts = $ver -split '\.'
|
||||||
$major = [int]$parts[0]; $minor = [int]$parts[1]
|
$major = [int]$parts[0]; $minor = [int]$parts[1]
|
||||||
if ($major -eq 3 -and $minor -ge 7 -and $minor -le 13) {
|
if ($major -eq 3 -and $minor -ge 7 -and $minor -le 12) {
|
||||||
return $bin.Source
|
return $bin.Source
|
||||||
}
|
}
|
||||||
|
if ($major -eq 3 -and $minor -eq 13 -and -not $fallback) {
|
||||||
|
$fallback = $bin.Source
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return $null
|
return $fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
$PythonCmd = Find-Python
|
$PythonCmd = Find-Python
|
||||||
@@ -342,12 +400,11 @@ function Install-Dependencies {
|
|||||||
& $PythonCmd -m pip install -e $BaseDir @pipMirror 2>&1 | Out-Null
|
& $PythonCmd -m pip install -e $BaseDir @pipMirror 2>&1 | Out-Null
|
||||||
$ErrorActionPreference = $prevEAP
|
$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
|
$scriptsDir = & $PythonCmd -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
|
||||||
if ($scriptsDir -and (Test-Path $scriptsDir)) {
|
if ($scriptsDir -and (Test-Path $scriptsDir)) {
|
||||||
if ($env:PATH -notlike "*$scriptsDir*") {
|
Add-ScriptsDirToPath $scriptsDir
|
||||||
$env:PATH = "$scriptsDir;$env:PATH"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
||||||
@@ -355,7 +412,39 @@ function Install-Dependencies {
|
|||||||
Write-Cow ((T "cow CLI 注册成功" "cow CLI registered") + ": $($cowBin.Source)")
|
Write-Cow ((T "cow CLI 注册成功" "cow CLI registered") + ": $($cowBin.Source)")
|
||||||
} else {
|
} else {
|
||||||
Write-Warn ((T "cow CLI 不在 PATH 中,你可以使用" "cow CLI not in PATH. You can use") + ": $PythonCmd -m cli.cli")
|
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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,10 +458,10 @@ $ModelChoices = @{
|
|||||||
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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 }
|
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
||||||
}
|
}
|
||||||
@@ -382,14 +471,14 @@ function Select-Model {
|
|||||||
$title = T "选择 AI 模型" "Select AI Model"
|
$title = T "选择 AI 模型" "Select AI Model"
|
||||||
$options = @(
|
$options = @(
|
||||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
||||||
"Claude (claude-opus-4-8, claude-opus-4-7, etc.)",
|
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
|
||||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
||||||
"OpenAI (gpt-5.5, etc.)",
|
"OpenAI (gpt-5.5, etc.)",
|
||||||
"MiniMax (MiniMax-M3, etc.)",
|
"MiniMax (MiniMax-M3, etc.)",
|
||||||
"GLM (glm-5.1, etc.)",
|
"GLM (glm-5.2, etc.)",
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
||||||
"Doubao (doubao-seed-2.0, etc.)",
|
"Doubao (doubao-seed-2.0, etc.)",
|
||||||
"Kimi (kimi-k2.6, etc.)",
|
"Kimi (kimi-k2.7-code, etc.)",
|
||||||
"MiMo (mimo-v2.5-pro, etc.)",
|
"MiMo (mimo-v2.5-pro, etc.)",
|
||||||
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
||||||
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
||||||
@@ -568,8 +657,11 @@ function New-ConfigFile {
|
|||||||
mimo_api_key = ""
|
mimo_api_key = ""
|
||||||
deepseek_api_key = ""
|
deepseek_api_key = ""
|
||||||
deepseek_api_base = "https://api.deepseek.com/v1"
|
deepseek_api_base = "https://api.deepseek.com/v1"
|
||||||
voice_to_text = "openai"
|
# Leave ASR/TTS provider empty so the web console auto-suggests the
|
||||||
text_to_voice = "openai"
|
# vendor whose API key is actually configured (e.g. LinkAI), instead
|
||||||
|
# of always defaulting to OpenAI.
|
||||||
|
voice_to_text = ""
|
||||||
|
text_to_voice = ""
|
||||||
voice_reply_voice = $false
|
voice_reply_voice = $false
|
||||||
speech_recognition = $true
|
speech_recognition = $true
|
||||||
group_speech_recognition = $false
|
group_speech_recognition = $false
|
||||||
@@ -577,9 +669,11 @@ function New-ConfigFile {
|
|||||||
linkai_api_key = ""
|
linkai_api_key = ""
|
||||||
linkai_app_code = ""
|
linkai_app_code = ""
|
||||||
agent = $true
|
agent = $true
|
||||||
agent_max_context_tokens = 40000
|
agent_max_context_tokens = 50000
|
||||||
agent_max_context_turns = 30
|
agent_max_context_turns = 20
|
||||||
agent_max_steps = 15
|
agent_max_steps = 20
|
||||||
|
# New installs opt into self-evolution (matches run.sh).
|
||||||
|
self_evolution_enabled = $true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set the API key into the right field (skipped models leave it empty).
|
# Set the API key into the right field (skipped models leave it empty).
|
||||||
@@ -601,11 +695,27 @@ function New-ConfigFile {
|
|||||||
Write-Cow (T "配置文件创建成功。" "Configuration file created.")
|
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 ─────────────────────────────────────────────
|
# ── start via cow CLI ─────────────────────────────────────────────
|
||||||
function Start-CowAgent {
|
function Start-CowAgent {
|
||||||
Write-Cow (T "正在启动 CowAgent..." "Starting CowAgent...")
|
Write-Cow (T "正在启动 CowAgent..." "Starting CowAgent...")
|
||||||
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
if (Resolve-CowCommand) {
|
||||||
if ($cowBin) {
|
|
||||||
& cow start
|
& cow start
|
||||||
} else {
|
} else {
|
||||||
Write-Warn (T "未找到 cow CLI,直接启动..." "cow CLI not found, starting directly...")
|
Write-Warn (T "未找到 cow CLI,直接启动..." "cow CLI not found, starting directly...")
|
||||||
@@ -616,12 +726,21 @@ function Start-CowAgent {
|
|||||||
# ── delegate management commands to cow CLI ──────────────────────
|
# ── delegate management commands to cow CLI ──────────────────────
|
||||||
function Invoke-CowCommand {
|
function Invoke-CowCommand {
|
||||||
param([string]$Cmd)
|
param([string]$Cmd)
|
||||||
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
if (Resolve-CowCommand) {
|
||||||
if ($cowBin) {
|
|
||||||
& cow $Cmd
|
& cow $Cmd
|
||||||
} else {
|
} else {
|
||||||
Write-Err (T "未找到 cow CLI,请先不带参数运行本脚本进行安装。" "cow CLI not found. Run this script without arguments first to install.")
|
# Fall back to the module entrypoint so management commands still work
|
||||||
exit 1
|
# 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +804,7 @@ function Install-Mode {
|
|||||||
# Auto-start after configuration for a true out-of-the-box experience.
|
# Auto-start after configuration for a true out-of-the-box experience.
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
if ($script:AccessInfo) { Write-Cow $script:AccessInfo }
|
if ($script:AccessInfo) { Write-Cow $script:AccessInfo }
|
||||||
|
Write-Warn (T "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool")
|
||||||
Start-CowAgent
|
Start-CowAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
185
tests/test_bash_streaming.py
Normal file
185
tests/test_bash_streaming.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.tools.bash.bash import Bash
|
||||||
|
|
||||||
|
|
||||||
|
posix_only = pytest.mark.skipif(Bash._IS_WIN, reason="POSIX shell command")
|
||||||
|
windows_only = pytest.mark.skipif(not Bash._IS_WIN, reason="Windows integration test")
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_pid_command(pid_file):
|
||||||
|
path = str(pid_file).replace("\\", "\\\\")
|
||||||
|
return (
|
||||||
|
f'"{sys.executable}" -c '
|
||||||
|
f'"import os,time;open(r\'{path}\',\'w\').write(str(os.getpid()));time.sleep(30)"'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_pid_is_running(pid):
|
||||||
|
result = subprocess.run(
|
||||||
|
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
return f'"{pid}"' in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_kill_pid_file(pid_file):
|
||||||
|
if pid_file.exists():
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/F", "/PID", pid_file.read_text()],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@posix_only
|
||||||
|
def test_fast_command_returns_output_without_progress(tmp_path):
|
||||||
|
tool = Bash({"cwd": str(tmp_path)})
|
||||||
|
progress = []
|
||||||
|
tool.progress_callback = progress.append
|
||||||
|
|
||||||
|
result = tool.execute({"command": "printf fast"})
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.result["output"] == "fast"
|
||||||
|
assert progress == []
|
||||||
|
|
||||||
|
|
||||||
|
@posix_only
|
||||||
|
def test_timeout_returns_promptly(tmp_path):
|
||||||
|
tool = Bash({"cwd": str(tmp_path)})
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
result = tool.execute({"command": "sleep 10", "timeout": 1})
|
||||||
|
|
||||||
|
assert result.status == "error"
|
||||||
|
assert "timed out after 1 seconds" in result.result
|
||||||
|
assert time.monotonic() - started < 3
|
||||||
|
|
||||||
|
|
||||||
|
@posix_only
|
||||||
|
def test_background_process_holding_pipe_does_not_hang(tmp_path):
|
||||||
|
tool = Bash({"cwd": str(tmp_path)})
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
result = tool.execute({"command": "sleep 10 & printf done", "timeout": 3})
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.result["output"] == "done"
|
||||||
|
assert time.monotonic() - started < 7
|
||||||
|
|
||||||
|
|
||||||
|
@posix_only
|
||||||
|
def test_output_without_trailing_newline_streams_progress(tmp_path):
|
||||||
|
tool = Bash({"cwd": str(tmp_path)})
|
||||||
|
progress = []
|
||||||
|
tool.progress_callback = progress.append
|
||||||
|
|
||||||
|
result = tool.execute({
|
||||||
|
"command": "for i in 1 2 3 4 5; do printf .; sleep 0.3; done",
|
||||||
|
"timeout": 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.result["output"] == "....."
|
||||||
|
assert progress
|
||||||
|
assert progress[-1].endswith(".")
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_kill_uses_taskkill_for_process_tree():
|
||||||
|
tool = Bash()
|
||||||
|
process = type("Process", (), {"pid": 1234, "poll": lambda self: None, "kill": lambda self: None})()
|
||||||
|
|
||||||
|
with patch.object(tool, "_IS_WIN", True), patch.object(subprocess, "run") as run:
|
||||||
|
run.return_value.returncode = 0
|
||||||
|
tool._kill_process(process)
|
||||||
|
|
||||||
|
run.assert_called_once_with(
|
||||||
|
["taskkill", "/F", "/T", "/PID", "1234"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_kill_falls_back_when_taskkill_fails():
|
||||||
|
tool = Bash()
|
||||||
|
process = type("Process", (), {"pid": 1234, "poll": lambda self: None, "kill": lambda self: None})()
|
||||||
|
|
||||||
|
with patch.object(tool, "_IS_WIN", True), \
|
||||||
|
patch.object(subprocess, "run", side_effect=OSError), \
|
||||||
|
patch.object(process, "kill") as kill:
|
||||||
|
tool._kill_process(process)
|
||||||
|
|
||||||
|
kill.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@windows_only
|
||||||
|
def test_windows_timeout_kills_process_tree(tmp_path):
|
||||||
|
pid_file = tmp_path / "timeout-child.pid"
|
||||||
|
child_command = _windows_pid_command(pid_file)
|
||||||
|
tool = Bash({"cwd": str(tmp_path)})
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = tool.execute({
|
||||||
|
"command": f'start "" /b {child_command} & ping -n 30 127.0.0.1 >nul',
|
||||||
|
"timeout": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result.status == "error"
|
||||||
|
assert "timed out after 1 seconds" in result.result
|
||||||
|
assert time.monotonic() - started < 8
|
||||||
|
assert pid_file.exists()
|
||||||
|
time.sleep(0.5)
|
||||||
|
assert not _windows_pid_is_running(pid_file.read_text())
|
||||||
|
finally:
|
||||||
|
_windows_kill_pid_file(pid_file)
|
||||||
|
|
||||||
|
|
||||||
|
@windows_only
|
||||||
|
def test_windows_long_running_command_streams_progress(tmp_path):
|
||||||
|
tool = Bash({"cwd": str(tmp_path)})
|
||||||
|
progress = []
|
||||||
|
tool.progress_callback = progress.append
|
||||||
|
|
||||||
|
result = tool.execute({
|
||||||
|
"command": (
|
||||||
|
f'"{sys.executable}" -u -c '
|
||||||
|
'"import sys,time; '
|
||||||
|
"[(sys.stdout.write('.'),sys.stdout.flush(),time.sleep(0.3)) "
|
||||||
|
'for _ in range(5)]"'
|
||||||
|
),
|
||||||
|
"timeout": 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.result["output"] == "....."
|
||||||
|
assert progress
|
||||||
|
assert progress[-1].endswith(".")
|
||||||
|
|
||||||
|
|
||||||
|
@windows_only
|
||||||
|
def test_windows_background_process_holding_pipe_does_not_hang(tmp_path):
|
||||||
|
pid_file = tmp_path / "background-child.pid"
|
||||||
|
child_command = _windows_pid_command(pid_file)
|
||||||
|
tool = Bash({"cwd": str(tmp_path)})
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = tool.execute({
|
||||||
|
"command": f'start "" /b {child_command} & echo done',
|
||||||
|
"timeout": 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.result["output"].strip() == "done"
|
||||||
|
assert time.monotonic() - started < 8
|
||||||
|
assert pid_file.exists()
|
||||||
|
finally:
|
||||||
|
_windows_kill_pid_file(pid_file)
|
||||||
240
tests/test_custom_provider.py
Normal file
240
tests/test_custom_provider.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
"""
|
||||||
|
Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838).
|
||||||
|
|
||||||
|
Covers models/custom_provider.py:
|
||||||
|
- Backward compatibility: legacy custom_api_key / custom_api_base fallback
|
||||||
|
- Multi-provider selection via bot_type "custom:<id>" routing
|
||||||
|
- parse_custom_bot_type helper
|
||||||
|
- Robustness against malformed config (missing id, non-dict, non-list)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
import config as config_module
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def set_conf(d):
|
||||||
|
"""Install a fresh Config as the global config used by conf()."""
|
||||||
|
config_module.config = Config(d)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCustomBotType(unittest.TestCase):
|
||||||
|
"""parse_custom_bot_type() parsing logic."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
self.parse = parse_custom_bot_type
|
||||||
|
|
||||||
|
def test_legacy_custom(self):
|
||||||
|
is_custom, pid = self.parse("custom")
|
||||||
|
self.assertTrue(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
def test_custom_with_id(self):
|
||||||
|
is_custom, pid = self.parse("custom:3f2a9c1b")
|
||||||
|
self.assertTrue(is_custom)
|
||||||
|
self.assertEqual(pid, "3f2a9c1b")
|
||||||
|
|
||||||
|
def test_non_custom(self):
|
||||||
|
is_custom, pid = self.parse("openai")
|
||||||
|
self.assertFalse(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
is_custom, pid = self.parse("")
|
||||||
|
self.assertFalse(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
is_custom, pid = self.parse(None)
|
||||||
|
self.assertFalse(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveCustomCredentials(unittest.TestCase):
|
||||||
|
"""resolve_custom_credentials() resolution order and fallbacks."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from models.custom_provider import resolve_custom_credentials, get_custom_providers
|
||||||
|
self.resolve = resolve_custom_credentials
|
||||||
|
self.get_providers = get_custom_providers
|
||||||
|
|
||||||
|
# --- Backward compatibility ---
|
||||||
|
|
||||||
|
def test_legacy_fallback_when_no_providers(self):
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom",
|
||||||
|
"custom_api_key": "legacy-key",
|
||||||
|
"custom_api_base": "https://legacy.example.com/v1",
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
self.resolve(),
|
||||||
|
("legacy-key", "https://legacy.example.com/v1", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_config(self):
|
||||||
|
set_conf({"bot_type": "custom"})
|
||||||
|
self.assertEqual(self.resolve(), ("", None, None))
|
||||||
|
|
||||||
|
# --- Multi-provider selection via bot_type ---
|
||||||
|
|
||||||
|
def test_provider_selected_by_id(self):
|
||||||
|
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"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
self.resolve(),
|
||||||
|
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_id_not_found_falls_back_to_legacy(self):
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom:ghost",
|
||||||
|
"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"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
self.resolve(),
|
||||||
|
("legacy-key", "https://legacy.example.com/v1", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_provider_without_model_returns_none_model(self):
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom:local01",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "local01", "name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
self.resolve(),
|
||||||
|
("", "http://localhost:11434/v1", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Robustness against malformed config ---
|
||||||
|
|
||||||
|
def test_malformed_entries_filtered_and_fallback(self):
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom:nope",
|
||||||
|
"custom_api_key": "legacy-key",
|
||||||
|
"custom_api_base": "https://legacy.example.com/v1",
|
||||||
|
"custom_providers": [
|
||||||
|
{"name": "no-id", "api_key": "no-id-key"}, # invalid: no id
|
||||||
|
"not-a-dict", # invalid: wrong type
|
||||||
|
],
|
||||||
|
})
|
||||||
|
# All entries invalid -> treated as empty -> legacy fallback
|
||||||
|
self.assertEqual(
|
||||||
|
self.resolve(),
|
||||||
|
("legacy-key", "https://legacy.example.com/v1", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_custom_providers_filters_invalid(self):
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "ok1", "name": "ok", "api_key": "k", "api_base": "https://x/v1"},
|
||||||
|
{"name": "no-id", "api_key": "no-id"}, # dropped: no id
|
||||||
|
123, # dropped
|
||||||
|
],
|
||||||
|
})
|
||||||
|
providers = self.get_providers()
|
||||||
|
self.assertEqual(len(providers), 1)
|
||||||
|
self.assertEqual(providers[0]["id"], "ok1")
|
||||||
|
|
||||||
|
def test_custom_providers_not_a_list_falls_back(self):
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom",
|
||||||
|
"custom_api_key": "legacy-key",
|
||||||
|
"custom_api_base": "https://legacy.example.com/v1",
|
||||||
|
"custom_providers": "oops-a-string",
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
self.resolve(),
|
||||||
|
("legacy-key", "https://legacy.example.com/v1", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateProviderId(unittest.TestCase):
|
||||||
|
"""generate_provider_id() produces valid short ids."""
|
||||||
|
|
||||||
|
def test_length_and_hex(self):
|
||||||
|
from models.custom_provider import generate_provider_id
|
||||||
|
pid = generate_provider_id()
|
||||||
|
self.assertEqual(len(pid), 8)
|
||||||
|
# Must be valid hex characters
|
||||||
|
int(pid, 16)
|
||||||
|
|
||||||
|
def test_uniqueness(self):
|
||||||
|
from models.custom_provider import generate_provider_id
|
||||||
|
ids = {generate_provider_id() for _ in range(100)}
|
||||||
|
self.assertEqual(len(ids), 100)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDefaults(unittest.TestCase):
|
||||||
|
"""The new config fields must exist with safe defaults."""
|
||||||
|
|
||||||
|
def test_default_config_has_custom_providers(self):
|
||||||
|
from config import available_setting
|
||||||
|
self.assertIn("custom_providers", available_setting)
|
||||||
|
self.assertEqual(available_setting["custom_providers"], [])
|
||||||
|
|
||||||
|
def test_default_config_no_custom_active_provider(self):
|
||||||
|
"""custom_active_provider was removed — replaced by bot_type routing."""
|
||||||
|
from config import available_setting
|
||||||
|
self.assertNotIn("custom_active_provider", available_setting)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDragSensitiveNested(unittest.TestCase):
|
||||||
|
"""drag_sensitive() must mask api_key in nested structures."""
|
||||||
|
|
||||||
|
def test_nested_api_key_masked(self):
|
||||||
|
from config import drag_sensitive
|
||||||
|
import json
|
||||||
|
test_config = {
|
||||||
|
"open_ai_api_key": "sk-1234567890abcdef",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "x1", "name": "test", "api_key": "sk-nested-secret-key-long", "api_base": "https://x/v1"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = drag_sensitive(test_config)
|
||||||
|
# Top-level key should be masked
|
||||||
|
self.assertNotIn("1234567890abcdef", str(result))
|
||||||
|
# Nested key should also be masked
|
||||||
|
self.assertNotIn("nested-secret-key-long", str(result))
|
||||||
|
# But the id/name/api_base should not be masked
|
||||||
|
self.assertIn("x1", str(result))
|
||||||
|
self.assertIn("test", str(result))
|
||||||
|
self.assertIn("https://x/v1", str(result))
|
||||||
|
|
||||||
|
def test_string_config_masked(self):
|
||||||
|
from config import drag_sensitive
|
||||||
|
import json
|
||||||
|
test_str = json.dumps({
|
||||||
|
"open_ai_api_key": "sk-1234567890abcdef",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "x1", "api_key": "sk-nested-very-long-secret"}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
result = drag_sensitive(test_str)
|
||||||
|
self.assertNotIn("1234567890abcdef", result)
|
||||||
|
self.assertNotIn("nested-very-long-secret", result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
289
tests/test_custom_provider_handlers.py
Normal file
289
tests/test_custom_provider_handlers.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
"""
|
||||||
|
Unit tests for the multi custom-provider management API (issue #2838, web UI).
|
||||||
|
|
||||||
|
Covers channel/web/web_channel.py::ModelsHandler:
|
||||||
|
- _custom_provider_cards / _provider_overview expansion
|
||||||
|
- _handle_set_custom_provider (create / edit / activate)
|
||||||
|
- _handle_delete_custom_provider
|
||||||
|
- _handle_set_active_custom_provider
|
||||||
|
|
||||||
|
Uses id-based routing (bot_type: "custom:<id>") — no custom_active_provider.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Add project root to path.
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
# Stub the web.py framework so web_channel imports without the dependency.
|
||||||
|
if "web" not in sys.modules:
|
||||||
|
_web_stub = types.ModuleType("web")
|
||||||
|
_web_stub.header = lambda *a, **k: None
|
||||||
|
_web_stub.data = lambda: b"{}"
|
||||||
|
_web_stub.ctx = types.SimpleNamespace()
|
||||||
|
sys.modules["web"] = _web_stub
|
||||||
|
|
||||||
|
import config as config_module
|
||||||
|
from config import Config
|
||||||
|
from channel.web.web_channel import ModelsHandler
|
||||||
|
|
||||||
|
|
||||||
|
def set_conf(d):
|
||||||
|
"""Install a fresh Config as the global config used by conf()."""
|
||||||
|
config_module.config = Config(d)
|
||||||
|
|
||||||
|
|
||||||
|
class _HandlerHarness:
|
||||||
|
"""Test double around ModelsHandler that captures persisted config in
|
||||||
|
memory instead of touching config.json, and no-ops the Bridge reset."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.handler = ModelsHandler.__new__(ModelsHandler)
|
||||||
|
self._file_cfg = {}
|
||||||
|
self.bridge_resets = 0
|
||||||
|
# Patch the disk + bridge boundary on this instance.
|
||||||
|
self.handler._read_file_config = lambda: dict(self._file_cfg)
|
||||||
|
self.handler._write_file_config = self._capture_write
|
||||||
|
self.handler._reset_bridge = self._capture_reset
|
||||||
|
|
||||||
|
def _capture_write(self, data):
|
||||||
|
self._file_cfg = dict(data)
|
||||||
|
|
||||||
|
def _capture_reset(self):
|
||||||
|
self.bridge_resets += 1
|
||||||
|
|
||||||
|
def call(self, **payload):
|
||||||
|
# Resolve the bound method by action for convenience.
|
||||||
|
action = payload.get("action")
|
||||||
|
method = {
|
||||||
|
"set_custom_provider": self.handler._handle_set_custom_provider,
|
||||||
|
"delete_custom_provider": self.handler._handle_delete_custom_provider,
|
||||||
|
"set_active_custom_provider": self.handler._handle_set_active_custom_provider,
|
||||||
|
}[action]
|
||||||
|
return json.loads(method(payload))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetCustomProvider(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
|
self.h = _HandlerHarness()
|
||||||
|
|
||||||
|
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")
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
self.assertTrue(res["created"])
|
||||||
|
self.assertIn("id", res)
|
||||||
|
# bot_type must remain unchanged — no auto-activation.
|
||||||
|
bot_type = config_module.conf().get("bot_type")
|
||||||
|
self.assertEqual(bot_type, "custom") # unchanged from setUp
|
||||||
|
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(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",
|
||||||
|
make_active=True)
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
bot_type = config_module.conf().get("bot_type")
|
||||||
|
self.assertEqual(bot_type, f"custom:{res['id']}")
|
||||||
|
|
||||||
|
def test_create_requires_api_base(self):
|
||||||
|
res = self.h.call(action="set_custom_provider", name="x", api_key="k")
|
||||||
|
self.assertEqual(res["status"], "error")
|
||||||
|
self.assertIn("api_base", res["message"])
|
||||||
|
|
||||||
|
def test_create_requires_name(self):
|
||||||
|
res = self.h.call(action="set_custom_provider", name="", api_base="https://x/v1")
|
||||||
|
self.assertEqual(res["status"], "error")
|
||||||
|
|
||||||
|
def test_second_provider_does_not_steal_active(self):
|
||||||
|
# Explicitly activate the first provider.
|
||||||
|
res1 = self.h.call(action="set_custom_provider", name="a",
|
||||||
|
api_base="https://a/v1", api_key="ak", make_active=True)
|
||||||
|
res2 = self.h.call(action="set_custom_provider", name="b",
|
||||||
|
api_base="https://b/v1", api_key="bk")
|
||||||
|
self.assertTrue(res2["created"])
|
||||||
|
# First provider stays active — second creation doesn't steal it.
|
||||||
|
bot_type = config_module.conf().get("bot_type")
|
||||||
|
self.assertEqual(bot_type, f"custom:{res1['id']}")
|
||||||
|
|
||||||
|
def test_make_active_flag(self):
|
||||||
|
self.h.call(action="set_custom_provider", name="a",
|
||||||
|
api_base="https://a/v1", api_key="ak")
|
||||||
|
res2 = self.h.call(action="set_custom_provider", name="b",
|
||||||
|
api_base="https://b/v1", api_key="bk", make_active=True)
|
||||||
|
bot_type = config_module.conf().get("bot_type")
|
||||||
|
self.assertEqual(bot_type, f"custom:{res2['id']}")
|
||||||
|
|
||||||
|
def test_edit_keeps_key_when_omitted(self):
|
||||||
|
res = self.h.call(action="set_custom_provider", name="a",
|
||||||
|
api_base="https://a/v1", api_key="secret")
|
||||||
|
pid = res["id"]
|
||||||
|
# Edit only the base; omit api_key.
|
||||||
|
res2 = self.h.call(action="set_custom_provider", name="a",
|
||||||
|
id=pid, api_base="https://a2/v1")
|
||||||
|
self.assertEqual(res2["status"], "success")
|
||||||
|
self.assertFalse(res2["created"])
|
||||||
|
providers = config_module.conf().get("custom_providers")
|
||||||
|
self.assertEqual(providers[0]["api_base"], "https://a2/v1")
|
||||||
|
self.assertEqual(providers[0]["api_key"], "secret") # preserved
|
||||||
|
|
||||||
|
def test_edit_can_rename(self):
|
||||||
|
res = self.h.call(action="set_custom_provider", name="old",
|
||||||
|
api_base="https://a/v1", api_key="ak")
|
||||||
|
pid = res["id"]
|
||||||
|
res2 = self.h.call(action="set_custom_provider", name="new",
|
||||||
|
id=pid, api_base="https://a/v1")
|
||||||
|
self.assertEqual(res2["status"], "success")
|
||||||
|
providers = config_module.conf().get("custom_providers")
|
||||||
|
self.assertEqual(providers[0]["name"], "new")
|
||||||
|
# ID stays the same
|
||||||
|
self.assertEqual(providers[0]["id"], pid)
|
||||||
|
|
||||||
|
def test_edit_clears_model_when_empty(self):
|
||||||
|
res = self.h.call(action="set_custom_provider", name="a",
|
||||||
|
api_base="https://a/v1", api_key="ak", model="m1")
|
||||||
|
pid = res["id"]
|
||||||
|
self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1")
|
||||||
|
self.h.call(action="set_custom_provider", name="a", id=pid,
|
||||||
|
api_base="https://a/v1", model="")
|
||||||
|
self.assertNotIn("model", config_module.conf().get("custom_providers")[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteCustomProvider(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
|
self.h = _HandlerHarness()
|
||||||
|
self.res_a = self.h.call(action="set_custom_provider", name="a",
|
||||||
|
api_base="https://a/v1", api_key="ak",
|
||||||
|
make_active=True)
|
||||||
|
self.res_b = self.h.call(action="set_custom_provider", name="b",
|
||||||
|
api_base="https://b/v1", api_key="bk")
|
||||||
|
|
||||||
|
def test_delete_unknown(self):
|
||||||
|
res = self.h.call(action="delete_custom_provider", id="ghost")
|
||||||
|
self.assertEqual(res["status"], "error")
|
||||||
|
|
||||||
|
def test_delete_non_active(self):
|
||||||
|
res = self.h.call(action="delete_custom_provider", id=self.res_b["id"])
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
ids = [p["id"] for p in config_module.conf().get("custom_providers")]
|
||||||
|
self.assertEqual(ids, [self.res_a["id"]])
|
||||||
|
# bot_type unchanged (still pointing to a)
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||||
|
|
||||||
|
def test_delete_active_falls_back_to_first_remaining(self):
|
||||||
|
# 'a' is active (created first); deleting it should re-point to 'b'.
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||||
|
res = self.h.call(action="delete_custom_provider", id=self.res_a["id"])
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
|
||||||
|
|
||||||
|
def test_delete_last_reverts_to_legacy(self):
|
||||||
|
self.h.call(action="delete_custom_provider", id=self.res_a["id"])
|
||||||
|
self.h.call(action="delete_custom_provider", id=self.res_b["id"])
|
||||||
|
self.assertEqual(config_module.conf().get("custom_providers"), [])
|
||||||
|
# When all providers deleted, reverts to legacy "custom"
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), "custom")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetActiveCustomProvider(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
|
self.h = _HandlerHarness()
|
||||||
|
self.res_a = self.h.call(action="set_custom_provider", name="a",
|
||||||
|
api_base="https://a/v1", api_key="ak",
|
||||||
|
make_active=True)
|
||||||
|
self.res_b = self.h.call(action="set_custom_provider", name="b",
|
||||||
|
api_base="https://b/v1", api_key="bk")
|
||||||
|
|
||||||
|
def test_set_active_valid(self):
|
||||||
|
res = self.h.call(action="set_active_custom_provider", id=self.res_b["id"])
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
|
||||||
|
|
||||||
|
def test_set_active_unknown(self):
|
||||||
|
res = self.h.call(action="set_active_custom_provider", id="ghost")
|
||||||
|
self.assertEqual(res["status"], "error")
|
||||||
|
# bot_type unchanged
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||||
|
|
||||||
|
def test_activation_syncs_model_to_global(self):
|
||||||
|
"""Activating a provider must write its model into global model field."""
|
||||||
|
set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"})
|
||||||
|
h = _HandlerHarness()
|
||||||
|
res = h.call(action="set_custom_provider", name="sf",
|
||||||
|
api_base="https://sf/v1", api_key="k", model="deepseek-v3",
|
||||||
|
make_active=True)
|
||||||
|
# Global model field should now be the provider's model.
|
||||||
|
self.assertEqual(config_module.conf().get("model"), "deepseek-v3")
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{res['id']}")
|
||||||
|
|
||||||
|
def test_activation_without_model_keeps_global_model(self):
|
||||||
|
"""Activating a provider with no model must not overwrite global model."""
|
||||||
|
set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"})
|
||||||
|
h = _HandlerHarness()
|
||||||
|
h.call(action="set_custom_provider", name="local",
|
||||||
|
api_base="http://localhost:11434/v1", api_key="",
|
||||||
|
make_active=True)
|
||||||
|
# Global model field should remain unchanged.
|
||||||
|
self.assertEqual(config_module.conf().get("model"), "gpt-4o")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderOverviewExpansion(unittest.TestCase):
|
||||||
|
"""_provider_overview / _custom_provider_cards should expand the list."""
|
||||||
|
|
||||||
|
def test_no_custom_providers_keeps_single_card(self):
|
||||||
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
|
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||||
|
self.assertEqual(cards, [])
|
||||||
|
overview = ModelsHandler._provider_overview()
|
||||||
|
custom_cards = [c for c in overview if c.get("id") == "custom"]
|
||||||
|
# Legacy single custom card remains present.
|
||||||
|
self.assertEqual(len(custom_cards), 1)
|
||||||
|
|
||||||
|
def test_multi_providers_expand_into_cards(self):
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom:id_b",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||||
|
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
overview = ModelsHandler._provider_overview()
|
||||||
|
custom_cards = [c for c in overview if c.get("is_custom")]
|
||||||
|
self.assertEqual(len(custom_cards), 2)
|
||||||
|
by_id = {c["custom_id"]: c for c in custom_cards}
|
||||||
|
self.assertEqual(by_id["id_a"]["id"], "custom:id_a")
|
||||||
|
self.assertFalse(by_id["id_a"]["active"])
|
||||||
|
self.assertTrue(by_id["id_b"]["active"])
|
||||||
|
self.assertEqual(by_id["id_b"]["model"], "m")
|
||||||
|
# No single legacy "custom" card when expanded.
|
||||||
|
self.assertFalse(any(c.get("id") == "custom" for c in overview))
|
||||||
|
|
||||||
|
def test_no_active_shows_none_active(self):
|
||||||
|
"""When bot_type is plain 'custom', no card is marked active."""
|
||||||
|
set_conf({
|
||||||
|
"bot_type": "custom",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||||
|
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||||
|
active_cards = [c for c in cards if c.get("active")]
|
||||||
|
self.assertEqual(len(active_cards), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
211
tests/test_knowledge_service.py
Normal file
211
tests/test_knowledge_service.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from agent.memory.storage import MemoryChunk, MemoryStorage
|
||||||
|
from agent.knowledge.service import KnowledgeService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStorage:
|
||||||
|
def __init__(self):
|
||||||
|
self.deleted = []
|
||||||
|
|
||||||
|
def delete_by_path(self, path):
|
||||||
|
self.deleted.append(path)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMemoryManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.storage = FakeStorage()
|
||||||
|
self.dirty = 0
|
||||||
|
self.synced = 0
|
||||||
|
|
||||||
|
def mark_dirty(self):
|
||||||
|
self.dirty += 1
|
||||||
|
|
||||||
|
async def sync(self):
|
||||||
|
self.synced += 1
|
||||||
|
|
||||||
|
|
||||||
|
def service(tmp_path):
|
||||||
|
(tmp_path / "knowledge").mkdir()
|
||||||
|
manager = FakeMemoryManager()
|
||||||
|
return KnowledgeService(str(tmp_path), manager), manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_lifecycle_and_confirmation(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
assert svc.dispatch("create_category", {"path": "notes"})["payload"]["created"]
|
||||||
|
(tmp_path / "knowledge/notes/a.md").write_text("# A", encoding="utf-8")
|
||||||
|
|
||||||
|
denied = svc.dispatch("delete_category", {"path": "notes"})
|
||||||
|
assert denied["code"] == 403
|
||||||
|
|
||||||
|
result = svc.dispatch("delete_category", {"path": "notes", "confirm": True})
|
||||||
|
assert result["payload"]["deleted_documents"] == 1
|
||||||
|
assert manager.storage.deleted == ["knowledge/notes/a.md"]
|
||||||
|
assert manager.synced == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_category_reindexes_documents(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
(tmp_path / "knowledge/old/sub").mkdir(parents=True)
|
||||||
|
(tmp_path / "knowledge/old/a.md").write_text("a", encoding="utf-8")
|
||||||
|
(tmp_path / "knowledge/old/sub/b.md").write_text("b", encoding="utf-8")
|
||||||
|
|
||||||
|
result = svc.dispatch("rename_category", {"path": "old", "new_path": "new"})
|
||||||
|
assert result["code"] == 200
|
||||||
|
assert sorted(manager.storage.deleted) == [
|
||||||
|
"knowledge/old/a.md", "knowledge/old/sub/b.md"
|
||||||
|
]
|
||||||
|
assert manager.synced == 1
|
||||||
|
assert (tmp_path / "knowledge/new/sub/b.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_documents_is_idempotent_and_protects_metadata(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
(tmp_path / "knowledge/index.md").write_text("index", encoding="utf-8")
|
||||||
|
(tmp_path / "knowledge/a.md").write_text("a", encoding="utf-8")
|
||||||
|
|
||||||
|
protected = svc.dispatch("delete_documents", {"paths": ["index.md"]})
|
||||||
|
assert protected["code"] == 403
|
||||||
|
first = svc.dispatch("delete_documents", {"paths": ["a.md", "missing.md"]})
|
||||||
|
assert first["payload"]["deleted"] == 1
|
||||||
|
second = svc.dispatch("delete_documents", {"paths": ["a.md"]})
|
||||||
|
assert second["payload"]["deleted"] == 0
|
||||||
|
assert manager.storage.deleted == ["knowledge/a.md", "knowledge/missing.md", "knowledge/a.md"]
|
||||||
|
assert manager.synced == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_documents_rejects_overwrite_and_syncs(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
(tmp_path / "knowledge/source").mkdir()
|
||||||
|
(tmp_path / "knowledge/target").mkdir()
|
||||||
|
(tmp_path / "knowledge/source/a.md").write_text("a", encoding="utf-8")
|
||||||
|
(tmp_path / "knowledge/source/b.md").write_text("b", encoding="utf-8")
|
||||||
|
(tmp_path / "knowledge/target/b.md").write_text("existing", encoding="utf-8")
|
||||||
|
|
||||||
|
result = svc.dispatch("move_documents", {
|
||||||
|
"paths": ["source/a.md", "source/b.md"], "target_category": "target"
|
||||||
|
})
|
||||||
|
assert result["payload"]["moved"] == 1
|
||||||
|
assert result["payload"]["results"][1]["reason"] == "target_exists"
|
||||||
|
assert manager.storage.deleted == ["knowledge/source/a.md"]
|
||||||
|
assert manager.synced == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_traversal_and_symlink_escape_are_rejected(tmp_path):
|
||||||
|
svc, _ = service(tmp_path)
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
(tmp_path / "knowledge/link").symlink_to(outside, target_is_directory=True)
|
||||||
|
|
||||||
|
assert svc.dispatch("create_category", {"path": "../bad"})["code"] == 403
|
||||||
|
assert svc.dispatch("create_category", {"path": "link/bad"})["code"] == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_sync_works_inside_running_event_loop(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
(tmp_path / "knowledge/source").mkdir()
|
||||||
|
(tmp_path / "knowledge/target").mkdir()
|
||||||
|
(tmp_path / "knowledge/source/a.md").write_text("a", encoding="utf-8")
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return svc.dispatch("move_documents", {
|
||||||
|
"paths": ["source/a.md"], "target_category": "target"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert asyncio.run(run())["code"] == 200
|
||||||
|
assert manager.synced == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_storage_delete_by_path_removes_chunks_and_file_metadata(tmp_path):
|
||||||
|
storage = MemoryStorage(tmp_path / "index.db")
|
||||||
|
path = "knowledge/category/a.md"
|
||||||
|
storage.save_chunks_batch([MemoryChunk(
|
||||||
|
id="chunk-1", user_id=None, scope="shared", source="knowledge",
|
||||||
|
path=path, start_line=1, end_line=1, text="unique content",
|
||||||
|
embedding=None, hash="hash-1",
|
||||||
|
)])
|
||||||
|
storage.update_file_metadata(path, "knowledge", "file-hash", 1, 14)
|
||||||
|
|
||||||
|
storage.delete_by_path(path)
|
||||||
|
|
||||||
|
assert storage.conn.execute("SELECT COUNT(*) FROM chunks WHERE path = ?", (path,)).fetchone()[0] == 0
|
||||||
|
assert storage.conn.execute("SELECT COUNT(*) FROM files WHERE path = ?", (path,)).fetchone()[0] == 0
|
||||||
|
storage.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_document_still_cleans_stale_index(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
|
||||||
|
result = svc.dispatch("delete_documents", {"paths": ["removed-by-agent.md"]})
|
||||||
|
|
||||||
|
assert result["code"] == 200
|
||||||
|
assert result["payload"]["results"][0]["reason"] == "not_found"
|
||||||
|
assert manager.storage.deleted == ["knowledge/removed-by-agent.md"]
|
||||||
|
assert manager.dirty == 1
|
||||||
|
assert manager.synced == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_rename_handles_concurrent_disappearance(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
category = tmp_path / "knowledge/source"
|
||||||
|
category.mkdir()
|
||||||
|
(category / "a.md").write_text("a", encoding="utf-8")
|
||||||
|
def disappear_then_rename(path, target):
|
||||||
|
(category / "a.md").unlink()
|
||||||
|
category.rmdir()
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
|
||||||
|
with patch.object(Path, "rename", disappear_then_rename):
|
||||||
|
result = svc.dispatch("rename_category", {"path": "source", "new_path": "target"})
|
||||||
|
|
||||||
|
assert result["code"] == 200
|
||||||
|
assert result["payload"]["reason"] == "not_found"
|
||||||
|
assert manager.storage.deleted == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_delete_handles_concurrent_disappearance(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
category = tmp_path / "knowledge/source"
|
||||||
|
category.mkdir()
|
||||||
|
(category / "a.md").write_text("a", encoding="utf-8")
|
||||||
|
|
||||||
|
def disappear_then_delete(path):
|
||||||
|
(category / "a.md").unlink()
|
||||||
|
category.rmdir()
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
|
||||||
|
with patch("agent.knowledge.service.shutil.rmtree", side_effect=disappear_then_delete):
|
||||||
|
result = svc.dispatch("delete_category", {"path": "source", "confirm": True})
|
||||||
|
|
||||||
|
assert result["code"] == 200
|
||||||
|
assert result["payload"]["reason"] == "not_found"
|
||||||
|
assert manager.storage.deleted == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_does_not_overwrite_target_created_concurrently(tmp_path):
|
||||||
|
svc, manager = service(tmp_path)
|
||||||
|
(tmp_path / "knowledge/source").mkdir()
|
||||||
|
(tmp_path / "knowledge/target").mkdir()
|
||||||
|
source = tmp_path / "knowledge/source/a.md"
|
||||||
|
target = tmp_path / "knowledge/target/a.md"
|
||||||
|
source.write_text("source", encoding="utf-8")
|
||||||
|
real_link = os.link
|
||||||
|
|
||||||
|
def create_target_then_link(src, dst):
|
||||||
|
target.write_text("concurrent", encoding="utf-8")
|
||||||
|
return real_link(src, dst)
|
||||||
|
|
||||||
|
with patch("agent.knowledge.service.os.link", side_effect=create_target_then_link):
|
||||||
|
result = svc.dispatch("move_documents", {
|
||||||
|
"paths": ["source/a.md"], "target_category": "target",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result["payload"]["results"][0]["reason"] == "target_exists"
|
||||||
|
assert source.read_text(encoding="utf-8") == "source"
|
||||||
|
assert target.read_text(encoding="utf-8") == "concurrent"
|
||||||
|
assert manager.storage.deleted == []
|
||||||
61
tests/test_knowledge_web.py
Normal file
61
tests/test_knowledge_web.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_knowledge_action_handler_delegates_to_dispatch(tmp_path):
|
||||||
|
from channel.web.web_channel import KnowledgeActionHandler
|
||||||
|
|
||||||
|
request = {"action": "create_category", "payload": {"path": "research"}}
|
||||||
|
dispatched = {"action": "create_category", "code": 200, "message": "success",
|
||||||
|
"payload": {"path": "research", "created": True}}
|
||||||
|
|
||||||
|
with patch("channel.web.web_channel._require_auth"), \
|
||||||
|
patch("channel.web.web_channel.web.header"), \
|
||||||
|
patch("channel.web.web_channel.web.data", return_value=json.dumps(request).encode()), \
|
||||||
|
patch("channel.web.web_channel._get_workspace_root", return_value=str(tmp_path)), \
|
||||||
|
patch("agent.knowledge.service.KnowledgeService.dispatch", return_value=dispatched) as dispatch:
|
||||||
|
response = json.loads(KnowledgeActionHandler().POST())
|
||||||
|
|
||||||
|
dispatch.assert_called_once_with("create_category", {"path": "research"})
|
||||||
|
assert response["status"] == "success"
|
||||||
|
assert response["payload"]["created"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_knowledge_action_handler_preserves_dispatch_error(tmp_path):
|
||||||
|
from channel.web.web_channel import KnowledgeActionHandler
|
||||||
|
|
||||||
|
dispatched = {"action": "delete_documents", "code": 403,
|
||||||
|
"message": "protected knowledge file: index.md", "payload": None}
|
||||||
|
request = {"action": "delete_documents", "payload": {"paths": ["index.md"]}}
|
||||||
|
|
||||||
|
with patch("channel.web.web_channel._require_auth"), \
|
||||||
|
patch("channel.web.web_channel.web.header"), \
|
||||||
|
patch("channel.web.web_channel.web.data", return_value=json.dumps(request).encode()), \
|
||||||
|
patch("channel.web.web_channel._get_workspace_root", return_value=str(tmp_path)), \
|
||||||
|
patch("agent.knowledge.service.KnowledgeService.dispatch", return_value=dispatched):
|
||||||
|
response = json.loads(KnowledgeActionHandler().POST())
|
||||||
|
|
||||||
|
assert response["status"] == "error"
|
||||||
|
assert response["code"] == 403
|
||||||
|
assert response["message"] == "protected knowledge file: index.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_knowledge_frontend_management_contract():
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
html = (root / "channel/web/chat.html").read_text(encoding="utf-8")
|
||||||
|
js = (root / "channel/web/static/js/console.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'id="knowledge-dialog-overlay"' in html
|
||||||
|
assert "function openKnowledgeDialog(" in js
|
||||||
|
assert "function _knowledgeCategoryPaths(" in js
|
||||||
|
assert "dispatchKnowledgeAction('create_category'" in js
|
||||||
|
assert "dispatchKnowledgeAction('rename_category'" in js
|
||||||
|
assert "dispatchKnowledgeAction('delete_category'" in js
|
||||||
|
assert "dispatchKnowledgeAction('delete_documents'" in js
|
||||||
|
assert "dispatchKnowledgeAction('move_documents'" in js
|
||||||
|
|
||||||
|
knowledge_section = js[js.index("// Knowledge View"):js.index("function _hasFilterMatch")]
|
||||||
|
assert "prompt(" not in knowledge_section
|
||||||
|
assert "alert(" not in knowledge_section
|
||||||
|
assert "if (path === 'index.md' || path === 'log.md') return '';" in knowledge_section
|
||||||
121
tests/test_robustness_fixes.py
Normal file
121
tests/test_robustness_fixes.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
"""
|
||||||
|
Unit tests for robustness fixes:
|
||||||
|
1. ChatChannel.cancel_session / cancel_all_session must not raise KeyError
|
||||||
|
when a session has been produced but no task has been dispatched yet
|
||||||
|
(so self.futures[session_id] does not exist).
|
||||||
|
2. common.utils.compress_imgfile must terminate (no infinite loop / invalid
|
||||||
|
PIL quality) when an image cannot be compressed below max_size.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. cancel_session / cancel_all_session KeyError regression
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCancelSessionMissingFutures(unittest.TestCase):
|
||||||
|
"""A session may exist in self.sessions before any future is recorded."""
|
||||||
|
|
||||||
|
def _make_channel(self):
|
||||||
|
# Import lazily and build a bare object without running __init__,
|
||||||
|
# to avoid pulling the full channel setup / config.
|
||||||
|
from channel.chat_channel import ChatChannel
|
||||||
|
|
||||||
|
ch = ChatChannel.__new__(ChatChannel)
|
||||||
|
import threading
|
||||||
|
|
||||||
|
ch.lock = threading.RLock()
|
||||||
|
# A produced session whose future has NOT been dispatched yet.
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.qsize.return_value = 0
|
||||||
|
semaphore = MagicMock()
|
||||||
|
ch.sessions = {"sid": [queue, semaphore]}
|
||||||
|
ch.futures = {} # intentionally empty: consume() never ran
|
||||||
|
return ch
|
||||||
|
|
||||||
|
def test_cancel_session_no_futures_entry(self):
|
||||||
|
ch = self._make_channel()
|
||||||
|
# Should not raise KeyError.
|
||||||
|
try:
|
||||||
|
ch.cancel_session("sid")
|
||||||
|
except KeyError:
|
||||||
|
self.fail("cancel_session raised KeyError when futures entry missing")
|
||||||
|
|
||||||
|
def test_cancel_all_session_no_futures_entry(self):
|
||||||
|
ch = self._make_channel()
|
||||||
|
try:
|
||||||
|
ch.cancel_all_session()
|
||||||
|
except KeyError:
|
||||||
|
self.fail("cancel_all_session raised KeyError when futures entry missing")
|
||||||
|
|
||||||
|
def test_cancel_session_cancels_existing_futures(self):
|
||||||
|
ch = self._make_channel()
|
||||||
|
fut = MagicMock()
|
||||||
|
ch.futures["sid"] = [fut]
|
||||||
|
ch.cancel_session("sid")
|
||||||
|
fut.cancel.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. compress_imgfile termination
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCompressImgfileTermination(unittest.TestCase):
|
||||||
|
"""compress_imgfile must always return, even for incompressible input."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Skip if Pillow is not available in the test environment.
|
||||||
|
try:
|
||||||
|
import PIL # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
self.skipTest("Pillow not installed")
|
||||||
|
|
||||||
|
def _make_image_buf(self, size=(64, 64)):
|
||||||
|
from PIL import Image
|
||||||
|
import random
|
||||||
|
|
||||||
|
img = Image.new("RGB", size)
|
||||||
|
# Fill with random noise so JPEG cannot compress it well.
|
||||||
|
pixels = [
|
||||||
|
(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
||||||
|
for _ in range(size[0] * size[1])
|
||||||
|
]
|
||||||
|
img.putdata(pixels)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, "JPEG", quality=95)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def test_returns_when_target_unreachable(self):
|
||||||
|
from common.utils import compress_imgfile
|
||||||
|
|
||||||
|
buf = self._make_image_buf()
|
||||||
|
# An impossibly small target that even quality=10 won't reach.
|
||||||
|
out = compress_imgfile(buf, max_size=10)
|
||||||
|
self.assertIsInstance(out, io.BytesIO)
|
||||||
|
# Verify the result is still a valid JPEG (PIL never got invalid quality).
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
out.seek(0)
|
||||||
|
img = Image.open(out)
|
||||||
|
img.verify()
|
||||||
|
|
||||||
|
def test_no_compression_needed_returns_same_object(self):
|
||||||
|
from common.utils import compress_imgfile
|
||||||
|
|
||||||
|
buf = self._make_image_buf()
|
||||||
|
size = buf.getbuffer().nbytes
|
||||||
|
out = compress_imgfile(buf, max_size=size + 1)
|
||||||
|
self.assertIs(out, buf)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
194
tests/test_security_ssrf_path_traversal.py
Normal file
194
tests/test_security_ssrf_path_traversal.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
"""
|
||||||
|
Unit tests for security fixes:
|
||||||
|
1. Vision tool SSRF protection (issue #2878, #2872)
|
||||||
|
2. Skill service path traversal protection (issue #2873)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
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 vision.py can be imported for testing.
|
||||||
|
if "requests" not in sys.modules:
|
||||||
|
_requests_stub = types.ModuleType("requests")
|
||||||
|
_requests_stub.get = lambda *a, **k: None
|
||||||
|
sys.modules["requests"] = _requests_stub
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Vision SSRF tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestVisionSSRFValidation(unittest.TestCase):
|
||||||
|
"""Test that _validate_url_safe blocks internal/private URLs."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from agent.tools.vision.vision import Vision
|
||||||
|
self.validate = Vision._validate_url_safe
|
||||||
|
|
||||||
|
def test_loopback_ipv4_blocked(self):
|
||||||
|
"""127.0.0.1 must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://127.0.0.1/canary.png")
|
||||||
|
self.assertIn("non-public", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_loopback_localhost_blocked(self):
|
||||||
|
"""localhost must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://localhost/canary.png")
|
||||||
|
self.assertIn("non-public", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_private_10_network_blocked(self):
|
||||||
|
"""10.x.x.x RFC1918 must be rejected."""
|
||||||
|
with patch("socket.getaddrinfo") as mock_gai:
|
||||||
|
mock_gai.return_value = [
|
||||||
|
(2, 1, 6, "", ("10.0.0.1", 0)),
|
||||||
|
]
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://internal.corp/image.png")
|
||||||
|
self.assertIn("non-public", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_private_172_network_blocked(self):
|
||||||
|
"""172.16.x.x RFC1918 must be rejected."""
|
||||||
|
with patch("socket.getaddrinfo") as mock_gai:
|
||||||
|
mock_gai.return_value = [
|
||||||
|
(2, 1, 6, "", ("172.16.0.1", 0)),
|
||||||
|
]
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://internal.corp/image.png")
|
||||||
|
self.assertIn("non-public", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_private_192_168_blocked(self):
|
||||||
|
"""192.168.x.x RFC1918 must be rejected."""
|
||||||
|
with patch("socket.getaddrinfo") as mock_gai:
|
||||||
|
mock_gai.return_value = [
|
||||||
|
(2, 1, 6, "", ("192.168.1.1", 0)),
|
||||||
|
]
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://router.local/image.png")
|
||||||
|
self.assertIn("non-public", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_link_local_blocked(self):
|
||||||
|
"""169.254.x.x (link-local / cloud metadata) must be rejected."""
|
||||||
|
with patch("socket.getaddrinfo") as mock_gai:
|
||||||
|
mock_gai.return_value = [
|
||||||
|
(2, 1, 6, "", ("169.254.169.254", 0)),
|
||||||
|
]
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://metadata.google.internal/image.png")
|
||||||
|
self.assertIn("non-public", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_ipv6_loopback_blocked(self):
|
||||||
|
"""::1 (IPv6 loopback) must be rejected."""
|
||||||
|
with patch("socket.getaddrinfo") as mock_gai:
|
||||||
|
mock_gai.return_value = [
|
||||||
|
(10, 1, 6, "", ("::1", 0, 0, 0)),
|
||||||
|
]
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://[::1]/image.png")
|
||||||
|
self.assertIn("non-public", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_public_url_allowed(self):
|
||||||
|
"""A URL resolving to a public IP should pass validation."""
|
||||||
|
with patch("socket.getaddrinfo") as mock_gai:
|
||||||
|
mock_gai.return_value = [
|
||||||
|
(2, 1, 6, "", ("151.101.1.140", 0)),
|
||||||
|
]
|
||||||
|
# Should not raise
|
||||||
|
self.validate("https://cdn.example.com/image.png")
|
||||||
|
|
||||||
|
def test_no_hostname_rejected(self):
|
||||||
|
"""A URL with no host must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http:///path/to/image.png")
|
||||||
|
self.assertIn("no hostname", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_non_http_scheme_rejected(self):
|
||||||
|
"""file:// and ftp:// schemes must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("file:///etc/passwd")
|
||||||
|
self.assertIn("scheme", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_dns_failure_rejected(self):
|
||||||
|
"""Unresolvable hostname must be rejected."""
|
||||||
|
import socket as sock_mod
|
||||||
|
with patch("socket.getaddrinfo", side_effect=sock_mod.gaierror("Name does not resolve")):
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.validate("http://nonexistent.invalid/img.png")
|
||||||
|
self.assertIn("Cannot resolve", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Skill service path traversal tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestSkillServicePathTraversal(unittest.TestCase):
|
||||||
|
"""Test that _safe_skill_dir blocks path traversal attempts."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp_root = tempfile.mkdtemp()
|
||||||
|
# Create a minimal SkillManager mock with custom_dir set.
|
||||||
|
from agent.skills.service import SkillService
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_manager.custom_dir = self.tmp_root
|
||||||
|
self.svc = SkillService(mock_manager)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.tmp_root, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_normal_name_allowed(self):
|
||||||
|
"""A simple name like 'my-skill' should produce a valid path."""
|
||||||
|
result = self.svc._safe_skill_dir("my-skill")
|
||||||
|
expected = os.path.realpath(os.path.join(self.tmp_root, "my-skill"))
|
||||||
|
self.assertEqual(result, expected)
|
||||||
|
|
||||||
|
def test_dotdot_traversal_blocked(self):
|
||||||
|
"""'../escaped' must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.svc._safe_skill_dir("../escaped")
|
||||||
|
self.assertIn("path traversal", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_nested_dotdot_blocked(self):
|
||||||
|
"""'foo/../../escaped' must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.svc._safe_skill_dir("foo/../../escaped")
|
||||||
|
self.assertIn("path traversal", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_absolute_path_blocked(self):
|
||||||
|
"""'/tmp/evil' must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.svc._safe_skill_dir("/tmp/evil")
|
||||||
|
self.assertIn("path traversal", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_backslash_path_blocked(self):
|
||||||
|
r"""'\\server\share' must be rejected."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.svc._safe_skill_dir("\\server\\share")
|
||||||
|
self.assertIn("path traversal", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_empty_name_blocked(self):
|
||||||
|
"""Empty name must be rejected."""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.svc._safe_skill_dir("")
|
||||||
|
|
||||||
|
def test_whitespace_only_blocked(self):
|
||||||
|
"""Whitespace-only name must be rejected."""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.svc._safe_skill_dir(" ")
|
||||||
|
|
||||||
|
def test_subdir_name_allowed(self):
|
||||||
|
"""A name with a forward slash but no traversal is allowed if it stays in root."""
|
||||||
|
# e.g. "category/skill-name" is a valid nested skill directory
|
||||||
|
result = self.svc._safe_skill_dir("category/skill-name")
|
||||||
|
expected = os.path.realpath(os.path.join(self.tmp_root, "category/skill-name"))
|
||||||
|
self.assertEqual(result, expected)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
188
tests/test_security_ssrf_web_fetch.py
Normal file
188
tests/test_security_ssrf_web_fetch.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
"""
|
||||||
|
Regression tests for web_fetch SSRF protection.
|
||||||
|
|
||||||
|
The web_fetch tool fetches model-supplied URLs. Without a guard, a model
|
||||||
|
(including one under prompt injection) can point it at loopback, RFC1918,
|
||||||
|
link-local or cloud-metadata (169.254.169.254) endpoints, or use a public
|
||||||
|
URL that 3xx-redirects into such a target. These tests ensure web_fetch
|
||||||
|
refuses the request instead of connecting to the internal address.
|
||||||
|
|
||||||
|
No real network is used: DNS resolution and ``requests.get`` are stubbed.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
# Stub 'requests' if not installed so the module can be imported for testing.
|
||||||
|
if "requests" not in sys.modules:
|
||||||
|
_requests_stub = types.ModuleType("requests")
|
||||||
|
_requests_stub.get = lambda *a, **k: None
|
||||||
|
|
||||||
|
class _Exc(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
_requests_stub.Timeout = type("Timeout", (_Exc,), {})
|
||||||
|
_requests_stub.ConnectionError = type("ConnectionError", (_Exc,), {})
|
||||||
|
_requests_stub.HTTPError = type("HTTPError", (_Exc,), {})
|
||||||
|
_requests_stub.Response = object
|
||||||
|
_compat = types.SimpleNamespace(urljoin=__import__("urllib.parse", fromlist=["urljoin"]).urljoin)
|
||||||
|
_requests_stub.compat = _compat
|
||||||
|
sys.modules["requests"] = _requests_stub
|
||||||
|
|
||||||
|
|
||||||
|
def _gai(ip_str):
|
||||||
|
"""Build a socket.getaddrinfo return value for a single IPv4 address."""
|
||||||
|
return [(2, 1, 6, "", (ip_str, 0))]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRedirect:
|
||||||
|
"""Minimal stand-in for a requests redirect Response."""
|
||||||
|
|
||||||
|
def __init__(self, location):
|
||||||
|
self.is_redirect = True
|
||||||
|
self.is_permanent_redirect = False
|
||||||
|
self.headers = {"Location": location}
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ok_response(body=b"<html><head><title>internal</title></head><body>secret</body></html>"):
|
||||||
|
"""A well-formed non-redirect response.
|
||||||
|
|
||||||
|
Returned by the mocked ``requests.get`` so that on UNPATCHED code the
|
||||||
|
fetch path runs to completion and the test fails specifically on the
|
||||||
|
``assert_not_called`` guard (proving a request reached the internal
|
||||||
|
target), rather than on an incidental error.
|
||||||
|
"""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.is_redirect = False
|
||||||
|
resp.is_permanent_redirect = False
|
||||||
|
resp.status_code = 200
|
||||||
|
resp.headers = {"Content-Type": "text/html; charset=utf-8"}
|
||||||
|
resp.content = body
|
||||||
|
resp.text = body.decode("utf-8")
|
||||||
|
resp.apparent_encoding = "utf-8"
|
||||||
|
resp.raise_for_status = lambda: None
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebFetchSSRF(unittest.TestCase):
|
||||||
|
"""web_fetch must refuse internal targets and never connect to them."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from agent.tools.web_fetch.web_fetch import WebFetch
|
||||||
|
self.tool = WebFetch()
|
||||||
|
|
||||||
|
# --- Literal internal IPs: rejected before any socket call ---
|
||||||
|
|
||||||
|
def test_loopback_literal_blocked(self):
|
||||||
|
"""http://127.0.0.1:<port>/x must be refused, no request issued."""
|
||||||
|
with patch("requests.get", return_value=_fake_ok_response()) as mock_get:
|
||||||
|
result = self.tool.execute({"url": "http://127.0.0.1:8080/canary"})
|
||||||
|
self.assertEqual(result.status, "error")
|
||||||
|
self.assertIn("non-public", result.result)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
def test_cloud_metadata_literal_blocked(self):
|
||||||
|
"""http://169.254.169.254/latest/meta-data/ must be refused."""
|
||||||
|
with patch("requests.get", return_value=_fake_ok_response()) as mock_get:
|
||||||
|
result = self.tool.execute(
|
||||||
|
{"url": "http://169.254.169.254/latest/meta-data/"}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.status, "error")
|
||||||
|
self.assertIn("non-public", result.result)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
def test_ipv6_loopback_literal_blocked(self):
|
||||||
|
"""http://[::1]/x must be refused."""
|
||||||
|
with patch("requests.get", return_value=_fake_ok_response()) as mock_get:
|
||||||
|
result = self.tool.execute({"url": "http://[::1]/canary"})
|
||||||
|
self.assertEqual(result.status, "error")
|
||||||
|
self.assertIn("non-public", result.result)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
# --- RFC1918 host resolved via DNS: rejected after resolution ---
|
||||||
|
|
||||||
|
def test_rfc1918_hostname_blocked(self):
|
||||||
|
"""A hostname that resolves to 10.x.x.x must be refused, no request."""
|
||||||
|
with patch("socket.getaddrinfo", return_value=_gai("10.1.2.3")), \
|
||||||
|
patch("requests.get", return_value=_fake_ok_response()) as mock_get:
|
||||||
|
result = self.tool.execute({"url": "http://internal.corp/secret"})
|
||||||
|
self.assertEqual(result.status, "error")
|
||||||
|
self.assertIn("non-public", result.result)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
def test_192_168_hostname_blocked(self):
|
||||||
|
"""A hostname that resolves to 192.168.x.x must be refused."""
|
||||||
|
with patch("socket.getaddrinfo", return_value=_gai("192.168.0.5")), \
|
||||||
|
patch("requests.get", return_value=_fake_ok_response()) as mock_get:
|
||||||
|
result = self.tool.execute({"url": "http://router.local/admin"})
|
||||||
|
self.assertEqual(result.status, "error")
|
||||||
|
self.assertIn("non-public", result.result)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
# --- Redirect bounce: public entry URL 302 -> loopback ---
|
||||||
|
|
||||||
|
def test_public_to_loopback_redirect_blocked(self):
|
||||||
|
"""A public URL that redirects to a loopback target must be refused.
|
||||||
|
|
||||||
|
The first hop resolves to a public IP and returns a 302 pointing at
|
||||||
|
127.0.0.1; the guard must re-validate the redirect target and refuse
|
||||||
|
instead of fetching the internal address.
|
||||||
|
"""
|
||||||
|
redirect = _FakeRedirect("http://127.0.0.1:8080/canary")
|
||||||
|
|
||||||
|
def fake_getaddrinfo(host, *a, **k):
|
||||||
|
# Public entry host resolves to a public IP; the loopback literal
|
||||||
|
# echoes back (as the real getaddrinfo does for an IP literal).
|
||||||
|
if host == "evil.example.com":
|
||||||
|
return _gai("93.184.216.34")
|
||||||
|
return _gai(host)
|
||||||
|
|
||||||
|
with patch("socket.getaddrinfo", side_effect=fake_getaddrinfo), \
|
||||||
|
patch("requests.get", return_value=redirect) as mock_get:
|
||||||
|
result = self.tool.execute({"url": "http://evil.example.com/start"})
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "error")
|
||||||
|
self.assertIn("non-public", result.result)
|
||||||
|
# The first (public) hop is issued exactly once; the loopback hop is
|
||||||
|
# rejected by the guard BEFORE a second requests.get to the internal
|
||||||
|
# target is made.
|
||||||
|
self.assertEqual(mock_get.call_count, 1)
|
||||||
|
first_call_url = mock_get.call_args[0][0]
|
||||||
|
self.assertEqual(first_call_url, "http://evil.example.com/start")
|
||||||
|
# The follow-up request to the internal target was never issued.
|
||||||
|
for call in mock_get.call_args_list:
|
||||||
|
self.assertNotIn("127.0.0.1", call[0][0])
|
||||||
|
|
||||||
|
# --- Sanity: a public URL is allowed to proceed to the fetch path ---
|
||||||
|
|
||||||
|
def test_public_url_allowed_through_guard(self):
|
||||||
|
"""A public URL passes the guard and a (mocked) request is issued."""
|
||||||
|
ok = MagicMock()
|
||||||
|
ok.is_redirect = False
|
||||||
|
ok.is_permanent_redirect = False
|
||||||
|
ok.headers = {"Content-Type": "text/html; charset=utf-8"}
|
||||||
|
ok.content = b"<html><head><title>Hi</title></head><body>ok</body></html>"
|
||||||
|
ok.text = "<html><head><title>Hi</title></head><body>ok</body></html>"
|
||||||
|
ok.apparent_encoding = "utf-8"
|
||||||
|
ok.raise_for_status = lambda: None
|
||||||
|
|
||||||
|
with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34")), \
|
||||||
|
patch("requests.get", return_value=ok) as mock_get:
|
||||||
|
result = self.tool.execute({"url": "http://example.com/page"})
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "success")
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
self.assertEqual(mock_get.call_args[0][0], "http://example.com/page")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user