Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ jobs:
- name: Run Mypy
run: uv run mypy .

- name: Run Tests
run: uv run pytest tests/

- name: Build wheel
run: uv build --wheel

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
- name: Run Mypy
run: uv run mypy .

- name: Run Tests
run: uv run pytest tests/

- name: Build project
run: uv build

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
- **Skills 架构**:全新设计的技能系统,将基础工具(Tools)与智能代理(Agents)分层管理,支持自动发现与注册。
- **Skills 热重载**:自动扫描 `skills/` 目录,检测到变更后即时重载工具与 Agent,无需重启服务。
- **配置热更新 + WebUI**:使用 `config.toml` 配置,支持热更新;提供 WebUI 在线编辑与校验。
- **会话白名单(群/私聊)**:只需配置 `access.allowed_group_ids` / `access.allowed_private_ids` 两个列表,即可把机器人“锁”在指定群与指定私聊里;避免被拉进陌生群误触发、也避免工具/定时任务把消息误发到不该去的地方(默认留空不限制)。
- **多模型池**:支持配置多个 AI 模型,可轮询、随机选择或用户指定;支持多模型并发比较,选择最佳结果继续对话。详见 [多模型功能文档](docs/multi-model.md)。
- **会话白名单(群/私聊)**:只需配置 `access.allowed_group_ids` / `access.allowed_private_ids` 两个列表,即可把机器人"锁"在指定群与指定私聊里;避免被拉进陌生群误触发、也避免工具/定时任务把消息误发到不该去的地方(默认留空不限制)。
- **并发防重复执行(进行中摘要)**:对私聊与 `@机器人` 场景在首轮前预占位,并在后续请求注入 `【进行中的任务】` 上下文,减少"催促/追问"导致的重复任务执行;支持通过 `features.inflight_pre_register_enabled`(预注册占位,默认启用)和 `features.inflight_summary_enabled`(摘要生成,默认禁用)独立控制。
- **并行工具执行**:无论是主 AI 还是子 Agent,均支持 `asyncio` 并发工具调用,大幅提升多任务处理速度(如同时读取多个文件或搜索多个关键词)。
- **智能 Agent 矩阵**:内置多个专业 Agent,分工协作处理复杂任务。
Expand Down
13 changes: 13 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"files": {
"include": ["src/Undefined/webui/static/js/**/*.js"]
},
"linter": {
"rules": {
"correctness": {
"noUnusedVariables": "off"
}
}
}
}
30 changes: 30 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ ws_url = "ws://127.0.0.1:3001"
# en: Access token (optional).
token = ""

[models]
# zh: 对话模型配置(主模型,处理每一条消息)。
# en: Chat model config (the main model, processing each message).
[models.chat]
Expand Down Expand Up @@ -85,6 +86,19 @@ thinking_include_budget = true
# en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models.
thinking_tool_call_compat = false

# zh: 模型池配置(可选,支持多模型轮询/随机/用户指定)。
# en: Model pool configuration (optional, supports round-robin/random/user-specified).
[models.chat.pool]
# zh: 是否启用模型池。
# en: Enable model pool.
enabled = false
# zh: 分配策略:default(用户指定)/ round_robin(轮询)/ random(随机)。
# en: Strategy: default (user-specified) / round_robin / random.
strategy = "default"
# zh: 模型池列表(每项需填 model_name、api_url、api_key,其余字段可选,缺省继承主模型)。
# en: Model pool entries (model_name, api_url, api_key required; others optional, inherit from primary).
models = []

# zh: 视觉模型配置(用于图片描述和 OCR)。
# en: Vision model config (image description and OCR).
[models.vision]
Expand Down Expand Up @@ -178,6 +192,19 @@ thinking_include_budget = true
# en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models.
thinking_tool_call_compat = false

# zh: Agent 模型池配置(可选,支持多模型轮询/随机/用户指定)。
# en: Agent model pool configuration (optional, supports round-robin/random/user-specified).
[models.agent.pool]
# zh: 是否启用 Agent 模型池。
# en: Enable agent model pool.
enabled = false
# zh: 分配策略:default(用户指定)/ round_robin(轮询)/ random(随机)。
# en: Strategy: default (user-specified) / round_robin / random.
strategy = "default"
# zh: Agent 模型池列表(每项需填 model_name、api_url、api_key,其余字段可选,缺省继承主模型)。
# en: Agent model pool entries (model_name, api_url, api_key required; others optional, inherit from primary).
models = []

# zh: 进行中任务摘要模型配置(可选,用于并发真空期的"处理中摘要")。仅当 [queue] 下 inflight_summary_enabled = true 时生效。
# en: Inflight summary model config (optional, used to generate in-progress task summaries). Only effective when inflight_summary_enabled = true under [queue].
# zh: 当 api_url/api_key/model_name 任一为空时,自动回退到 [models.chat]。
Expand Down Expand Up @@ -281,6 +308,9 @@ inflight_pre_register_enabled = true
# en: - true: enable, generate action summary asynchronously
# en: - false: disable (default), no summary generation
inflight_summary_enabled = false
# zh: 多模型池全局开关(关闭后所有多模型功能禁用,行为与原版一致)。无特殊需求不建议启用。
# en: Global model pool switch. When disabled, all multi-model features are disabled. Not recommended unless you have specific needs.
pool_enabled = false

# zh: 彩蛋功能(可选)。
# en: Easter egg features (optional)
Expand Down
135 changes: 135 additions & 0 deletions docs/multi-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# 多模型池功能

## 功能概述

- **Chat 模型池(私聊)**:轮询/随机自动切换,或在私聊中通过「选X」指定模型
- **Agent 模型池**:按策略(轮询/随机)自动分配,无需用户干预

> 仅私聊支持用户手动切换 Chat 模型;群聊始终使用主模型。

## 配置方式

### 方式一:WebUI

启动 `uv run Undefined-webui`,登录后进入「配置修改」页:

- **全局开关**:`features` → `pool_enabled` 设为 `true`
- **Chat 模型池**:`models` → `chat` → `pool`,设置 `enabled`、`strategy`,在 `models` 列表中添加/移除条目
- **Agent 模型池**:`models` → `agent` → `pool`,同上

每次修改自动保存并热更新,无需重启。

### 方式二:直接编辑 config.toml

## 配置

### 1. 全局开关

```toml
[features]
pool_enabled = true # 默认 false,需显式开启
```

### 2. Chat 模型池

```toml
[models.chat.pool]
enabled = true
strategy = "round_robin" # "default" | "round_robin" | "random"

[[models.chat.pool.models]]
model_name = "claude-sonnet-4-20250514"
api_url = "https://api.anthropic.com/v1"
api_key = "sk-ant-xxx"
# 其他字段(max_tokens, thinking_* 等)可选,缺省继承主模型

[[models.chat.pool.models]]
model_name = "deepseek-chat"
api_url = "https://api.deepseek.com/v1"
api_key = "sk-ds-xxx"
```

### 3. Agent 模型池

```toml
[models.agent.pool]
enabled = true
strategy = "round_robin" # "default" | "round_robin" | "random"

[[models.agent.pool.models]]
model_name = "claude-sonnet-4-20250514"
api_url = "https://api.anthropic.com/v1"
api_key = "sk-ant-xxx"
```

### strategy 说明

| 值 | 行为 |
|----|------|
| `default` | 只使用主模型,忽略池中模型 |
| `round_robin` | 按顺序轮流使用池中模型 |
| `random` | 每次随机选择池中模型 |

> `pool.models` 中只有 `model_name` 必填,其余字段缺省时继承主模型配置。

## 私聊使用方法

### 自动轮换

配置 `strategy = "round_robin"` 或 `"random"` 后,私聊请求会自动在池中模型间切换,无需任何操作。

### 手动指定模型(私聊)

1. 私聊发送 `/compare <问题>` 或 `/pk <问题>`,bot 并发请求所有模型并编号返回:

```
你: /compare 写一首关于春天的诗

bot:
正在向 3 个模型发送问题,请稍候...

问题: 写一首关于春天的诗

【1】gpt-4o
春风拂面暖如酥...

【2】claude-sonnet-4-20250514
春日融融暖意浓...

【3】deepseek-chat
春回大地万象新...

回复「选X」可切换到该模型并继续对话
```

2. 5 分钟内回复 `选2`,后续私聊固定使用第 2 个模型继续对话。

3. 偏好持久化保存在 `data/model_preferences.json`,重启后保留。

## 开关层级

```
features.pool_enabled ← 全局总开关(false 时完全不生效)
└─ models.chat.pool.enabled ← Chat 模型池开关
└─ models.agent.pool.enabled ← Agent 模型池开关
```

## 注意事项

- 不同模型使用独立队列,互不影响
- 所有模型的 Token 使用均会被统计
- 「选X」状态 5 分钟后过期
- 群聊不受多模型池影响,始终使用主模型

## 代码结构

| 文件 | 职责 |
|------|------|
| `config/models.py` | `ModelPool`, `ModelPoolEntry` 数据类 |
| `config/loader.py` | 解析 pool 配置,字段缺省继承主模型 |
| `ai/model_selector.py` | 纯选择逻辑:策略、偏好存储、compare 状态 |
| `services/model_pool.py` | 私聊交互服务:`/compare`、「选X」、`select_chat_config` |
| `services/ai_coordinator.py` | 持有 `ModelPoolService`,私聊队列投递时选模型 |
| `handlers.py` | 私聊消息委托给 `model_pool.handle_private_message()` |
| `skills/agents/runner.py` | Agent 执行时调用 `model_selector.select_agent_config()` |
| `utils/queue_intervals.py` | 注册 pool 模型的队列间隔 |
39 changes: 36 additions & 3 deletions src/Undefined/ai/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import httpx

from Undefined.ai.llm import ModelRequester
from Undefined.ai.model_selector import ModelSelector
from Undefined.ai.multimodal import MultimodalAnalyzer
from Undefined.ai.prompts import PromptBuilder
from Undefined.ai.summaries import SummaryService
Expand Down Expand Up @@ -150,6 +151,9 @@ def __init__(
anthropic_skill_registry=self.anthropic_skill_registry,
)

# 初始化模型选择器
self.model_selector = ModelSelector()

# 绑定上下文资源扫描路径(基于注册表 watch_paths)
scan_paths = [
p
Expand Down Expand Up @@ -246,6 +250,15 @@ async def init_mcp_async() -> None:

self._mcp_init_task = asyncio.create_task(init_mcp_async())

# 异步加载模型偏好
async def load_preferences_async() -> None:
try:
await self.model_selector.load_preferences()
except Exception as exc:
logger.warning("[初始化] 加载模型偏好失败: %s", exc)

self._preferences_load_task = asyncio.create_task(load_preferences_async())

logger.info("[初始化] AIClient 初始化完成")

async def close(self) -> None:
Expand Down Expand Up @@ -393,6 +406,18 @@ def _get_runtime_config(self) -> Config:

return get_config(strict=False)

def _find_chat_config_by_name(self, model_name: str) -> ChatModelConfig:
"""根据模型名查找配置(主模型或池中模型)"""
if model_name == self.chat_config.model_name:
return self.chat_config
if self.chat_config.pool and self.chat_config.pool.enabled:
for entry in self.chat_config.pool.models:
if entry.model_name == model_name:
return self.model_selector._entry_to_chat_config(
entry, self.chat_config
)
return self.chat_config

def _get_prefetch_tool_names(self) -> list[str]:
runtime_config = self._get_runtime_config()
return list(runtime_config.prefetch_tools)
Expand Down Expand Up @@ -867,11 +892,19 @@ async def ask(
if inflight_location is None:
inflight_location = self._build_inflight_location(tool_context)

# 动态选择模型(等待偏好加载就绪,避免竞态)
await self.model_selector.wait_ready()
selected_model_name = pre_context.get("selected_model_name")
if selected_model_name:
effective_chat_config = self._find_chat_config_by_name(selected_model_name)
else:
effective_chat_config = self.chat_config

max_iterations = 1000
iteration = 0
conversation_ended = False
any_tool_executed = False
cot_compat = getattr(self.chat_config, "thinking_tool_call_compat", False)
cot_compat = getattr(effective_chat_config, "thinking_tool_call_compat", False)
cot_compat_logged = False
cot_missing_logged = False

Expand All @@ -888,7 +921,7 @@ async def _clear_inflight_on_exit() -> None:

try:
result = await self.request_model(
model_config=self.chat_config,
model_config=effective_chat_config,
messages=messages,
max_tokens=8192,
call_type="chat",
Expand Down Expand Up @@ -931,7 +964,7 @@ async def _clear_inflight_on_exit() -> None:
cot_compat
and log_thinking
and tools
and getattr(self.chat_config, "thinking_enabled", False)
and getattr(effective_chat_config, "thinking_enabled", False)
and not reasoning_content
and tool_calls
and not cot_missing_logged
Expand Down
Loading