Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
121e155248 | ||
|
|
d513c5f892 | ||
|
|
4cb97318b5 | ||
|
|
31b2a4d6ca | ||
|
|
db54921066 | ||
|
|
9fee9a1478 | ||
|
|
d3acccd146 | ||
|
|
d1f780e381 | ||
|
|
d3a672a2a8 |
No files matched your search
@@ -236,6 +236,7 @@
|
|||||||
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。
|
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。
|
||||||
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
|
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
|
||||||
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;`models` 配置节可挂多个 OpenAI 兼容端点按档案名切换(档案级 reasoning/温度/展示名/`fallback` 失败转移),`--once` 支持无 WebSocket 的 CLI 一次性对话;详见 [aibox/README.md](aibox/README.md)。
|
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;`models` 配置节可挂多个 OpenAI 兼容端点按档案名切换(档案级 reasoning/温度/展示名/`fallback` 失败转移),`--once` 支持无 WebSocket 的 CLI 一次性对话;详见 [aibox/README.md](aibox/README.md)。
|
||||||
|
- **四轮安全审计修复(2026-08,Batch E/F/G/H/I 共 60+ 项)**:命令执行保护链(1MB/超时/中断)、凭据脱敏(代理密码不落盘、日志打码、Bearer 脱敏)、取消传播(子代理/情绪子代理/调度器超时)、DB 事务与 FTS5 转义、POSIX 进程组终止等;审计报告见 [docs/reports/aibox_audit_20260817.md](docs/reports/aibox_audit_20260817.md)。
|
||||||
|
|
||||||
### BotOps(aibox 智能体 TUI 前端)
|
### BotOps(aibox 智能体 TUI 前端)
|
||||||
|
|
||||||
|
|||||||
+47
-3
@@ -57,13 +57,28 @@ python aibox.py
|
|||||||
|
|
||||||
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。
|
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。
|
||||||
|
|
||||||
> 注意:本模块入口文件为 `aibox.py`(当前约 3018 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。
|
### 管理大模型接入点(不用手写配置)
|
||||||
|
|
||||||
|
不想手写 `config.json` 的 `models` 节?用交互式菜单工具:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python model_config.py # 交互式菜单:列出 / 添加 / 删除 / 连通性验证
|
||||||
|
python model_config.py --list # 非交互:列出当前接入点
|
||||||
|
```
|
||||||
|
|
||||||
|
- **添加接入点**:按提示填写档案名、API 地址(附常见厂商端点参考)、模型名;Key 两种方式——环境变量名(推荐,Key 不落盘)或免密本地端点(Ollama 等);可选显示名/温度/超时/思考模式/失败转移(从已有接入点多选)/设为默认
|
||||||
|
- **删除接入点**:编号选择 + 确认,自动清理其他档案的失败转移引用
|
||||||
|
- **连通性验证**:对选中接入点发一次最小请求,直接给出成败原因
|
||||||
|
- 添加后可立即使用:`python aibox.py --model <档案名>` 指定,或设为默认后无需 `--model`
|
||||||
|
|
||||||
|
> 注意:本模块入口文件为 `aibox.py`(当前约 3408 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。
|
||||||
|
|
||||||
## 📁 目录结构
|
## 📁 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
aibox/
|
aibox/
|
||||||
├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 任务调度器 + 核心类 DeepSeekChat/WebSocketHandler/EnhancedAIManager)
|
├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 任务调度器 + 核心类 DeepSeekChat/WebSocketHandler/EnhancedAIManager)
|
||||||
|
├── model_config.py # 大模型接入点交互式管理工具(列表/添加/删除/连通性验证,不用手写配置)
|
||||||
├── client.py # 示例 WebSocket 客户端
|
├── client.py # 示例 WebSocket 客户端
|
||||||
├── voice_client.py # 语音客户端
|
├── voice_client.py # 语音客户端
|
||||||
├── llm_client.py # 大模型客户端抽象层(多厂商切换,DeepSeekClient 兼容 OpenAI 协议)
|
├── llm_client.py # 大模型客户端抽象层(多厂商切换,DeepSeekClient 兼容 OpenAI 协议)
|
||||||
@@ -92,7 +107,7 @@ aibox/
|
|||||||
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
|
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
|
||||||
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy
|
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy
|
||||||
│ └── misc_tools.py # B 批:AskUserTool
|
│ └── misc_tools.py # B 批:AskUserTool
|
||||||
├── tests/ # 单元测试套件(355 项全过)
|
├── tests/ # 单元测试套件(441 项全过)
|
||||||
│ ├── conftest.py # sys.path 配置 + websockets 桩
|
│ ├── conftest.py # sys.path 配置 + websockets 桩
|
||||||
│ ├── test_basic_tools.py # A 批工具 43 项
|
│ ├── test_basic_tools.py # A 批工具 43 项
|
||||||
│ ├── test_b_tools.py # B 批工具 58 项
|
│ ├── test_b_tools.py # B 批工具 58 项
|
||||||
@@ -103,7 +118,13 @@ aibox/
|
|||||||
│ ├── test_conversation_compressor.py # 对话压缩器
|
│ ├── test_conversation_compressor.py # 对话压缩器
|
||||||
│ ├── test_skill_manager.py # 技能管理器
|
│ ├── test_skill_manager.py # 技能管理器
|
||||||
│ ├── test_once_mode.py # CLI 一次性对话模式(Batch A/B/C 15 项)
|
│ ├── test_once_mode.py # CLI 一次性对话模式(Batch A/B/C 15 项)
|
||||||
│ └── test_models_profile.py # 多模型档案(Batch D 17 项)
|
│ ├── test_models_profile.py # 多模型档案(Batch D 17 项)
|
||||||
|
│ ├── test_config.py # 配置脱敏(F1 3 项)
|
||||||
|
│ ├── test_f2_fixes.py # 超时清理/线程收尾(F2 5 项)
|
||||||
|
│ ├── test_g_fixes.py # 第三轮审计 Batch G(20 项)
|
||||||
|
│ ├── test_h_fixes.py # 第四轮审计 Batch H(26 项)
|
||||||
|
│ ├── test_i_fixes.py # 遗留收尾 Batch I(16 项)
|
||||||
|
│ └── test_model_config.py # 模型接入点管理工具(17 项)
|
||||||
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
|
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
|
||||||
│ ├── hello-world.py
|
│ ├── hello-world.py
|
||||||
│ ├── aes-crypto.py
|
│ ├── aes-crypto.py
|
||||||
@@ -890,6 +911,29 @@ elif command == "/mycommand":
|
|||||||
|
|
||||||
## 📄 更新日志
|
## 📄 更新日志
|
||||||
|
|
||||||
|
### v3.5
|
||||||
|
- ✅ **安全审计与修复(第三轮 Batch G + 第四轮 Batch H + 遗留收尾 Batch I)**(完整记录见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
|
||||||
|
- **Batch G(llm 加固 + 线程生命周期 + 取消传播 + DB 事务)**:
|
||||||
|
- llm_client:非瞬时 HTTP 错误体截断 `[:200]`(密钥/错误页不进异常栈)、Retry-After 上限 cap 3600s、fallback 档案缺 model 跳过并告警、fallback 去重、删除 `LLMClient._cancelled` 死状态、FailoverClient 并发契约文档化
|
||||||
|
- 子代理取消传播:`run_full_subagent` 共享父 `_cancel_event` + 0.2s 观察者线程转发深度中断(强关在途 HTTP、kill 子进程);`SubAgentTool` 构造注入父事件
|
||||||
|
- drain 线程重构:批次级单线程 + stop 事件 + try/finally 统一回收(消除每次工具调用泄漏一个常驻线程);`/new` 的 `reset_conversation` 移入线程池(不再同步获取 `_msg_lock` 冻结事件循环);broadcast 收窄 except + unregister 改 discard;`_processing_tasks` 改按连接的任务集合登记(关闭时取消全部在途任务)
|
||||||
|
- DB:FTS5 关键词双引号翻倍转义(`"` → `""`)、两处 `LIMIT` f-string 参数化、`mark_as_triggered` 读写收进单连接 `BEGIN IMMEDIATE` 事务(消灭并发触发竞态);历史索引裸 except 收窄 + 归档文件名同秒加序号
|
||||||
|
- **Batch H(第四轮审计,3 路并行审读 + Linux PoC 6/6 实证)**:
|
||||||
|
- **H1(高危)**:修复 N5 回归——子代理共享父取消事件后首轮 think 无条件 `clear()` 会吞掉父级已置位的取消信号(用户中断 / --once 超时被绕过),共享事件时跳过 clear + 父已取消直接早退
|
||||||
|
- `/reload_prompts` 移入线程池(N3 同类遗漏);技能扫描(串行 `--help` 子进程)启动前预热,不再在首条消息/首个任务时冻结事件循环
|
||||||
|
- 调度器任务级超时(`scheduler.task_timeout`,默认 600s)——AI think 卡死不再停摆整个调度循环
|
||||||
|
- `get_recent_memories(limit=None)` 默认上限(不再全表加载);`memo delete` 改物理删除(此前只标记完成);`PromptManager.reload` 本地构建后原子替换(并发读者不再读到空/半截系统提示词)
|
||||||
|
- read_file 的 sed 参数强转 int(防引号注入);`set_proxy` 落盘失败如实报告;SSE 非 UTF-8 行容错;persona 路径 realpath 校验(软链越界拒绝);1MB 截断无半个字符;compressor 除零防护;InstallSkillTool 指引命令路径引用;ask_user 缺参明确报错
|
||||||
|
- **POSIX 进程组终止**:executor 的 `Popen` 加 `start_new_session` + 组信号 kill(组 ID 在 Popen 后立即捕获)——`(sleep 300 &)` 形态的孙进程持管道不再悬挂 reader 线程/泄漏 fd(实证修复前孙进程残留)
|
||||||
|
- web 工具 scheme 白名单(http/https,`file://` 等拒绝);registry 日志打码改包含匹配(apiKey/passwd 变体);FailoverClient 日志 Bearer 令牌正则脱敏(网关回显 Authorization 头不再进日志)
|
||||||
|
- **Batch I(遗留收尾)**:
|
||||||
|
- **N9**:prompt 写入内容长度上限(`MAX_CONTENT_LENGTH=10000`,identity/soul/status 超长拒绝)——约束持久化注入面
|
||||||
|
- **F5**:情绪子代理取消传播——`run_light_subagent` 改走 stream_chat + 观察者线程,用户中断可即时终止(此前阻塞至请求超时)
|
||||||
|
- 提醒时间格式异常任务按"到期"返回(此前被 SQL 层字符串比较静默丢弃,任务永不执行);`mark_as_triggered` 加 `busy_timeout=10000`(多进程同库写竞争等待而非立即报 locked)
|
||||||
|
- 对话文件原子写(tmp + os.replace,崩溃不留半截 JSON);`load_conversation` realpath 越界防护;WS 命令专用线程池(`/new`、`/reload_prompts` 不再占用默认池);--once 硬超时兜底(软超时后宽限一个周期强杀进程)
|
||||||
|
- PromptManager 单例加锁;`limit=0` 语义修正(None 才不加 LIMIT)
|
||||||
|
- ✅ **测试**:424 项全过(Linux 环境;Batch G 20 + Batch H 26 + Batch I 16 项新增;`test_execute_timeout`/`test_flat_api_still_reads_key_file` 为 Windows 向用例,Linux 预存失败与本轮无关)
|
||||||
|
|
||||||
### v3.4
|
### v3.4
|
||||||
- ✅ **多模型档案加固(H1/H2/H3/M1/M2/M3 六项修复)**:
|
- ✅ **多模型档案加固(H1/H2/H3/M1/M2/M3 六项修复)**:
|
||||||
- **H1(凭据隔离)**:档案端点环境变量取不到时直接报错,**绝不回退读 `~/.cert/deepseek.key`**(该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏);key 文件回退仅对无档案的旧 `api` 扁平节生效
|
- **H1(凭据隔离)**:档案端点环境变量取不到时直接报错,**绝不回退读 `~/.cert/deepseek.key`**(该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏);key 文件回退仅对无档案的旧 `api` 扁平节生效
|
||||||
|
|||||||
+126
-15
@@ -26,6 +26,7 @@ import locale
|
|||||||
import asyncio
|
import asyncio
|
||||||
import websockets
|
import websockets
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Dict, Optional, Any, Tuple, Callable, Union, Set
|
from typing import List, Dict, Optional, Any, Tuple, Callable, Union, Set
|
||||||
import argparse
|
import argparse
|
||||||
@@ -328,14 +329,35 @@ class TaskScheduler:
|
|||||||
ai_thread.daemon = True
|
ai_thread.daemon = True
|
||||||
ai_thread.start()
|
ai_thread.start()
|
||||||
|
|
||||||
# 等待 AI 完成,同时处理可能的取消
|
# M3(审计 2026-08-18):任务级超时兜底。此前 await ai_future
|
||||||
|
# 无任何超时/取消路径——AI think 卡死(LLM 挂起、子进程不可杀)
|
||||||
|
# 时该任务永不返回,调度器 run 循环顺序 await 被阻塞,后续全部
|
||||||
|
# 定时/即时任务不再执行且任务已 mark_as_triggered 不会重试。
|
||||||
|
# 到点 call_later 触发 ai.cancel()(置取消事件 + 关在途 HTTP 流),
|
||||||
|
# worker 线程自然收尾,调度循环继续。
|
||||||
|
task_timeout = float(ConfigManager().get("scheduler.task_timeout", 600))
|
||||||
|
timed_out = threading.Event()
|
||||||
|
|
||||||
|
def _timeout_cancel():
|
||||||
|
timed_out.set()
|
||||||
|
try:
|
||||||
|
ai.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
timeout_handle = loop.call_later(task_timeout, _timeout_cancel)
|
||||||
try:
|
try:
|
||||||
response = await ai_future
|
response = await ai_future
|
||||||
result = response or "AI无响应"
|
result = response or "AI无响应"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if timed_out.is_set():
|
||||||
|
self.logger.error(f"任务执行超时({task_timeout}秒),AI 已取消")
|
||||||
|
result = f"任务执行超时({task_timeout}秒),AI 已取消"
|
||||||
|
else:
|
||||||
self.logger.error(f"AI对话失败: {e}")
|
self.logger.error(f"AI对话失败: {e}")
|
||||||
result = f"任务执行但AI对话失败: {str(e)}"
|
result = f"任务执行但AI对话失败: {str(e)}"
|
||||||
finally:
|
finally:
|
||||||
|
timeout_handle.cancel()
|
||||||
# 确保线程结束
|
# 确保线程结束
|
||||||
ai_thread.join(timeout=1.0)
|
ai_thread.join(timeout=1.0)
|
||||||
|
|
||||||
@@ -490,6 +512,12 @@ class TaskScheduler:
|
|||||||
|
|
||||||
|
|
||||||
class ExpressEmotionTool(Tool):
|
class ExpressEmotionTool(Tool):
|
||||||
|
def __init__(self, logger=None, cancel_event: threading.Event = None):
|
||||||
|
super().__init__(logger)
|
||||||
|
# I 批(审计 2026-08-18):父代理取消事件注入——情绪子代理此前
|
||||||
|
# 用独立 client 且不可中断,父级 cancel 后仍阻塞至请求自然结束
|
||||||
|
self.cancel_event = cancel_event
|
||||||
|
|
||||||
def get_name(self) -> str:
|
def get_name(self) -> str:
|
||||||
return "express_emotion"
|
return "express_emotion"
|
||||||
|
|
||||||
@@ -534,10 +562,11 @@ class ExpressEmotionTool(Tool):
|
|||||||
if not name:
|
if not name:
|
||||||
return ""
|
return ""
|
||||||
path = os.path.join(constants.SKILLS_DIR, name + ".md")
|
path = os.path.join(constants.SKILLS_DIR, name + ".md")
|
||||||
# N12:persona 为 LLM 可控输入,做路径边界校验——normcase 统一大小写与
|
# N12 + L4(审计 2026-08-18):persona 为 LLM 可控输入。abspath 前缀
|
||||||
# 分隔符后要求解析结果仍在 SKILLS_DIR 内,防止 ../../ 越界读取并注入
|
# 检查拦截 ../ 越界,但符号链接可绕过(skills 目录内软链指向任意文件
|
||||||
|
# 即被读取注入);realpath 解析软链后再做前缀校验。
|
||||||
skills_root = os.path.normcase(os.path.abspath(constants.SKILLS_DIR))
|
skills_root = os.path.normcase(os.path.abspath(constants.SKILLS_DIR))
|
||||||
resolved = os.path.normcase(os.path.abspath(path))
|
resolved = os.path.normcase(os.path.realpath(path))
|
||||||
if not resolved.startswith(skills_root + os.sep):
|
if not resolved.startswith(skills_root + os.sep):
|
||||||
self.logger.warning(f"人格技能名非法(越界路径): {name}")
|
self.logger.warning(f"人格技能名非法(越界路径): {name}")
|
||||||
return ""
|
return ""
|
||||||
@@ -572,7 +601,8 @@ class ExpressEmotionTool(Tool):
|
|||||||
task = "\n".join(task_lines)
|
task = "\n".join(task_lines)
|
||||||
|
|
||||||
# 情绪子代理:独立上下文/人格,生成情绪化回应
|
# 情绪子代理:独立上下文/人格,生成情绪化回应
|
||||||
return run_light_subagent(role, task, self.config, logger=self.logger, max_tokens=2048)
|
return run_light_subagent(role, task, self.config, logger=self.logger,
|
||||||
|
max_tokens=2048, cancel_event=self.cancel_event)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"情绪子代理执行异常: {e}")
|
self.logger.error(f"情绪子代理执行异常: {e}")
|
||||||
return f"❌ 情绪子代理执行失败: {str(e)}"
|
return f"❌ 情绪子代理执行失败: {str(e)}"
|
||||||
@@ -584,12 +614,20 @@ class ExpressEmotionTool(Tool):
|
|||||||
|
|
||||||
|
|
||||||
def run_light_subagent(role, task, config, logger=None, model=None,
|
def run_light_subagent(role, task, config, logger=None, model=None,
|
||||||
temperature=None, max_tokens=2048) -> str:
|
temperature=None, max_tokens=2048,
|
||||||
|
cancel_event: threading.Event = None) -> str:
|
||||||
"""轻量子代理执行:独立上下文/角色,无工具,一次 LLM 补全返回结果。
|
"""轻量子代理执行:独立上下文/角色,无工具,一次 LLM 补全返回结果。
|
||||||
|
|
||||||
供 express_emotion 情绪子代理使用。
|
供 express_emotion 情绪子代理使用。
|
||||||
返回子代理生成的完整文本;失败时返回带 ❌ 的错误信息。
|
返回子代理生成的完整文本;失败时返回带 ❌ 的错误信息。
|
||||||
|
|
||||||
|
cancel_event(I 批,审计 2026-08-18):父代理取消事件。此前本函数用
|
||||||
|
非流式 chat_completion,父级 cancel 只能等请求自然结束(最长 timeout,
|
||||||
|
期间持锁阻塞);改走 stream_chat 后由观察者线程转发 sub_client.cancel()
|
||||||
|
强关在途 HTTP 流,用户中断可即时终止。
|
||||||
"""
|
"""
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
return "❌ 子代理已因父级取消而终止"
|
||||||
try:
|
try:
|
||||||
# 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗
|
# 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗
|
||||||
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS))
|
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS))
|
||||||
@@ -605,8 +643,33 @@ def run_light_subagent(role, task, config, logger=None, model=None,
|
|||||||
]
|
]
|
||||||
if logger is not None:
|
if logger is not None:
|
||||||
logger.info(f"子代理执行: model={model or sub_client.model}, role={str(role)[:40]}...")
|
logger.info(f"子代理执行: model={model or sub_client.model}, role={str(role)[:40]}...")
|
||||||
result = sub_client.chat_completion(messages, temperature=temp, max_tokens=max_tokens)
|
|
||||||
if result is None or not str(result).strip():
|
_watch_done = threading.Event()
|
||||||
|
if cancel_event is not None:
|
||||||
|
def _watch_parent_cancel():
|
||||||
|
while not _watch_done.wait(timeout=0.2):
|
||||||
|
if cancel_event.is_set():
|
||||||
|
try:
|
||||||
|
sub_client.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
threading.Thread(target=_watch_parent_cancel, daemon=True,
|
||||||
|
name="light-subagent-cancel-watch").start()
|
||||||
|
|
||||||
|
# I 批:改走流式(可被 cancel 强关在途 HTTP),拼接 content 事件
|
||||||
|
parts = []
|
||||||
|
try:
|
||||||
|
for event in sub_client.stream_chat(messages, temperature=temp,
|
||||||
|
max_tokens=max_tokens):
|
||||||
|
if event.get("type") == "content":
|
||||||
|
parts.append(event.get("text") or "")
|
||||||
|
finally:
|
||||||
|
_watch_done.set()
|
||||||
|
|
||||||
|
result = "".join(parts)
|
||||||
|
if not str(result).strip():
|
||||||
return "❌ 子代理执行失败:未获得有效响应"
|
return "❌ 子代理执行失败:未获得有效响应"
|
||||||
return str(result).strip()
|
return str(result).strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -673,6 +736,14 @@ def run_full_subagent(role, task, config, logger=None, model=None,
|
|||||||
# 深度中断(sub_chat.cancel() 强关在途 HTTP 流并 kill 其子进程)。
|
# 深度中断(sub_chat.cancel() 强关在途 HTTP 流并 kill 其子进程)。
|
||||||
if cancel_event is not None:
|
if cancel_event is not None:
|
||||||
sub_chat._cancel_event = cancel_event
|
sub_chat._cancel_event = cancel_event
|
||||||
|
# H1(审计 2026-08-18):共享父事件后禁止子代理首轮 clear——
|
||||||
|
# _think_and_respond_impl 首轮无条件 clear() 会吞掉父级已置位的
|
||||||
|
# 取消信号(用户中断 / --once 超时恰落在子代理生成窗口时被清掉,
|
||||||
|
# 观察者线程随之失效,子代理跑完全程)。置位后立即检查一次,
|
||||||
|
# 父级已取消则不再启动子代理。
|
||||||
|
sub_chat._shared_cancel_event = True
|
||||||
|
if cancel_event.is_set():
|
||||||
|
return "❌ 子代理已因父级取消而终止"
|
||||||
|
|
||||||
def _watch_parent_cancel():
|
def _watch_parent_cancel():
|
||||||
while not _watch_done.wait(timeout=0.2):
|
while not _watch_done.wait(timeout=0.2):
|
||||||
@@ -1131,8 +1202,9 @@ class DeepSeekChat:
|
|||||||
builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger))
|
builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger))
|
||||||
|
|
||||||
# 添加询问用户的工具
|
# 添加询问用户的工具
|
||||||
|
# I 批:ExpressEmotionTool 注入父取消事件(情绪子代理可被中断)
|
||||||
builtin_tools.append(AskUserTool(self.logger))
|
builtin_tools.append(AskUserTool(self.logger))
|
||||||
builtin_tools.append(ExpressEmotionTool(self.logger))
|
builtin_tools.append(ExpressEmotionTool(self.logger, cancel_event=self._cancel_event))
|
||||||
|
|
||||||
# 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具
|
# 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具
|
||||||
builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger))
|
builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger))
|
||||||
@@ -1456,8 +1528,9 @@ class DeepSeekChat:
|
|||||||
|
|
||||||
def _think_and_respond_impl(self, input_text: str, output_callback: Callable = None, is_continuation: bool = False) -> Optional[str]:
|
def _think_and_respond_impl(self, input_text: str, output_callback: Callable = None, is_continuation: bool = False) -> Optional[str]:
|
||||||
"""think_and_respond 的实际实现,在 _msg_lock 保护下执行。"""
|
"""think_and_respond 的实际实现,在 _msg_lock 保护下执行。"""
|
||||||
# 首轮开始时清除取消标志
|
# 首轮开始时清除取消标志(H1:共享父事件的子代理不清除——清除会
|
||||||
if not is_continuation:
|
# 吞掉父级已置位的取消信号,使中断/--once 超时失效)
|
||||||
|
if not is_continuation and not getattr(self, "_shared_cancel_event", False):
|
||||||
self._cancel_event.clear()
|
self._cancel_event.clear()
|
||||||
|
|
||||||
# 立即检查是否已被取消
|
# 立即检查是否已被取消
|
||||||
@@ -2021,6 +2094,17 @@ class WebSocketHandler:
|
|||||||
self.client_sessions = {}
|
self.client_sessions = {}
|
||||||
self._processing_tasks = {} # websocket -> {asyncio.Task}(N14:集合登记,支持并发多任务)
|
self._processing_tasks = {} # websocket -> {asyncio.Task}(N14:集合登记,支持并发多任务)
|
||||||
self._shared_skill_manager = None # 共享技能管理器(懒加载,供所有连接复用)
|
self._shared_skill_manager = None # 共享技能管理器(懒加载,供所有连接复用)
|
||||||
|
# I 批(审计 2026-08-18):/new、/reload_prompts 等命令专用线程池——
|
||||||
|
# 此前 run_in_executor(None) 与 AI think 共用默认池,繁忙时 reset 任务
|
||||||
|
# 排队等锁,多连接并发 /new 可耗尽默认池(阻塞新消息处理)
|
||||||
|
self._cmd_executor = ThreadPoolExecutor(max_workers=2,
|
||||||
|
thread_name_prefix="ws-cmd")
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""I 批:释放命令专用线程池(服务退出时调用)"""
|
||||||
|
if self._cmd_executor is not None:
|
||||||
|
self._cmd_executor.shutdown(wait=False)
|
||||||
|
self._cmd_executor = None
|
||||||
|
|
||||||
def _get_shared_skill_manager(self):
|
def _get_shared_skill_manager(self):
|
||||||
"""获取共享技能管理器(懒加载一次,供所有连接实例复用,避免重复扫描技能)"""
|
"""获取共享技能管理器(懒加载一次,供所有连接实例复用,避免重复扫描技能)"""
|
||||||
@@ -2394,7 +2478,7 @@ class WebSocketHandler:
|
|||||||
# 重新初始化提示词管理器(确保最新)
|
# 重新初始化提示词管理器(确保最新)
|
||||||
self.ai_manager.prompt_manager.reload()
|
self.ai_manager.prompt_manager.reload()
|
||||||
|
|
||||||
await loop.run_in_executor(None, _do_reset)
|
await loop.run_in_executor(self._cmd_executor, _do_reset)
|
||||||
# 重置该连接对话进行中标记
|
# 重置该连接对话进行中标记
|
||||||
if session:
|
if session:
|
||||||
session["dialogue_in_progress"] = False
|
session["dialogue_in_progress"] = False
|
||||||
@@ -2492,10 +2576,17 @@ class WebSocketHandler:
|
|||||||
elif command == "/reload_prompts":
|
elif command == "/reload_prompts":
|
||||||
session = self._get_session(websocket)
|
session = self._get_session(websocket)
|
||||||
ai = session.get("ai") if session else None
|
ai = session.get("ai") if session else None
|
||||||
|
# M1(审计 2026-08-18):reload_prompts 内部同步获取 _msg_lock
|
||||||
|
# (繁忙对话持锁可达数十秒),直接调用会冻结整个事件循环——
|
||||||
|
# N3 只修了 /new,这里同类遗漏。移入线程池执行。
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _do_reload_prompts():
|
||||||
if ai:
|
if ai:
|
||||||
new_count = ai.reload_prompts()
|
return ai.reload_prompts()
|
||||||
else:
|
return self.ai_manager.prompt_manager.reload()
|
||||||
new_count = self.ai_manager.prompt_manager.reload()
|
|
||||||
|
new_count = await loop.run_in_executor(self._cmd_executor, _do_reload_prompts)
|
||||||
await self.send_message(websocket, {
|
await self.send_message(websocket, {
|
||||||
"type": "command_result",
|
"type": "command_result",
|
||||||
"content": i18n.get('prompts_reloaded', new_count)
|
"content": i18n.get('prompts_reloaded', new_count)
|
||||||
@@ -2883,12 +2974,17 @@ class EnhancedAIManager:
|
|||||||
port = constants.WEBSOCKET_PORT
|
port = constants.WEBSOCKET_PORT
|
||||||
|
|
||||||
self.ws_handler = WebSocketHandler(self)
|
self.ws_handler = WebSocketHandler(self)
|
||||||
|
# M2(审计 2026-08-18):技能扫描对每个可执行技能串行跑 --help
|
||||||
|
# 子进程(各 ≤10s),懒加载于首条消息/首个任务时在事件循环线程
|
||||||
|
# 执行会冻结整个服务。启动前预热,把扫描成本移到连接建立之前。
|
||||||
|
self.ws_handler._get_shared_skill_manager()
|
||||||
|
|
||||||
# 如果启用了任务调度器,创建并启动
|
# 如果启用了任务调度器,创建并启动
|
||||||
if constants.SCHEDULER_ENABLED:
|
if constants.SCHEDULER_ENABLED:
|
||||||
self.scheduler = TaskScheduler(asyncio.get_running_loop(), self.logger)
|
self.scheduler = TaskScheduler(asyncio.get_running_loop(), self.logger)
|
||||||
self.scheduler.set_websocket_handler(self.ws_handler)
|
self.scheduler.set_websocket_handler(self.ws_handler)
|
||||||
self.scheduler.set_ai_manager(self) # 设置AI管理器引用
|
self.scheduler.set_ai_manager(self) # 设置AI管理器引用
|
||||||
|
self.scheduler._get_shared_skill_manager()
|
||||||
|
|
||||||
async def handler(websocket):
|
async def handler(websocket):
|
||||||
await self.handle_connection(websocket, None)
|
await self.handle_connection(websocket, None)
|
||||||
@@ -2914,6 +3010,8 @@ class EnhancedAIManager:
|
|||||||
finally:
|
finally:
|
||||||
if self.scheduler:
|
if self.scheduler:
|
||||||
self.scheduler.stop()
|
self.scheduler.stop()
|
||||||
|
# I 批:释放命令专用线程池
|
||||||
|
self.ws_handler.shutdown()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""运行WebSocket服务器"""
|
"""运行WebSocket服务器"""
|
||||||
@@ -3186,6 +3284,19 @@ def handle_once_mode(manager, args) -> bool:
|
|||||||
def _on_timeout():
|
def _on_timeout():
|
||||||
state["timed_out"] = True
|
state["timed_out"] = True
|
||||||
manager.cancel()
|
manager.cancel()
|
||||||
|
# I 批(F9,审计 2026-08-18):软超时兜底——cancel 后若 AI 卡死在
|
||||||
|
# 不可中断路径(HTTP 请求空窗、忽略信号的子进程),think 可能永不
|
||||||
|
# 返回、进程挂起(退出码 4 永远到不了)。宽限一个超时周期后强杀进程,
|
||||||
|
# 保证 --once 有硬性结束语义
|
||||||
|
def _hard_exit():
|
||||||
|
print(f"\n⏰ 一次性对话强制超时({args.once_timeout * 2} 秒),已终止",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
os._exit(4)
|
||||||
|
|
||||||
|
hard_timer = threading.Timer(max(1, args.once_timeout), _hard_exit)
|
||||||
|
hard_timer.daemon = True
|
||||||
|
hard_timer.start()
|
||||||
timer = threading.Timer(max(1, args.once_timeout), _on_timeout)
|
timer = threading.Timer(max(1, args.once_timeout), _on_timeout)
|
||||||
timer.daemon = True
|
timer.daemon = True
|
||||||
timer.start()
|
timer.start()
|
||||||
|
|||||||
@@ -278,16 +278,19 @@ class ConfigManager:
|
|||||||
"password_env": password_env
|
"password_env": password_env
|
||||||
}
|
}
|
||||||
if save:
|
if save:
|
||||||
self._save_config()
|
return self._save_config()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _save_config(self):
|
def _save_config(self) -> bool:
|
||||||
"""将当前配置保存到配置文件(覆盖)。
|
"""将当前配置保存到配置文件(覆盖)。返回是否成功落盘。
|
||||||
|
|
||||||
落盘前对代理密码做纵深防御脱敏:无论调用路径如何,明文密码一律不写入文件。
|
落盘前对代理密码做纵深防御脱敏:无论调用路径如何,明文密码一律不写入文件。
|
||||||
|
L2(审计 2026-08-18):此前异常仅 print,set_proxy 无条件报"已保存",
|
||||||
|
磁盘只读等场景下用户以为已持久化,重启后配置丢失。
|
||||||
"""
|
"""
|
||||||
config_path = self._config_path
|
config_path = self._config_path
|
||||||
if config_path:
|
if not config_path:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
safe_config = copy.deepcopy(self._config)
|
safe_config = copy.deepcopy(self._config)
|
||||||
socks = safe_config.get("proxy", {}).get("socks5")
|
socks = safe_config.get("proxy", {}).get("socks5")
|
||||||
@@ -297,8 +300,10 @@ class ConfigManager:
|
|||||||
with open(config_path, 'w', encoding='utf-8') as f:
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(safe_config, f, ensure_ascii=False, indent=2)
|
json.dump(safe_config, f, ensure_ascii=False, indent=2)
|
||||||
print(f"✅ 配置已保存到 {config_path}")
|
print(f"✅ 配置已保存到 {config_path}")
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 保存配置失败: {e}")
|
print(f"⚠️ 保存配置失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def get_proxy_info(self) -> Dict:
|
def get_proxy_info(self) -> Dict:
|
||||||
"""获取代理配置的详细信息(用于显示,不回传任何密码值)"""
|
"""获取代理配置的详细信息(用于显示,不回传任何密码值)"""
|
||||||
|
|||||||
+16
-4
@@ -22,9 +22,21 @@ from typing import Dict, Iterator, List, Optional
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
# L12(审计 2026-08-18):瞬时错误体可能回显网关/WAF 错误页中的
|
||||||
|
# Authorization 头,抹掉 Bearer 令牌后再进日志,防密钥落盘
|
||||||
|
_AUTH_HEADER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_log_msg(msg: str) -> str:
|
||||||
|
"""日志脱敏:替换错误消息中的 Bearer 令牌。"""
|
||||||
|
if not msg:
|
||||||
|
return msg
|
||||||
|
return _AUTH_HEADER_RE.sub("Bearer ***", msg)
|
||||||
|
|
||||||
|
|
||||||
class LLMClient(ABC):
|
class LLMClient(ABC):
|
||||||
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
|
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
|
||||||
@@ -64,7 +76,7 @@ class LLMClient(ABC):
|
|||||||
def _log(self, level: str, msg: str):
|
def _log(self, level: str, msg: str):
|
||||||
if self.logger is not None and hasattr(self.logger, level):
|
if self.logger is not None and hasattr(self.logger, level):
|
||||||
try:
|
try:
|
||||||
getattr(self.logger, level)(msg)
|
getattr(self.logger, level)(_sanitize_log_msg(msg))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -219,7 +231,7 @@ class DeepSeekClient(LLMClient):
|
|||||||
for line in response.iter_lines():
|
for line in response.iter_lines():
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
line_text = line.decode("utf-8")
|
line_text = line.decode("utf-8", errors="replace") # L3:非 UTF-8 行不中断流
|
||||||
if not line_text.startswith("data: "):
|
if not line_text.startswith("data: "):
|
||||||
continue
|
continue
|
||||||
data = line_text[6:]
|
data = line_text[6:]
|
||||||
@@ -352,7 +364,7 @@ class FailoverClient:
|
|||||||
def _log(self, level: str, msg: str):
|
def _log(self, level: str, msg: str):
|
||||||
if self.logger is not None and hasattr(self.logger, level):
|
if self.logger is not None and hasattr(self.logger, level):
|
||||||
try:
|
try:
|
||||||
getattr(self.logger, level)(msg)
|
getattr(self.logger, level)(_sanitize_log_msg(msg))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -382,7 +394,7 @@ class FailoverClient:
|
|||||||
self._stats["failovers"] += 1
|
self._stats["failovers"] += 1
|
||||||
try:
|
try:
|
||||||
if self.on_failover is not None:
|
if self.on_failover is not None:
|
||||||
self.on_failover(from_name, to_name, reason)
|
self.on_failover(from_name, to_name, _sanitize_log_msg(reason))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log("warning", f"on_failover 回调异常(忽略): {e}")
|
self._log("warning", f"on_failover 回调异常(忽略): {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
大模型接入点(模型档案)交互式管理工具
|
||||||
|
|
||||||
|
不用手写 config.json:菜单式列出 / 添加 / 删除 / 连通性验证
|
||||||
|
`models` 节的模型接入点(OpenAI Chat Completions 兼容端点:
|
||||||
|
DeepSeek / Kimi / Qwen / GLM / 本地 Ollama 等)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python model_config.py # 交互式菜单
|
||||||
|
python model_config.py --list # 非交互:列出当前接入点
|
||||||
|
|
||||||
|
安全约定(与 aibox 审计一致):API Key 一律不写入配置文件——
|
||||||
|
只保存环境变量名(api_key_env),或留空表示免密本地端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
# 允许从仓库根目录或 aibox/ 目录启动
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from common import ConfigManager # noqa: E402
|
||||||
|
|
||||||
|
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
# 常见厂商端点提示(首行是默认建议值)
|
||||||
|
PROVIDER_HINTS = {
|
||||||
|
"DeepSeek": "https://api.deepseek.com/v1/chat/completions",
|
||||||
|
"Kimi (Moonshot)": "https://api.moonshot.cn/v1/chat/completions",
|
||||||
|
"Qwen (阿里云百炼)": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||||
|
"GLM (智谱)": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
|
||||||
|
"Ollama 本地": "http://localhost:11434/v1/chat/completions",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 核心逻辑(可单测,不依赖 input) ====================
|
||||||
|
|
||||||
|
def get_models(config: ConfigManager) -> Dict:
|
||||||
|
"""取 models 节(不存在则初始化为空 dict)。"""
|
||||||
|
models = config.get("models") or {}
|
||||||
|
if not isinstance(models, dict):
|
||||||
|
models = {}
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles(config: ConfigManager) -> List[Dict]:
|
||||||
|
"""列出所有档案的归一化展示数据。"""
|
||||||
|
models = get_models(config)
|
||||||
|
default = config.get("api.default_model") or ""
|
||||||
|
profiles = []
|
||||||
|
for name, profile in models.items():
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
continue
|
||||||
|
profiles.append({
|
||||||
|
"name": name,
|
||||||
|
"is_default": name == default,
|
||||||
|
"display_name": profile.get("display_name") or name,
|
||||||
|
"api_url": profile.get("api_url", ""),
|
||||||
|
"model": profile.get("model", ""),
|
||||||
|
"api_key_env": profile.get("api_key_env", ""),
|
||||||
|
"reasoning_enabled": bool(profile.get("reasoning_enabled", False)),
|
||||||
|
"temperature": profile.get("temperature"),
|
||||||
|
"timeout": profile.get("timeout"),
|
||||||
|
"fallback": profile.get("fallback") or [],
|
||||||
|
})
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
def validate_new_profile(config: ConfigManager, name: str, api_url: str,
|
||||||
|
model: str) -> Optional[str]:
|
||||||
|
"""校验新档案字段,返回错误信息(合法返回 None)。"""
|
||||||
|
if not name:
|
||||||
|
return "档案名不能为空"
|
||||||
|
if not PROFILE_NAME_RE.match(name):
|
||||||
|
return f"档案名只能包含字母/数字/下划线/短横线(当前: {name})"
|
||||||
|
models = get_models(config)
|
||||||
|
if name in models:
|
||||||
|
return f"档案名 '{name}' 已存在"
|
||||||
|
if not api_url or not str(api_url).strip().lower().startswith(("http://", "https://")):
|
||||||
|
return "API 地址必须是 http:// 或 https:// 开头的完整 URL"
|
||||||
|
if not model or not str(model).strip():
|
||||||
|
return "模型名不能为空(如 deepseek-chat / gpt-4o / qwen-plus)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def add_profile(config: ConfigManager, name: str, api_url: str, model: str,
|
||||||
|
api_key_env: str = "", display_name: str = None,
|
||||||
|
temperature: float = None, timeout: int = None,
|
||||||
|
reasoning_enabled: bool = False,
|
||||||
|
fallback: Optional[List[str]] = None,
|
||||||
|
set_default: bool = False) -> List[str]:
|
||||||
|
"""添加档案。返回提示信息列表;字段非法时返回带 ❌ 的错误信息。"""
|
||||||
|
err = validate_new_profile(config, name, api_url, model)
|
||||||
|
if err:
|
||||||
|
return [f"❌ {err}"]
|
||||||
|
|
||||||
|
models = get_models(config)
|
||||||
|
profile = {
|
||||||
|
"api_url": str(api_url).strip(),
|
||||||
|
"model": str(model).strip(),
|
||||||
|
}
|
||||||
|
if api_key_env:
|
||||||
|
profile["api_key_env"] = api_key_env.strip()
|
||||||
|
else:
|
||||||
|
# 空串 = 免密本地端点(Ollama/vLLM),显式声明
|
||||||
|
profile["api_key_env"] = ""
|
||||||
|
if display_name:
|
||||||
|
profile["display_name"] = display_name.strip()
|
||||||
|
if temperature is not None:
|
||||||
|
try:
|
||||||
|
profile["temperature"] = max(0.0, min(2.0, float(temperature)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ["❌ 温度必须是 0.0~2.0 的数字"]
|
||||||
|
if timeout is not None:
|
||||||
|
try:
|
||||||
|
profile["timeout"] = max(5, int(timeout))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ["❌ 超时必须是整数秒"]
|
||||||
|
if reasoning_enabled:
|
||||||
|
profile["reasoning_enabled"] = True
|
||||||
|
if fallback:
|
||||||
|
# 只保留已存在的档案,避免悬空引用(L4a 启动期硬性报错)
|
||||||
|
known = set(models.keys())
|
||||||
|
invalid = [f for f in fallback if f not in known or f == name]
|
||||||
|
if invalid:
|
||||||
|
return [f"❌ 失败转移包含不存在的档案或自身: {invalid},请重新选择"]
|
||||||
|
profile["fallback"] = list(fallback)
|
||||||
|
|
||||||
|
models[name] = profile
|
||||||
|
config._config["models"] = models
|
||||||
|
if set_default:
|
||||||
|
config._config.setdefault("api", {})["default_model"] = name
|
||||||
|
|
||||||
|
saved = config._save_config()
|
||||||
|
msgs = [f"✅ 接入点 '{name}' 已添加({profile['api_url']},模型 {profile['model']})"]
|
||||||
|
if not saved:
|
||||||
|
msgs.append("⚠️ 保存失败(仅本次运行生效,重启后丢失)——请检查配置文件目录权限")
|
||||||
|
if set_default:
|
||||||
|
msgs.append(f"⭐ 已设为默认接入点(--model 不指定时使用)")
|
||||||
|
if not api_key_env:
|
||||||
|
msgs.append("ℹ️ 免密端点:请求不携带 API Key(适用于 Ollama/vLLM 等本地服务)")
|
||||||
|
else:
|
||||||
|
msgs.append(f"🔑 Key 从环境变量 {api_key_env} 读取(未设置时该档案不可用)")
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def delete_profile(config: ConfigManager, name: str) -> List[str]:
|
||||||
|
"""删除档案,并自动清理其他档案 fallback 中的悬空引用。"""
|
||||||
|
models = get_models(config)
|
||||||
|
if name not in models:
|
||||||
|
return [f"❌ 档案 '{name}' 不存在"]
|
||||||
|
|
||||||
|
del models[name]
|
||||||
|
|
||||||
|
# 清理其他档案对该档案的 fallback 引用(悬空引用会导致启动期报错)
|
||||||
|
cleaned = []
|
||||||
|
for other, profile in models.items():
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
continue
|
||||||
|
fb = profile.get("fallback") or []
|
||||||
|
if name in fb:
|
||||||
|
profile["fallback"] = [f for f in fb if f != name]
|
||||||
|
cleaned.append(other)
|
||||||
|
config._config["models"] = models
|
||||||
|
|
||||||
|
# 若删除的是默认档案,清除默认标记(回退旧 api 节语义)
|
||||||
|
if config.get("api.default_model") == name:
|
||||||
|
config._config.setdefault("api", {}).pop("default_model", None)
|
||||||
|
|
||||||
|
saved = config._save_config()
|
||||||
|
msgs = [f"✅ 接入点 '{name}' 已删除"]
|
||||||
|
if cleaned:
|
||||||
|
msgs.append(f"ℹ️ 已同时清理 {len(cleaned)} 个档案的失败转移引用: {', '.join(cleaned)}")
|
||||||
|
if not saved:
|
||||||
|
msgs.append("⚠️ 保存失败——请检查配置文件目录权限")
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile(config: ConfigManager, name: str, logger=None) -> str:
|
||||||
|
"""连通性验证:向该档案端点发一次最小请求。"""
|
||||||
|
models = get_models(config)
|
||||||
|
if name not in models:
|
||||||
|
return f"❌ 档案 '{name}' 不存在"
|
||||||
|
try:
|
||||||
|
from llm_client import create_llm_client
|
||||||
|
client = create_llm_client(config, api_key=None, logger=logger, model=name)
|
||||||
|
result = client.chat_completion(
|
||||||
|
[{"role": "user", "content": "ping"}], max_tokens=5)
|
||||||
|
if result is None or not str(result).strip():
|
||||||
|
return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)"
|
||||||
|
return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"❌ 档案 '{name}' 验证失败:{str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 交互式菜单 ====================
|
||||||
|
|
||||||
|
def _ask(prompt: str, default: str = "") -> str:
|
||||||
|
if default:
|
||||||
|
val = input(f"{prompt}(回车默认: {default}): ").strip()
|
||||||
|
return val if val else default
|
||||||
|
return input(f"{prompt}: ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_yes_no(prompt: str, default: bool = False) -> bool:
|
||||||
|
hint = "y/n" if not default else "Y/n"
|
||||||
|
while True:
|
||||||
|
val = input(f"{prompt} [{hint}] ").strip().lower()
|
||||||
|
if not val:
|
||||||
|
return default
|
||||||
|
if val in ("y", "yes", "是"):
|
||||||
|
return True
|
||||||
|
if val in ("n", "no", "否"):
|
||||||
|
return False
|
||||||
|
print(" 请输入 y 或 n")
|
||||||
|
|
||||||
|
|
||||||
|
def _choose(prompt: str, options: List[str], multi: bool = False) -> List[str]:
|
||||||
|
"""编号选择(单选或多选)。多选用逗号分隔,直接回车 = 不选。"""
|
||||||
|
for i, opt in enumerate(options, 1):
|
||||||
|
print(f" {i}. {opt}")
|
||||||
|
if multi:
|
||||||
|
while True:
|
||||||
|
raw = input(f"{prompt}(多选,逗号分隔,直接回车不选): ").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
idxs = [int(x) for x in raw.replace(",", ",").split(",") if x.strip()]
|
||||||
|
except ValueError:
|
||||||
|
print(" 请输入数字编号,逗号分隔")
|
||||||
|
continue
|
||||||
|
if all(1 <= i <= len(options) for i in idxs):
|
||||||
|
return [options[i - 1] for i in idxs]
|
||||||
|
print(f" 编号超出范围(1~{len(options)})")
|
||||||
|
else:
|
||||||
|
while True:
|
||||||
|
raw = input(f"{prompt}(1~{len(options)},直接回车不选): ").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
idx = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
print(" 请输入数字编号")
|
||||||
|
continue
|
||||||
|
if 1 <= idx <= len(options):
|
||||||
|
return [options[idx - 1]]
|
||||||
|
print(f" 编号超出范围(1~{len(options)})")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_profiles(profiles: List[Dict]):
|
||||||
|
if not profiles:
|
||||||
|
print(" (当前没有任何接入点。旧版 api 节仍可用,或选 2 添加)")
|
||||||
|
return
|
||||||
|
for p in profiles:
|
||||||
|
mark = " ⭐默认" if p["is_default"] else ""
|
||||||
|
key_desc = f"环境变量 {p['api_key_env']}" if p["api_key_env"] else "免密"
|
||||||
|
fb = f",失败转移 → {', '.join(p['fallback'])}" if p["fallback"] else ""
|
||||||
|
print(f" {p['name']}{mark} [{p['display_name']}]")
|
||||||
|
print(f" 地址: {p['api_url']} 模型: {p['model']}")
|
||||||
|
print(f" Key: {key_desc} 温度: {p['temperature'] or '默认'} 超时: {p['timeout'] or '默认'}{fb}")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_flow(config: ConfigManager):
|
||||||
|
print("\n── 添加接入点 ──")
|
||||||
|
print("常见厂商端点参考:")
|
||||||
|
for k, v in PROVIDER_HINTS.items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
|
name = _ask("档案名(英文,如 deepseek/kimi/local)")
|
||||||
|
if not name:
|
||||||
|
print("❌ 已取消(档案名不能为空)")
|
||||||
|
return
|
||||||
|
if not PROFILE_NAME_RE.match(name):
|
||||||
|
print(f"❌ 档案名只能包含字母/数字/下划线/短横线")
|
||||||
|
return
|
||||||
|
if name in get_models(config):
|
||||||
|
print(f"❌ 档案名 '{name}' 已存在")
|
||||||
|
return
|
||||||
|
api_url = _ask("API 地址")
|
||||||
|
model = _ask("模型名(如 deepseek-chat / qwen-plus)")
|
||||||
|
|
||||||
|
print("Key 方式:")
|
||||||
|
print(" 1. 环境变量名(推荐,如 DEEPSEEK_API_KEY——Key 不落盘,重启不丢)")
|
||||||
|
print(" 2. 免密本地端点(如 Ollama,不需要 Key)")
|
||||||
|
key_mode = _ask("选择 [1/2]", "1")
|
||||||
|
api_key_env = ""
|
||||||
|
if key_mode == "1":
|
||||||
|
api_key_env = _ask("环境变量名", "DEEPSEEK_API_KEY")
|
||||||
|
elif key_mode != "2":
|
||||||
|
print("❌ 已取消(Key 方式无效)")
|
||||||
|
return
|
||||||
|
|
||||||
|
display_name = _ask("显示名(可选,回车用档案名)")
|
||||||
|
temperature_raw = _ask("温度 0.0~2.0(可选,越低越确定)")
|
||||||
|
timeout_raw = _ask("超时秒数(可选)")
|
||||||
|
reasoning = _ask_yes_no("启用思考模式?", default=False)
|
||||||
|
|
||||||
|
profiles = list_profiles(config)
|
||||||
|
fallback = []
|
||||||
|
if profiles:
|
||||||
|
print("失败转移:主接入点故障时按顺序尝试的备用接入点(可选)")
|
||||||
|
fallback = _choose("选择备用接入点", [p["name"] for p in profiles], multi=True)
|
||||||
|
|
||||||
|
set_default = _ask_yes_no("设为默认接入点?", default=(len(profiles) == 0))
|
||||||
|
|
||||||
|
print("\n── 确认 ──")
|
||||||
|
print(f" 档案名: {name}")
|
||||||
|
print(f" 地址: {api_url} 模型: {model}")
|
||||||
|
print(f" Key: {'环境变量 ' + api_key_env if api_key_env else '免密(不携带)'}")
|
||||||
|
if display_name:
|
||||||
|
print(f" 显示名: {display_name}")
|
||||||
|
if temperature_raw:
|
||||||
|
print(f" 温度: {temperature_raw}")
|
||||||
|
if timeout_raw:
|
||||||
|
print(f" 超时: {timeout_raw}s")
|
||||||
|
if reasoning:
|
||||||
|
print(" 思考模式: 开启")
|
||||||
|
if fallback:
|
||||||
|
print(f" 失败转移: {', '.join(fallback)}")
|
||||||
|
if not _ask_yes_no("确认保存?", default=True):
|
||||||
|
print("已取消")
|
||||||
|
return
|
||||||
|
|
||||||
|
msgs = add_profile(config, name, api_url, model,
|
||||||
|
api_key_env=api_key_env, display_name=display_name,
|
||||||
|
temperature=float(temperature_raw) if temperature_raw else None,
|
||||||
|
timeout=int(timeout_raw) if timeout_raw else None,
|
||||||
|
reasoning_enabled=reasoning, fallback=fallback,
|
||||||
|
set_default=set_default)
|
||||||
|
for m in msgs:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_flow(config: ConfigManager):
|
||||||
|
profiles = list_profiles(config)
|
||||||
|
if not profiles:
|
||||||
|
print("\n(当前没有任何接入点)")
|
||||||
|
return
|
||||||
|
print("\n── 删除接入点 ──")
|
||||||
|
picked = _choose("选择要删除的接入点", [p["name"] for p in profiles])
|
||||||
|
if not picked:
|
||||||
|
print("已取消")
|
||||||
|
return
|
||||||
|
name = picked[0]
|
||||||
|
if not _ask_yes_no(f"确认删除 '{name}'?(其他档案的失败转移引用会自动清理)", default=False):
|
||||||
|
print("已取消")
|
||||||
|
return
|
||||||
|
for m in delete_profile(config, name):
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
def _test_flow(config: ConfigManager):
|
||||||
|
profiles = list_profiles(config)
|
||||||
|
if not profiles:
|
||||||
|
print("\n(当前没有任何接入点)")
|
||||||
|
return
|
||||||
|
print("\n── 连通性验证 ──")
|
||||||
|
picked = _choose("选择要验证的接入点", [p["name"] for p in profiles])
|
||||||
|
if not picked:
|
||||||
|
print("已取消")
|
||||||
|
return
|
||||||
|
print(" 正在请求端点(最长 20 秒)...")
|
||||||
|
print(test_profile(config, picked[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def run_interactive(config: ConfigManager = None):
|
||||||
|
"""交互式主循环。config 可注入(测试用)。"""
|
||||||
|
config = config or ConfigManager()
|
||||||
|
config_path = getattr(config, "_config_path", None) or "(默认位置)"
|
||||||
|
print("=" * 56)
|
||||||
|
print(" aibox 大模型接入点管理工具")
|
||||||
|
print(f" 配置文件: {config_path}")
|
||||||
|
print(" 说明:API Key 只存环境变量名,不落盘;免密端点适合 Ollama 等本地服务")
|
||||||
|
print("=" * 56)
|
||||||
|
while True:
|
||||||
|
print("\n请选择操作:")
|
||||||
|
print(" 1. 📋 列出接入点")
|
||||||
|
print(" 2. ➕ 添加接入点")
|
||||||
|
print(" 3. 🗑️ 删除接入点")
|
||||||
|
print(" 4. 🔍 验证接入点连通性")
|
||||||
|
print(" 0. 退出")
|
||||||
|
choice = input("> ").strip()
|
||||||
|
if choice == "1":
|
||||||
|
_print_profiles(list_profiles(config))
|
||||||
|
elif choice == "2":
|
||||||
|
_add_flow(config)
|
||||||
|
elif choice == "3":
|
||||||
|
_delete_flow(config)
|
||||||
|
elif choice == "4":
|
||||||
|
_test_flow(config)
|
||||||
|
elif choice in ("0", "q", "exit", "退出"):
|
||||||
|
print("再见")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print("无效选择,请输入 0~4")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="aibox 大模型接入点管理工具")
|
||||||
|
parser.add_argument("--list", action="store_true", help="非交互:列出当前接入点")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = ConfigManager()
|
||||||
|
if args.list:
|
||||||
|
_print_profiles(list_profiles(config))
|
||||||
|
return
|
||||||
|
run_interactive(config)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -514,7 +514,8 @@ class ConversationCompressor:
|
|||||||
final_tokens = self.estimate_tokens([m for m in final_messages if m.get("role") != "system"])
|
final_tokens = self.estimate_tokens([m for m in final_messages if m.get("role") != "system"])
|
||||||
|
|
||||||
print(f"\n✅ {i18n.get('conversation_compressed', original_count, final_count)}")
|
print(f"\n✅ {i18n.get('conversation_compressed', original_count, final_count)}")
|
||||||
print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens/original_tokens:.1%})")
|
# L6:压缩块全空时 original_tokens 可能为 0,除零防护
|
||||||
|
print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens / max(original_tokens, 1):.1%})")
|
||||||
|
|
||||||
# 检查是否压缩到阈值以下
|
# 检查是否压缩到阈值以下
|
||||||
if final_count >= threshold:
|
if final_count >= threshold:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ subsystems.conversation_history - 对话历史管理器
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -68,10 +69,19 @@ class ConversationHistory:
|
|||||||
return {"conversations": [], "last_id": 0}
|
return {"conversations": [], "last_id": 0}
|
||||||
return {"conversations": [], "last_id": 0}
|
return {"conversations": [], "last_id": 0}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _atomic_write_json(path: Path, data) -> None:
|
||||||
|
"""原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再
|
||||||
|
os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏
|
||||||
|
后仅静默置 None,历史内容丢失)"""
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
def _save_history_index(self):
|
def _save_history_index(self):
|
||||||
"""保存历史索引"""
|
"""保存历史索引"""
|
||||||
with open(self.history_file, 'w', encoding='utf-8') as f:
|
self._atomic_write_json(self.history_file, self.history_index)
|
||||||
json.dump(self.history_index, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
def _load_current_conversation(self):
|
def _load_current_conversation(self):
|
||||||
"""加载当前的对话(如果有)"""
|
"""加载当前的对话(如果有)"""
|
||||||
@@ -86,8 +96,7 @@ class ConversationHistory:
|
|||||||
def save_current_conversation(self):
|
def save_current_conversation(self):
|
||||||
"""保存当前对话"""
|
"""保存当前对话"""
|
||||||
if self.current_conversation:
|
if self.current_conversation:
|
||||||
with open(self.current_file, 'w', encoding='utf-8') as f:
|
self._atomic_write_json(self.current_file, self.current_conversation)
|
||||||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
def start_new_conversation(self, topic: str = None) -> Dict:
|
def start_new_conversation(self, topic: str = None) -> Dict:
|
||||||
"""开始新对话"""
|
"""开始新对话"""
|
||||||
@@ -145,8 +154,7 @@ class ConversationHistory:
|
|||||||
counter += 1
|
counter += 1
|
||||||
archive_path = self.archive_dir / archive_filename
|
archive_path = self.archive_dir / archive_filename
|
||||||
|
|
||||||
with open(archive_path, 'w', encoding='utf-8') as f:
|
self._atomic_write_json(archive_path, self.current_conversation)
|
||||||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
self.history_index["conversations"].append({
|
self.history_index["conversations"].append({
|
||||||
"id": self.current_conversation["id"],
|
"id": self.current_conversation["id"],
|
||||||
@@ -350,7 +358,13 @@ class ConversationHistory:
|
|||||||
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
|
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
|
||||||
return self.current_conversation
|
return self.current_conversation
|
||||||
|
|
||||||
file_path = self.archive_dir / file_name
|
# I 批(审计 2026-08-18):file_name 目前无 LLM 可控调用面,但一旦
|
||||||
|
# 接入即成为任意 .json 读取通道——realpath 解析软链后再做目录前缀校验
|
||||||
|
archive_root = os.path.normcase(os.path.realpath(str(self.archive_dir)))
|
||||||
|
target = os.path.normcase(os.path.realpath(str(self.archive_dir / file_name)))
|
||||||
|
if not target.startswith(archive_root + os.sep):
|
||||||
|
return None
|
||||||
|
file_path = Path(target)
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|||||||
+36
-11
@@ -232,13 +232,14 @@ class MemoryDatabase:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
def get_recent_memories(self, limit: int = 10) -> List[Dict]:
|
def get_recent_memories(self, limit: int = 10) -> List[Dict]:
|
||||||
"""获取最近的记忆。limit=None 时不限制返回条数(供分页使用)。"""
|
"""获取最近的记忆。limit=None 时使用默认上限(M4:此前 None 无 LIMIT
|
||||||
|
全表加载,记忆库增长后单次调用 fetchall 全量入内存;与 search_memories
|
||||||
|
的 None→默认上限语义对齐)。"""
|
||||||
|
if limit is None:
|
||||||
|
limit = constants.MEMORY_MAX_SEARCH_RESULTS
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
if limit is None:
|
|
||||||
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC')
|
|
||||||
else:
|
|
||||||
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
|
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
@@ -401,11 +402,14 @@ class MemoDatabase:
|
|||||||
# 如果传入了 before_time,使用它,否则使用当前时间
|
# 如果传入了 before_time,使用它,否则使用当前时间
|
||||||
compare_time = before_time if before_time else now
|
compare_time = before_time if before_time else now
|
||||||
|
|
||||||
|
# I 批(审计 2026-08-18):去掉 SQL 层 reminder_time <= ? 预过滤——
|
||||||
|
# 字符串比较会把格式异常的时间(如 "不是时间")静默排除在 SQL 层,
|
||||||
|
# Python 二次验证永远看不到它们(Q5:格式异常任务按到期返回)。
|
||||||
|
# 时间过滤改由下方 Python 解析做权威判断(本地小库全扫描可忽略)。
|
||||||
query = '''
|
query = '''
|
||||||
SELECT * FROM memos
|
SELECT * FROM memos
|
||||||
WHERE is_completed = 0
|
WHERE is_completed = 0
|
||||||
AND reminder_time IS NOT NULL
|
AND reminder_time IS NOT NULL
|
||||||
AND reminder_time <= ? -- 严格小于等于比较时间
|
|
||||||
AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
|
AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -414,11 +418,13 @@ class MemoDatabase:
|
|||||||
|
|
||||||
query += ' ORDER BY reminder_time ASC, priority DESC'
|
query += ' ORDER BY reminder_time ASC, priority DESC'
|
||||||
|
|
||||||
params = [compare_time, compare_time]
|
params = [compare_time]
|
||||||
|
|
||||||
if limit:
|
# I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
|
||||||
|
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
|
||||||
|
if limit is not None:
|
||||||
query += ' LIMIT ?'
|
query += ' LIMIT ?'
|
||||||
params.append(int(limit))
|
params.append(max(0, int(limit)))
|
||||||
|
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
|
|
||||||
@@ -439,7 +445,11 @@ class MemoDatabase:
|
|||||||
# 调试日志
|
# 调试日志
|
||||||
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
|
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}")
|
# I 批(审计 2026-08-18):格式异常此前被静默丢弃——
|
||||||
|
# 任务永不执行且用户无感知。改为按"到期"返回并告警,
|
||||||
|
# 至少保证任务被处理而非永久丢失
|
||||||
|
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败,按到期处理: {e}")
|
||||||
|
results.append(memo)
|
||||||
else:
|
else:
|
||||||
# 没有提醒时间的任务不应该在待执行列表中
|
# 没有提醒时间的任务不应该在待执行列表中
|
||||||
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
|
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
|
||||||
@@ -460,9 +470,11 @@ class MemoDatabase:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
params = []
|
params = []
|
||||||
if limit:
|
# I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
|
||||||
|
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
|
||||||
|
if limit is not None:
|
||||||
query += ' LIMIT ?'
|
query += ' LIMIT ?'
|
||||||
params.append(int(limit))
|
params.append(max(0, int(limit)))
|
||||||
|
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
|
|
||||||
@@ -524,6 +536,10 @@ class MemoDatabase:
|
|||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
# I 批(审计 2026-08-18):多进程同库(CLI --memo-add + 服务调度)
|
||||||
|
# 写竞争时默认 5s 后直接报 database is locked——busy_timeout 让
|
||||||
|
# SQLite 等待锁释放(最长 10s)而不是立即失败
|
||||||
|
cursor.execute("PRAGMA busy_timeout = 10000")
|
||||||
cursor.execute("BEGIN IMMEDIATE")
|
cursor.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
|
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
|
||||||
@@ -672,6 +688,15 @@ class MemoDatabase:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def delete_memo(self, memo_id: int) -> bool:
|
||||||
|
"""物理删除任务(M5:delete 语义此前被 complete_memo 顶替——只标记
|
||||||
|
完成不删行,重复任务还会被推进而非移除,stats 计数失真)"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def get_memo_stats(self) -> Dict:
|
def get_memo_stats(self) -> Dict:
|
||||||
"""获取任务统计信息"""
|
"""获取任务统计信息"""
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ subsystems.executors - 命令执行器
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -71,17 +72,44 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
def __init__(self, logger=None):
|
def __init__(self, logger=None):
|
||||||
super().__init__(logger)
|
super().__init__(logger)
|
||||||
self._current_process = None
|
self._current_process = None
|
||||||
|
self._current_pgid = None # L9:Popen 后立即捕获,shell 死亡后 getpgid 会失败
|
||||||
self._process_lock = threading.Lock()
|
self._process_lock = threading.Lock()
|
||||||
|
|
||||||
|
def _terminate_tree(self, proc, force: bool = False):
|
||||||
|
"""终止整个进程组(L9:POSIX 下 shell=True 只杀 shell 时,孙进程
|
||||||
|
(`(x &)` 等)仍持管道写端——reader 阻塞无 EOF、fd/线程悬挂至孙
|
||||||
|
进程退出,写满 64KB 管道缓冲后孙进程也卡死。start_new_session 使
|
||||||
|
shell 成为会话首进程,按组信号可一次覆盖整棵进程树)。
|
||||||
|
组 ID 在 Popen 后立即保存:shell 死亡后 os.getpgid(pid) 会抛
|
||||||
|
ProcessLookupError,届时无法再从进程反查组。
|
||||||
|
Windows 无进程组概念,退回单进程 terminate/kill。"""
|
||||||
|
try:
|
||||||
|
if os.name == "posix":
|
||||||
|
sig = signal.SIGKILL if force else signal.SIGTERM
|
||||||
|
with self._process_lock:
|
||||||
|
pgid = self._current_pgid
|
||||||
|
if pgid is None:
|
||||||
|
pgid = os.getpgid(proc.pid)
|
||||||
|
os.killpg(pgid, sig)
|
||||||
|
elif force:
|
||||||
|
proc.kill()
|
||||||
|
else:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
"""取消当前正在运行的命令(kill子进程)。线程安全。"""
|
"""取消当前正在运行的命令(kill子进程组)。线程安全。"""
|
||||||
with self._process_lock:
|
with self._process_lock:
|
||||||
proc = self._current_process
|
proc = self._current_process
|
||||||
if proc is not None:
|
if proc is not None:
|
||||||
try:
|
try:
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.kill()
|
self._terminate_tree(proc, force=True)
|
||||||
self.logger.info("已终止正在运行的子进程")
|
self.logger.info("已终止正在运行的子进程组")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -117,14 +145,14 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
while True:
|
while True:
|
||||||
# 检查超时(readline 阻塞不再影响本判断)
|
# 检查超时(readline 阻塞不再影响本判断)
|
||||||
if timeout > 0 and time.time() - start_time > timeout:
|
if timeout > 0 and time.time() - start_time > timeout:
|
||||||
process.terminate()
|
self._terminate_tree(process)
|
||||||
timeout_occurred = True
|
timeout_occurred = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# 检查输出大小
|
# 检查输出大小
|
||||||
if total_bytes >= self.MAX_OUTPUT_BYTES:
|
if total_bytes >= self.MAX_OUTPUT_BYTES:
|
||||||
truncated = True
|
truncated = True
|
||||||
process.terminate()
|
self._terminate_tree(process)
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -140,6 +168,12 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
if shell_dead_since is None:
|
if shell_dead_since is None:
|
||||||
shell_dead_since = time.time()
|
shell_dead_since = time.time()
|
||||||
elif time.time() - shell_dead_since > 1.0:
|
elif time.time() - shell_dead_since > 1.0:
|
||||||
|
# L9:shell 已死但孙进程仍持管道写端(`(sleep 300 &) ;
|
||||||
|
# echo done` 形态)。只靠 daemon reader 兜底会悬挂线程
|
||||||
|
# 与管道 fd 至孙进程退出,写满 64KB 缓冲后孙进程自身
|
||||||
|
# 也卡死。此处强杀进程组收尾(nohup 重定向输出的
|
||||||
|
# 真正脱离场景不持管道,不会走到这里)。
|
||||||
|
self._terminate_tree(process, force=True)
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -147,10 +181,12 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
|
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
|
||||||
allowed = self.MAX_OUTPUT_BYTES - total_bytes
|
allowed = self.MAX_OUTPUT_BYTES - total_bytes
|
||||||
if allowed > 0:
|
if allowed > 0:
|
||||||
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace'))
|
# L5:decode 用 errors='ignore'——按字节截断可能落在多字节
|
||||||
|
# 字符中间,errors='replace' 会在行尾留下半个字符(�)
|
||||||
|
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='ignore'))
|
||||||
total_bytes = self.MAX_OUTPUT_BYTES
|
total_bytes = self.MAX_OUTPUT_BYTES
|
||||||
truncated = True
|
truncated = True
|
||||||
process.terminate()
|
self._terminate_tree(process)
|
||||||
break
|
break
|
||||||
|
|
||||||
output_chunks.append(line)
|
output_chunks.append(line)
|
||||||
@@ -209,25 +245,30 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
bufsize=1,
|
bufsize=1,
|
||||||
universal_newlines=True,
|
universal_newlines=True,
|
||||||
env=exec_env,
|
env=exec_env,
|
||||||
cwd=os.path.expanduser(work_dir)
|
cwd=os.path.expanduser(work_dir),
|
||||||
|
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
|
||||||
|
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
|
||||||
|
start_new_session=(os.name == "posix")
|
||||||
)
|
)
|
||||||
# 保存进程引用以便cancel()可以kill它
|
# 保存进程引用以便cancel()可以kill它
|
||||||
with self._process_lock:
|
with self._process_lock:
|
||||||
self._current_process = process
|
self._current_process = process
|
||||||
|
if os.name == "posix":
|
||||||
|
try:
|
||||||
|
self._current_pgid = os.getpgid(process.pid)
|
||||||
|
except Exception:
|
||||||
|
self._current_pgid = None
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
|
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
|
||||||
|
|
||||||
# 确保进程终止
|
# 确保进程终止
|
||||||
if process.poll() is None:
|
if process.poll() is None:
|
||||||
if timeout_occurred or truncated:
|
self._terminate_tree(process, force=timeout_occurred or truncated)
|
||||||
process.kill()
|
|
||||||
else:
|
|
||||||
process.terminate()
|
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except:
|
except:
|
||||||
process.kill()
|
self._terminate_tree(process, force=True)
|
||||||
|
|
||||||
return_code = process.returncode if process.returncode is not None else -1
|
return_code = process.returncode if process.returncode is not None else -1
|
||||||
|
|
||||||
@@ -255,10 +296,11 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
return f"❌ 执行错误: {str(e)}"
|
return f"❌ 执行错误: {str(e)}"
|
||||||
finally:
|
finally:
|
||||||
if process and process.poll() is None:
|
if process and process.poll() is None:
|
||||||
process.kill()
|
self._terminate_tree(process, force=True)
|
||||||
# 清除当前进程引用
|
# 清除当前进程引用
|
||||||
with self._process_lock:
|
with self._process_lock:
|
||||||
self._current_process = None
|
self._current_process = None
|
||||||
|
self._current_pgid = None
|
||||||
|
|
||||||
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
|
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
|
||||||
work_dir: str = None, env: Dict = None, **kwargs):
|
work_dir: str = None, env: Dict = None, **kwargs):
|
||||||
@@ -296,12 +338,20 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
bufsize=1,
|
bufsize=1,
|
||||||
universal_newlines=True,
|
universal_newlines=True,
|
||||||
env=exec_env,
|
env=exec_env,
|
||||||
cwd=os.path.expanduser(work_dir)
|
cwd=os.path.expanduser(work_dir),
|
||||||
|
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
|
||||||
|
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
|
||||||
|
start_new_session=(os.name == "posix")
|
||||||
)
|
)
|
||||||
self.process = process
|
self.process = process
|
||||||
# 保存进程引用以便cancel()可以kill它
|
# 保存进程引用以便cancel()可以kill它
|
||||||
with self._process_lock:
|
with self._process_lock:
|
||||||
self._current_process = process
|
self._current_process = process
|
||||||
|
if os.name == "posix":
|
||||||
|
try:
|
||||||
|
self._current_pgid = os.getpgid(process.pid)
|
||||||
|
except Exception:
|
||||||
|
self._current_pgid = None
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
# H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发
|
# H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发
|
||||||
@@ -315,7 +365,7 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
|
|
||||||
# 如果超时或截断,确保进程被终止
|
# 如果超时或截断,确保进程被终止
|
||||||
if (timeout_occurred or truncated) and process.poll() is None:
|
if (timeout_occurred or truncated) and process.poll() is None:
|
||||||
process.kill()
|
self._terminate_tree(process, force=True)
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except:
|
except:
|
||||||
@@ -352,11 +402,12 @@ class SubprocessCommandExecutor(CommandExecutor):
|
|||||||
output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
|
output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
|
||||||
finally:
|
finally:
|
||||||
if process and process.poll() is None:
|
if process and process.poll() is None:
|
||||||
process.terminate()
|
self._terminate_tree(process)
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except:
|
except:
|
||||||
process.kill()
|
self._terminate_tree(process, force=True)
|
||||||
self.process = None
|
self.process = None
|
||||||
with self._process_lock:
|
with self._process_lock:
|
||||||
self._current_process = None
|
self._current_process = None
|
||||||
|
self._current_pgid = None
|
||||||
@@ -18,6 +18,7 @@ subsystems.prompt_manager - 提示词管理器
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ class PromptManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
|
_instance_lock = threading.Lock() # I 批:并发首例化竞态防护
|
||||||
_prompts = []
|
_prompts = []
|
||||||
_prompts_dir = None
|
_prompts_dir = None
|
||||||
|
|
||||||
@@ -43,10 +45,16 @@ class PromptManager:
|
|||||||
STATUS_FILE = "100_STATUS.md"
|
STATUS_FILE = "100_STATUS.md"
|
||||||
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
|
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
|
||||||
|
|
||||||
|
# N9(审计 2026-08-18):Agent 可控内容直写 00/99/100 文件并作为后续
|
||||||
|
# 会话系统提示词加载(持久化注入面)。内容长度上限约束注入规模,
|
||||||
|
# 超长内容拒绝写入(工具层有更友好的提示)。
|
||||||
|
MAX_CONTENT_LENGTH = 10000
|
||||||
|
|
||||||
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
|
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
|
||||||
_SKILL_FILE_RE = re.compile(r'^(\d+)_')
|
_SKILL_FILE_RE = re.compile(r'^(\d+)_')
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
|
with cls._instance_lock:
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
return cls._instance
|
return cls._instance
|
||||||
@@ -60,10 +68,16 @@ class PromptManager:
|
|||||||
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
|
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
|
||||||
return os.path.expanduser(prompts_dir)
|
return os.path.expanduser(prompts_dir)
|
||||||
|
|
||||||
def _load_prompts(self):
|
def _load_prompts(self, target: Optional[List[Dict]] = None):
|
||||||
|
"""加载提示词到 target 列表(缺省 self._prompts)。
|
||||||
|
|
||||||
|
M6:reload 传入新列表本地构建,完成后由 reload 原子替换——
|
||||||
|
此前先清空 self._prompts 再逐文件重读,并发读者(其他连接构建
|
||||||
|
system 消息)会读到空/半截提示词列表。
|
||||||
|
"""
|
||||||
|
prompts = target if target is not None else self._prompts
|
||||||
self._prompts_dir = self._get_prompts_dir()
|
self._prompts_dir = self._get_prompts_dir()
|
||||||
os.makedirs(self._prompts_dir, exist_ok=True)
|
os.makedirs(self._prompts_dir, exist_ok=True)
|
||||||
self._prompts = []
|
|
||||||
|
|
||||||
# 确保核心文件存在
|
# 确保核心文件存在
|
||||||
self._ensure_identity_file()
|
self._ensure_identity_file()
|
||||||
@@ -86,17 +100,14 @@ class PromptManager:
|
|||||||
with open(filepath, 'r', encoding='utf-8') as f:
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
content = f.read().strip()
|
content = f.read().strip()
|
||||||
if content:
|
if content:
|
||||||
self._prompts.append({
|
prompts.append({
|
||||||
'file': filename,
|
'file': filename,
|
||||||
'content': content
|
'content': content
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
|
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
|
||||||
|
|
||||||
essential_files = [self.IDENTITY_FILE, self.STATUS_FILE, self.SOUL_FILE]
|
if not prompts:
|
||||||
other_files = [f for f in prompt_files if f not in essential_files] if prompt_files else []
|
|
||||||
|
|
||||||
if not self._prompts:
|
|
||||||
self._create_default_prompts()
|
self._create_default_prompts()
|
||||||
|
|
||||||
def _get_template_dir(self) -> str:
|
def _get_template_dir(self) -> str:
|
||||||
@@ -148,6 +159,9 @@ class PromptManager:
|
|||||||
|
|
||||||
def update_identity(self, content: str) -> bool:
|
def update_identity(self, content: str) -> bool:
|
||||||
"""更新用户身份信息"""
|
"""更新用户身份信息"""
|
||||||
|
if len(content or "") > self.MAX_CONTENT_LENGTH:
|
||||||
|
print(f"⚠️ 身份内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
|
||||||
|
return False
|
||||||
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
|
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
@@ -168,6 +182,9 @@ class PromptManager:
|
|||||||
|
|
||||||
def update_status(self, content: str) -> bool:
|
def update_status(self, content: str) -> bool:
|
||||||
"""更新智能体状态"""
|
"""更新智能体状态"""
|
||||||
|
if len(content or "") > self.MAX_CONTENT_LENGTH:
|
||||||
|
print(f"⚠️ 状态内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
|
||||||
|
return False
|
||||||
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
|
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
@@ -188,6 +205,9 @@ class PromptManager:
|
|||||||
|
|
||||||
def update_soul(self, content: str) -> bool:
|
def update_soul(self, content: str) -> bool:
|
||||||
"""更新智能体灵魂定义 - 所有角色定位内容"""
|
"""更新智能体灵魂定义 - 所有角色定位内容"""
|
||||||
|
if len(content or "") > self.MAX_CONTENT_LENGTH:
|
||||||
|
print(f"⚠️ 灵魂内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
|
||||||
|
return False
|
||||||
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
|
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
@@ -240,10 +260,6 @@ class PromptManager:
|
|||||||
return """我是DeepSeek,一个智能AI助手。
|
return """我是DeepSeek,一个智能AI助手。
|
||||||
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
|
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
|
||||||
|
|
||||||
def get_skill_prompts(self) -> List[Dict]:
|
|
||||||
"""获取所有基础技能提示词(以1_开头的文件)"""
|
|
||||||
return [p for p in self._prompts if p['file'].startswith(self.SKILL_PREFIX)]
|
|
||||||
|
|
||||||
def get_soul_prompt(self) -> Optional[Dict]:
|
def get_soul_prompt(self) -> Optional[Dict]:
|
||||||
"""获取灵魂提示词(99_SOUL.md)"""
|
"""获取灵魂提示词(99_SOUL.md)"""
|
||||||
for p in self._prompts:
|
for p in self._prompts:
|
||||||
@@ -296,9 +312,10 @@ class PromptManager:
|
|||||||
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
|
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
"""重新加载所有提示词"""
|
"""重新加载所有提示词(M6:构建新列表后原子替换,并发读者始终读到完整列表)"""
|
||||||
self._prompts = []
|
new_prompts: List[Dict] = []
|
||||||
self._load_prompts()
|
self._load_prompts(new_prompts)
|
||||||
|
self._prompts = new_prompts
|
||||||
return len(self._prompts)
|
return len(self._prompts)
|
||||||
|
|
||||||
def get_prompt_count(self) -> int:
|
def get_prompt_count(self) -> int:
|
||||||
|
|||||||
+155
-1
@@ -15,7 +15,7 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from common import constants
|
from common import ConfigManager, constants
|
||||||
from subsystems import (
|
from subsystems import (
|
||||||
MemoryDatabase, MemoDatabase, PromptManager, SkillManager,
|
MemoryDatabase, MemoDatabase, PromptManager, SkillManager,
|
||||||
SubprocessCommandExecutor,
|
SubprocessCommandExecutor,
|
||||||
@@ -425,6 +425,160 @@ def test_get_proxy_when_disabled():
|
|||||||
assert "未启用" in r or "启用" in r
|
assert "未启用" in r or "启用" in r
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- SetProxyTool 连通性验证(设置前验证,失败不落盘) ----------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_config(tmp_path, monkeypatch):
|
||||||
|
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
|
||||||
|
cfg_file = tmp_path / "config.json"
|
||||||
|
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
|
||||||
|
|
||||||
|
def _reset():
|
||||||
|
ConfigManager._instance = None
|
||||||
|
ConfigManager._config = None
|
||||||
|
ConfigManager._config_path = None
|
||||||
|
|
||||||
|
_reset()
|
||||||
|
yield ConfigManager
|
||||||
|
_reset()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, status_code):
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_session_factory(exc=None, resp=None):
|
||||||
|
"""构造替代 requests.Session 的假类:get() 抛异常或返回假响应"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.trust_env = True
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
if exc:
|
||||||
|
raise exc
|
||||||
|
return resp
|
||||||
|
|
||||||
|
_FakeSession.calls = calls
|
||||||
|
return _FakeSession
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_connectivity_ok(monkeypatch):
|
||||||
|
fake = _fake_session_factory(resp=_FakeResp(204))
|
||||||
|
monkeypatch.setattr("tools.proxy_tools.requests.Session", fake)
|
||||||
|
t = SetProxyTool()
|
||||||
|
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
|
||||||
|
assert ok and kind == ""
|
||||||
|
# trust_env 必须关掉:排除系统 http_proxy 环境变量干扰
|
||||||
|
assert fake.calls, "应发出探测请求"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_connectivity_classification(monkeypatch, tmp_path):
|
||||||
|
"""异常类型/消息 → 错误类型分类(与系统语言无关)"""
|
||||||
|
import requests as requests_lib
|
||||||
|
cases = [
|
||||||
|
# Windows 本地化的连接拒绝消息:靠异常类型而非英文关键词分类
|
||||||
|
(requests_lib.exceptions.ConnectionError(
|
||||||
|
"[WinError 10061] 由于目标计算机积极拒绝,无法连接。"), "connect_failed"),
|
||||||
|
# ConnectTimeout 同时继承 ConnectionError/Timeout,必须按 Timeout 归类
|
||||||
|
(requests_lib.exceptions.ConnectTimeout("connection timeout"), "timeout"),
|
||||||
|
(requests_lib.exceptions.ConnectionError(
|
||||||
|
"SOCKS5 authentication failed"), "auth_failed"),
|
||||||
|
(requests_lib.exceptions.ConnectionError(
|
||||||
|
"Missing dependencies for SOCKS support."), "missing_dependency"),
|
||||||
|
(Exception("Read timed out"), "timeout"), # 兜底:非 requests 异常按消息
|
||||||
|
(Exception("Connection refused"), "connect_failed"),
|
||||||
|
(Exception("something weird"), "error"),
|
||||||
|
]
|
||||||
|
t = SetProxyTool()
|
||||||
|
for exc, expect_kind in cases:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"tools.proxy_tools.requests.Session",
|
||||||
|
_fake_session_factory(exc=exc))
|
||||||
|
ok, kind, _ = t._check_connectivity("127.0.0.1", 1080, "u", "p")
|
||||||
|
assert not ok
|
||||||
|
assert kind == expect_kind, f"{exc!r} 应分类为 {expect_kind},实际 {kind}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_connectivity_bad_status(monkeypatch):
|
||||||
|
"""探测端点返回非 200/204 → probe_failed"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"tools.proxy_tools.requests.Session",
|
||||||
|
_fake_session_factory(resp=_FakeResp(502)))
|
||||||
|
t = SetProxyTool()
|
||||||
|
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
|
||||||
|
assert not ok and kind == "probe_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_proxy_rejected_when_unreachable(fresh_config, monkeypatch):
|
||||||
|
"""验证失败:返回 ❌ 且配置不落盘"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SetProxyTool, "_check_connectivity",
|
||||||
|
lambda self, h, p, u, pw: (False, "connect_failed", "Connection refused"))
|
||||||
|
t = SetProxyTool()
|
||||||
|
r = t.execute(host="10.255.255.1", port=1080)
|
||||||
|
assert "❌" in r
|
||||||
|
assert "未保存" in r
|
||||||
|
# 配置未写入:代理仍未启用
|
||||||
|
assert fresh_config().get_proxy() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_proxy_saved_when_check_passes(fresh_config, monkeypatch):
|
||||||
|
"""验证通过:配置保存且结果带连通性详情"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SetProxyTool, "_check_connectivity",
|
||||||
|
lambda self, h, p, u, pw: (True, "", "探测成功 HTTP 204"))
|
||||||
|
t = SetProxyTool()
|
||||||
|
r = t.execute(host="127.0.0.1", port=1080)
|
||||||
|
assert "✅" in r
|
||||||
|
assert "探测成功" in r
|
||||||
|
proxies = fresh_config().get_proxy()
|
||||||
|
assert proxies and "127.0.0.1:1080" in proxies["http"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_proxy_skip_check_bypasses_validation(fresh_config, monkeypatch):
|
||||||
|
"""skip_check=True:不验证直接保存(代理未启动的预配置场景)"""
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def _no_call(self, *a, **kw):
|
||||||
|
called.append(1)
|
||||||
|
raise AssertionError("skip_check=True 时不应调用验证")
|
||||||
|
|
||||||
|
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
|
||||||
|
t = SetProxyTool()
|
||||||
|
r = t.execute(host="127.0.0.1", port=1080, skip_check=True)
|
||||||
|
assert "✅" in r
|
||||||
|
assert not called
|
||||||
|
assert fresh_config().get_proxy() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_proxy_disabled_skips_check(fresh_config, monkeypatch):
|
||||||
|
"""enabled=False:禁用代理无需验证"""
|
||||||
|
def _no_call(self, *a, **kw):
|
||||||
|
raise AssertionError("禁用代理不应触发验证")
|
||||||
|
|
||||||
|
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
|
||||||
|
t = SetProxyTool()
|
||||||
|
r = t.execute(host="127.0.0.1", port=1080, enabled=False)
|
||||||
|
assert "✅" in r
|
||||||
|
assert fresh_config().get_proxy() is None # 禁用态 get_proxy 返回 None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_proxy_empty_env_hint(fresh_config, monkeypatch):
|
||||||
|
"""username + password_env 指向未设环境变量:精准提示而非裸 auth 失败"""
|
||||||
|
monkeypatch.delenv("AIBOX_TEST_SOCKS_PW", raising=False)
|
||||||
|
t = SetProxyTool()
|
||||||
|
r = t.execute(host="127.0.0.1", port=1080,
|
||||||
|
username="u1", password_env="AIBOX_TEST_SOCKS_PW")
|
||||||
|
assert "❌" in r
|
||||||
|
assert "环境变量" in r
|
||||||
|
assert "未保存" in r
|
||||||
|
assert fresh_config().get_proxy() is None
|
||||||
|
|
||||||
|
|
||||||
# ==================== AskUserTool ====================
|
# ==================== AskUserTool ====================
|
||||||
|
|
||||||
def test_ask_user_interface():
|
def test_ask_user_interface():
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""aibox 第四轮审计 Batch H 修复回归测试(2026-08-18,审计报告 §十一)
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- H1:子代理共享父取消事件后首轮 think 不再清除父事件(N5 回归)+ 父已取消时跳过启动
|
||||||
|
- M3:调度器任务级超时兜底(AI think 卡死不再停摆调度循环)
|
||||||
|
- M4:get_recent_memories(limit=None) 使用默认上限(不再全表加载)
|
||||||
|
- M5:memo delete 物理删除(此前只标记完成)
|
||||||
|
- M6:PromptManager.reload 原子替换(并发读者不再读到空/半截列表)
|
||||||
|
- L1:read_file 的 sed 参数强转 int(防引号注入)
|
||||||
|
- L2:set_proxy 落盘失败如实报告(不再静默报"已保存")
|
||||||
|
- L3:SSE 非 UTF-8 行不中断流(errors=replace)
|
||||||
|
- L4:persona 路径校验解析符号链接(软链越界拒绝)
|
||||||
|
- L5:1MB 截断不再产生半个字符(�)
|
||||||
|
- L6:compressor 空内容压缩不再除零
|
||||||
|
- L7:InstallSkillTool 指引命令路径引用(防拆词/注入)
|
||||||
|
- L8:ask_user 缺参返回明确错误(不再 TypeError)
|
||||||
|
- L10:web 工具仅允许 http/https(file:// 等拒绝)
|
||||||
|
- L11:registry 日志打码改包含匹配(apiKey/access_token 等变体)
|
||||||
|
- L12:FailoverClient 日志 Bearer 令牌脱敏
|
||||||
|
|
||||||
|
M1(/reload_prompts 移线程池)/M2(技能扫描启动前预热)为 async WebSocket /
|
||||||
|
启动路径,依赖事件循环与真实连接对象,由既有 WebSocket 测试面与启动逻辑回归。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
|
||||||
|
if "websockets" not in sys.modules:
|
||||||
|
sys.modules["websockets"] = types.ModuleType("websockets")
|
||||||
|
|
||||||
|
import aibox # noqa: E402
|
||||||
|
from aibox import DeepSeekChat, TaskScheduler, run_full_subagent # noqa: E402
|
||||||
|
|
||||||
|
from llm_client import DeepSeekClient, FailoverClient, _sanitize_log_msg # noqa: E402
|
||||||
|
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
|
||||||
|
from subsystems.prompt_manager import PromptManager # noqa: E402
|
||||||
|
from subsystems.conversation_compressor import ConversationCompressor # noqa: E402
|
||||||
|
from common import constants # noqa: E402
|
||||||
|
|
||||||
|
from test_conversation_compressor import FakeLLMClient # noqa: E402
|
||||||
|
from test_once_mode import _make_ai # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_sub_chat():
|
||||||
|
fake = mock.MagicMock()
|
||||||
|
fake.think_and_respond = mock.MagicMock(return_value="任务完成报告")
|
||||||
|
fake.stats = {}
|
||||||
|
fake.messages = []
|
||||||
|
fake._cancel_event = threading.Event()
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== H1:子代理取消事件清除回归 ====================
|
||||||
|
|
||||||
|
class TestH1SubagentCancelClear:
|
||||||
|
def test_shared_event_not_cleared_on_first_think(self):
|
||||||
|
"""共享父事件后首轮 think 不清除事件——父级已置位的取消信号不被吞掉"""
|
||||||
|
ai = _make_ai()
|
||||||
|
parent_event = threading.Event()
|
||||||
|
parent_event.set()
|
||||||
|
ai._cancel_event = parent_event
|
||||||
|
ai._shared_cancel_event = True
|
||||||
|
with pytest.raises(InterruptedError):
|
||||||
|
ai._think_and_respond_impl("hi")
|
||||||
|
assert parent_event.is_set()
|
||||||
|
|
||||||
|
def test_own_event_still_cleared_on_first_think(self):
|
||||||
|
"""未共享(普通会话)时首轮仍清除自身事件——原语义不回归"""
|
||||||
|
import contextlib
|
||||||
|
ai = _make_ai()
|
||||||
|
ai._cancel_event.set()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
ai._think_and_respond_impl("hi")
|
||||||
|
assert not ai._cancel_event.is_set()
|
||||||
|
|
||||||
|
def test_subagent_not_started_when_parent_already_cancelled(self):
|
||||||
|
"""父事件已置位 → 直接返回取消提示,不启动子代理"""
|
||||||
|
fake = _fake_sub_chat()
|
||||||
|
parent_event = threading.Event()
|
||||||
|
parent_event.set()
|
||||||
|
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
|
||||||
|
mock.patch("aibox.create_llm_client"), \
|
||||||
|
mock.patch("aibox.SubprocessCommandExecutor"):
|
||||||
|
result = run_full_subagent("r", "t", None, cancel_event=parent_event)
|
||||||
|
assert "取消" in result
|
||||||
|
fake.think_and_respond.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== M3:调度器任务超时 ====================
|
||||||
|
|
||||||
|
class _BlockingAI:
|
||||||
|
"""think 卡死直到被 cancel 的伪 AI(模拟 LLM 挂起)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cancelled = threading.Event()
|
||||||
|
self.cancel_called = 0
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.cancel_called += 1
|
||||||
|
self.cancelled.set()
|
||||||
|
|
||||||
|
def think_and_respond(self, input_text, output_callback=None,
|
||||||
|
is_continuation=False):
|
||||||
|
self.cancelled.wait(timeout=15)
|
||||||
|
raise InterruptedError("用户中断")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMemoDB:
|
||||||
|
def get_memo(self, memo_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TestM3SchedulerTimeout:
|
||||||
|
def test_stuck_task_times_out_and_scheduler_continues(self):
|
||||||
|
from aibox import ConfigManager
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
scheduler = TaskScheduler(loop=loop, logger=None)
|
||||||
|
fake_ai = _BlockingAI()
|
||||||
|
memo = {"id": 7, "title": "卡死任务", "content": "内容", "trigger_count": 0}
|
||||||
|
orig_get = ConfigManager.get
|
||||||
|
|
||||||
|
def fake_get(self, key, default=None):
|
||||||
|
# 只把任务超时压到 0.3s,其余配置读取走原实现(防止
|
||||||
|
# DATETIME_FORMAT 等属性读到错误类型)
|
||||||
|
if key == "scheduler.task_timeout":
|
||||||
|
return 0.3
|
||||||
|
return orig_get(self, key, default)
|
||||||
|
|
||||||
|
with mock.patch.object(ConfigManager, "get", fake_get), \
|
||||||
|
mock.patch.object(scheduler, "_create_task_ai", return_value=fake_ai):
|
||||||
|
result = loop.run_until_complete(
|
||||||
|
scheduler.execute_task_as_conversation(memo, _FakeMemoDB()))
|
||||||
|
assert "超时" in result
|
||||||
|
assert fake_ai.cancel_called >= 1
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== M4/M5:记忆上限与任务删除 ====================
|
||||||
|
|
||||||
|
class TestM4M5Db:
|
||||||
|
def test_recent_memories_none_capped(self, tmp_path, monkeypatch):
|
||||||
|
"""limit=None 不再全表加载,使用默认上限(对齐 search_memories)"""
|
||||||
|
from test_conversation_compressor import patch_const
|
||||||
|
db = MemoryDatabase(db_path=str(tmp_path / "m.db"))
|
||||||
|
for i in range(7):
|
||||||
|
db.add_memory(topic=f"t{i}", summary=f"s{i}", content="c",
|
||||||
|
start_time="2026-08-18 00:00:00",
|
||||||
|
end_time="2026-08-18 00:00:01", importance=1)
|
||||||
|
patch_const(monkeypatch, "MEMORY_MAX_SEARCH_RESULTS", 5)
|
||||||
|
res = db.get_recent_memories(limit=None)
|
||||||
|
assert len(res) == 5
|
||||||
|
|
||||||
|
def test_delete_memo_physically_removes(self, tmp_path):
|
||||||
|
"""delete_memo 真实删除行(此前 complete_memo 只标记完成)"""
|
||||||
|
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
|
||||||
|
mid = db.add_memo(title="待删任务", content="内容")
|
||||||
|
assert db.get_memo(mid) is not None
|
||||||
|
assert db.delete_memo(mid) is True
|
||||||
|
assert db.get_memo(mid) is None
|
||||||
|
assert db.delete_memo(mid) is False # 已不存在
|
||||||
|
|
||||||
|
def test_memo_tool_delete_uses_physical_delete(self, tmp_path):
|
||||||
|
"""MemoTool delete 走 delete_memo:行消失而非仅标记完成"""
|
||||||
|
from tools import MemoTool
|
||||||
|
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
|
||||||
|
mid = db.add_memo(title="重复任务", content="内容", repeat_type="daily")
|
||||||
|
tool = MemoTool(db, None)
|
||||||
|
result = tool.execute(action="delete", memo_id=mid, confirm=True)
|
||||||
|
assert "已删除" in result
|
||||||
|
assert db.get_memo(mid) is None
|
||||||
|
stats = db.get_memo_stats()
|
||||||
|
assert stats["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== M6:PromptManager 原子 reload ====================
|
||||||
|
|
||||||
|
class TestM6PromptReloadAtomic:
|
||||||
|
def test_reload_builds_new_list_then_swaps(self):
|
||||||
|
"""reload 在本地新列表构建完成后才原子替换——读者始终看到完整列表"""
|
||||||
|
pm = PromptManager()
|
||||||
|
old_list = pm._prompts
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_load(target):
|
||||||
|
seen.append(target)
|
||||||
|
target.append({"file": "X_test.md", "content": "y"})
|
||||||
|
|
||||||
|
with mock.patch.object(pm, "_load_prompts", side_effect=fake_load):
|
||||||
|
n = pm.reload()
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert seen[0] is not old_list # 构建用的是新列表
|
||||||
|
assert pm._prompts is seen[0] # 完成后才替换
|
||||||
|
assert n == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L1:read_file 参数强转 ====================
|
||||||
|
|
||||||
|
class TestL1ReadFileCoercion:
|
||||||
|
def test_non_integer_lines_rejected(self, tmp_path):
|
||||||
|
from tools import ReadFileTool
|
||||||
|
f = tmp_path / "f.txt"
|
||||||
|
f.write_text("line1\nline2\n", encoding="utf-8")
|
||||||
|
tool = ReadFileTool()
|
||||||
|
r = tool.execute(filepath=str(f), start_line="1'; rm -rf /; echo '",
|
||||||
|
end_line=5)
|
||||||
|
assert "参数错误" in r
|
||||||
|
|
||||||
|
def test_valid_integers_still_work(self, tmp_path):
|
||||||
|
from tools import ReadFileTool
|
||||||
|
f = tmp_path / "f.txt"
|
||||||
|
f.write_text("line1\nline2\n", encoding="utf-8")
|
||||||
|
tool = ReadFileTool()
|
||||||
|
r = tool.execute(filepath=str(f), start_line=1, end_line=2)
|
||||||
|
assert "sed -n" in r and "参数错误" not in r
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L2:set_proxy 落盘失败如实报告 ====================
|
||||||
|
|
||||||
|
class TestL2ProxySaveFailure:
|
||||||
|
def test_set_proxy_returns_false_on_save_failure(self, tmp_path):
|
||||||
|
from common import ConfigManager
|
||||||
|
cm = ConfigManager()
|
||||||
|
cm._config_path = str(tmp_path / "config.json")
|
||||||
|
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
|
||||||
|
ok = cm.set_proxy("127.0.0.1", 1080, save=True)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
def test_proxy_tool_reports_save_failure(self, tmp_path):
|
||||||
|
from tools import SetProxyTool
|
||||||
|
tool = SetProxyTool(None)
|
||||||
|
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
|
||||||
|
r = tool.execute(host="127.0.0.1", port=1080, save=True,
|
||||||
|
skip_check=True, enabled=True)
|
||||||
|
assert "保存失败" in r
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L3:SSE 非 UTF-8 行不中断流 ====================
|
||||||
|
|
||||||
|
class TestL3SseDecode:
|
||||||
|
def test_non_utf8_line_does_not_break_stream(self):
|
||||||
|
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
|
||||||
|
resp = mock.MagicMock()
|
||||||
|
resp.status_code = 200
|
||||||
|
resp.iter_lines.return_value = [
|
||||||
|
b"data: \xff\xfe",
|
||||||
|
b'data: {"choices":[{"delta":{"content":"ok"}}]}',
|
||||||
|
b"data: [DONE]",
|
||||||
|
]
|
||||||
|
with mock.patch("llm_client.requests.post", return_value=resp):
|
||||||
|
events = list(c.stream_chat([{"role": "user", "content": "hi"}]))
|
||||||
|
assert any(e.get("type") == "content" and e.get("text") == "ok"
|
||||||
|
for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L4:persona 软链越界 ====================
|
||||||
|
|
||||||
|
class TestL4PersonaSymlink:
|
||||||
|
def test_symlink_outside_skills_dir_rejected(self, tmp_path, monkeypatch):
|
||||||
|
from test_conversation_compressor import patch_const
|
||||||
|
from aibox import ExpressEmotionTool
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
outside = tmp_path / "outside.md"
|
||||||
|
outside.write_text("秘密内容", encoding="utf-8")
|
||||||
|
(skills_dir / "evil.md").symlink_to(outside)
|
||||||
|
tool = ExpressEmotionTool()
|
||||||
|
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
|
||||||
|
content = tool._load_persona("evil", "")
|
||||||
|
assert content == ""
|
||||||
|
|
||||||
|
def test_normal_persona_still_loads(self, tmp_path, monkeypatch):
|
||||||
|
from test_conversation_compressor import patch_const
|
||||||
|
from aibox import ExpressEmotionTool
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
(skills_dir / "happy.md").write_text("快乐人格", encoding="utf-8")
|
||||||
|
tool = ExpressEmotionTool()
|
||||||
|
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
|
||||||
|
content = tool._load_persona("happy", "")
|
||||||
|
assert content == "快乐人格"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L5:截断无半个字符 ====================
|
||||||
|
|
||||||
|
class TestL5NoPartialChar:
|
||||||
|
def test_truncated_output_has_no_replacement_char(self):
|
||||||
|
from subsystems.executors import SubprocessCommandExecutor
|
||||||
|
ex = SubprocessCommandExecutor()
|
||||||
|
r = ex.execute("python3 -c \"print('中' * 600000)\"", timeout=60)
|
||||||
|
assert "截断" in r
|
||||||
|
assert "\ufffd" not in r
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "posix", reason="Linux 进程组语义")
|
||||||
|
def test_grandchild_holding_pipe_is_reaped(self):
|
||||||
|
"""`(sleep N &) ; echo done` 形态:shell 退出后孙进程持管道写端,
|
||||||
|
宽限窗口到期强杀进程组——不悬挂 reader、孙进程被清理"""
|
||||||
|
import subprocess as sp
|
||||||
|
from subsystems.executors import SubprocessCommandExecutor
|
||||||
|
marker = f"sleep {12340}"
|
||||||
|
ex = SubprocessCommandExecutor()
|
||||||
|
r = ex.execute(f"({marker} &) ; echo done", timeout=8)
|
||||||
|
assert "done" in r
|
||||||
|
leftover = sp.run(["pgrep", "-f", marker],
|
||||||
|
capture_output=True, text=True).stdout.strip()
|
||||||
|
assert leftover == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L6:compressor 除零 ====================
|
||||||
|
|
||||||
|
class TestL6CompressorDivZero:
|
||||||
|
def test_empty_content_compress_no_div0(self, monkeypatch):
|
||||||
|
comp = ConversationCompressor(llm_client=FakeLLMClient(),
|
||||||
|
logger=None)
|
||||||
|
from test_conversation_compressor import patch_const
|
||||||
|
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
|
||||||
|
messages = [{"role": "system", "content": "SYS"}] + [
|
||||||
|
{"role": "user" if i % 2 == 0 else "assistant", "content": ""}
|
||||||
|
for i in range(15)
|
||||||
|
]
|
||||||
|
result = comp.compress_conversation(messages)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L7:InstallSkillTool 路径引用 ====================
|
||||||
|
|
||||||
|
class TestL7SkillPathQuoting:
|
||||||
|
def test_guide_commands_quote_paths(self):
|
||||||
|
from tools import InstallSkillTool
|
||||||
|
tool = InstallSkillTool()
|
||||||
|
r = tool.execute(skill_path="/tmp/a b; rm -rf /")
|
||||||
|
assert "ls -l '/tmp/a b; rm -rf /'" in r
|
||||||
|
assert "cp '/tmp/a b; rm -rf /' " in r
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L8:ask_user 缺参 ====================
|
||||||
|
|
||||||
|
class TestL8AskUser:
|
||||||
|
def test_missing_question_returns_error(self):
|
||||||
|
from tools import AskUserTool
|
||||||
|
tool = AskUserTool()
|
||||||
|
assert "需要提供问题" in tool.execute()
|
||||||
|
|
||||||
|
def test_question_passed_through(self):
|
||||||
|
from tools import AskUserTool
|
||||||
|
tool = AskUserTool()
|
||||||
|
assert "测试问题" in tool.execute(question="测试问题")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L10:web 工具 scheme 白名单 ====================
|
||||||
|
|
||||||
|
class TestL10WebScheme:
|
||||||
|
def test_file_url_rejected(self):
|
||||||
|
from tools import SimpleWebCrawlerTool
|
||||||
|
tool = SimpleWebCrawlerTool()
|
||||||
|
r = tool.execute(action="get", url="file:///etc/shadow")
|
||||||
|
assert "仅支持 http/https" in r
|
||||||
|
|
||||||
|
def test_http_url_accepted(self):
|
||||||
|
from tools import SimpleWebCrawlerTool
|
||||||
|
tool = SimpleWebCrawlerTool()
|
||||||
|
r = tool.execute(action="get", url="https://example.com")
|
||||||
|
assert "curl" in r and "仅支持" not in r
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L11:registry 打码包含匹配 ====================
|
||||||
|
|
||||||
|
class TestL11SanitizeSubstring:
|
||||||
|
def test_variant_keys_redacted(self):
|
||||||
|
from tools.registry import _sanitize_kwargs
|
||||||
|
out = _sanitize_kwargs({
|
||||||
|
"apiKey": "x", "access_token": "y", "passwd": "z",
|
||||||
|
"url": "https://ok",
|
||||||
|
})
|
||||||
|
assert out == {"apiKey": "***", "access_token": "***",
|
||||||
|
"passwd": "***", "url": "https://ok"}
|
||||||
|
|
||||||
|
def test_normal_keys_untouched(self):
|
||||||
|
from tools.registry import _sanitize_kwargs
|
||||||
|
out = _sanitize_kwargs({"filepath": "/tmp/a", "question": "hi"})
|
||||||
|
assert out == {"filepath": "/tmp/a", "question": "hi"}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== L12:failover 日志 Bearer 脱敏 ====================
|
||||||
|
|
||||||
|
class TestL12LogSanitize:
|
||||||
|
def test_sanitize_msg_redacts_bearer(self):
|
||||||
|
msg = "瞬时故障: HTTP 401: Bearer sk-abc123xyz"
|
||||||
|
out = _sanitize_log_msg(msg)
|
||||||
|
assert "sk-abc123xyz" not in out
|
||||||
|
assert "Bearer ***" in out
|
||||||
|
|
||||||
|
def test_failover_log_redacts_bearer(self):
|
||||||
|
logger = mock.MagicMock()
|
||||||
|
c = FailoverClient([], logger=logger)
|
||||||
|
c._log("warning", "瞬时故障(Bearer sk-secret456)")
|
||||||
|
logged = logger.warning.call_args[0][0]
|
||||||
|
assert "sk-secret456" not in logged
|
||||||
|
assert "Bearer ***" in logged
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""aibox 第四轮审计 Batch I 修复回归测试(2026-08-18,审计报告 §十二)
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- N9:prompt 写入内容长度上限(持久化注入面约束)
|
||||||
|
- Q7b:load_conversation 越界路径拒绝(realpath 前缀校验)
|
||||||
|
- F5:run_light_subagent 取消传播(流式可中断 + 父已取消早退)
|
||||||
|
- Q5:提醒时间格式异常的任务按到期返回(不再静默丢弃)
|
||||||
|
- Q8:对话文件原子写(无 .tmp 残留、JSON 完整)
|
||||||
|
- F6:WebSocketHandler 命令专用线程池存在且可释放
|
||||||
|
- limit=0 语义修正(None 才不加 LIMIT)
|
||||||
|
|
||||||
|
Q4(busy_timeout 10s)为时序性行为(默认 5s 等待已能通过短竞争),
|
||||||
|
不做慢速单测;F9(--once 硬超时)为进程级行为,由 CLI 集成面覆盖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
|
||||||
|
if "websockets" not in sys.modules:
|
||||||
|
sys.modules["websockets"] = types.ModuleType("websockets")
|
||||||
|
|
||||||
|
import aibox # noqa: E402
|
||||||
|
from aibox import run_light_subagent # noqa: E402
|
||||||
|
|
||||||
|
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
|
||||||
|
from subsystems.prompt_manager import PromptManager # noqa: E402
|
||||||
|
from subsystems.conversation_history import ConversationHistory # noqa: E402
|
||||||
|
from tools.prompt_tools import ( # noqa: E402
|
||||||
|
UpdateIdentityTool,
|
||||||
|
UpdateSoulTool,
|
||||||
|
SaveMemoryAndEndConversationTool,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== N9:prompt 写入长度上限 ====================
|
||||||
|
|
||||||
|
class TestN9PromptContentCap:
|
||||||
|
def test_update_identity_oversized_rejected(self, tmp_path, monkeypatch):
|
||||||
|
"""manager 层兜底:超长身份内容拒绝写入"""
|
||||||
|
pm = PromptManager()
|
||||||
|
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
|
||||||
|
assert pm.update_identity("x" * (PromptManager.MAX_CONTENT_LENGTH + 1)) is False
|
||||||
|
assert not (tmp_path / pm.IDENTITY_FILE).exists()
|
||||||
|
|
||||||
|
def test_update_identity_tool_friendly_error(self, tmp_path):
|
||||||
|
"""工具层:超长内容返回明确提示而非写入"""
|
||||||
|
pm = PromptManager()
|
||||||
|
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
|
||||||
|
tool = UpdateIdentityTool(pm, None)
|
||||||
|
r = tool.execute(content="x" * (PromptManager.MAX_CONTENT_LENGTH + 1))
|
||||||
|
assert "内容过长" in r
|
||||||
|
|
||||||
|
def test_update_soul_tool_friendly_error(self, tmp_path):
|
||||||
|
pm = PromptManager()
|
||||||
|
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
|
||||||
|
tool = UpdateSoulTool(pm, None)
|
||||||
|
r = tool.execute(content="y" * (PromptManager.MAX_CONTENT_LENGTH + 1))
|
||||||
|
assert "内容过长" in r
|
||||||
|
|
||||||
|
def test_save_memory_status_oversized_rejected(self, tmp_path):
|
||||||
|
"""save_memory_and_end_conversation:状态内容超长拒绝"""
|
||||||
|
pm = PromptManager()
|
||||||
|
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
|
||||||
|
tool = SaveMemoryAndEndConversationTool(
|
||||||
|
exit_callback=lambda *a: None,
|
||||||
|
memory_db=None, prompt_manager=pm, logger=None)
|
||||||
|
r = tool.execute(summary="这是一段足够长的对话总结" * 20,
|
||||||
|
current_status="x" * PromptManager.MAX_CONTENT_LENGTH)
|
||||||
|
assert "状态内容过长" in r
|
||||||
|
|
||||||
|
def test_normal_content_still_written(self, tmp_path):
|
||||||
|
pm = PromptManager()
|
||||||
|
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
|
||||||
|
assert pm.update_identity("正常身份内容") is True
|
||||||
|
assert "正常身份内容" in (tmp_path / pm.IDENTITY_FILE).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Q7b:load_conversation 路径校验 ====================
|
||||||
|
|
||||||
|
class TestQ7bLoadConversationBoundary:
|
||||||
|
def test_traversal_rejected(self, tmp_path):
|
||||||
|
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
|
||||||
|
secret = tmp_path / "secret.json"
|
||||||
|
secret.write_text('{"leak": true}', encoding="utf-8")
|
||||||
|
assert h.load_conversation("../secret.json") is None
|
||||||
|
|
||||||
|
def test_normal_file_still_loads(self, tmp_path):
|
||||||
|
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
|
||||||
|
h.current_conversation = {
|
||||||
|
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
|
||||||
|
"topic": "t", "messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"tool_calls": [], "stats": {},
|
||||||
|
}
|
||||||
|
h.save_current_conversation()
|
||||||
|
h.archive_current_conversation()
|
||||||
|
archives = list(h.archive_dir.glob("conversation_*.json"))
|
||||||
|
assert archives
|
||||||
|
loaded = h.load_conversation(archives[0].name)
|
||||||
|
assert loaded is not None and loaded["id"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== F5:run_light_subagent 取消传播 ====================
|
||||||
|
|
||||||
|
class _BlockingLightClient:
|
||||||
|
"""流式产出首段后卡住,直到 cancel() 才中断的伪 LLM 客户端"""
|
||||||
|
|
||||||
|
model = "fake-model"
|
||||||
|
default_temperature = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cancelled = threading.Event()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.cancelled.set()
|
||||||
|
|
||||||
|
def stream_chat(self, messages, **kwargs):
|
||||||
|
yield {"type": "content", "text": "开头"}
|
||||||
|
while not self.cancelled.wait(timeout=0.1):
|
||||||
|
pass
|
||||||
|
raise RuntimeError("连接已中断(cancel)")
|
||||||
|
|
||||||
|
|
||||||
|
class TestF5LightSubagentCancel:
|
||||||
|
def test_cancelled_before_start_returns_early(self):
|
||||||
|
"""父事件已置位 → 直接返回取消提示,不创建客户端"""
|
||||||
|
parent = threading.Event()
|
||||||
|
parent.set()
|
||||||
|
with mock.patch("aibox.create_llm_client") as mk:
|
||||||
|
r = run_light_subagent("r", "t", None, cancel_event=parent)
|
||||||
|
assert "取消" in r
|
||||||
|
mk.assert_not_called()
|
||||||
|
|
||||||
|
def test_cancel_during_stream_aborts(self):
|
||||||
|
"""流式中父级取消 → 观察者线程 cancel 客户端 → 立即中断返回"""
|
||||||
|
parent = threading.Event()
|
||||||
|
fake = _BlockingLightClient()
|
||||||
|
with mock.patch("aibox.create_llm_client", return_value=fake):
|
||||||
|
result_holder = {}
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
result_holder["r"] = run_light_subagent(
|
||||||
|
"r", "t", None, cancel_event=parent)
|
||||||
|
|
||||||
|
th = threading.Thread(target=_run, daemon=True)
|
||||||
|
th.start()
|
||||||
|
time.sleep(0.5) # 等待首段产出、观察者就绪
|
||||||
|
parent.set()
|
||||||
|
th.join(timeout=5)
|
||||||
|
assert not th.is_alive() # 未挂死
|
||||||
|
assert "失败" in result_holder.get("r", "")
|
||||||
|
|
||||||
|
def test_express_emotion_registered_with_parent_event(self):
|
||||||
|
"""DeepSeekChat 注册 ExpressEmotionTool 时注入自身取消事件"""
|
||||||
|
from test_once_mode import _make_ai
|
||||||
|
ai = _make_ai()
|
||||||
|
tool = next((t for t in ai.tool_registry.get_all_tools()
|
||||||
|
if t.get_name() == "express_emotion"), None)
|
||||||
|
assert tool is not None
|
||||||
|
assert tool.cancel_event is ai._cancel_event
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Q5:格式异常任务不丢弃 ====================
|
||||||
|
|
||||||
|
class TestQ5MalformedReminderTime:
|
||||||
|
def test_malformed_time_task_returned_as_due(self, tmp_path):
|
||||||
|
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
|
||||||
|
mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间")
|
||||||
|
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
|
||||||
|
assert any(m["id"] == mid for m in pending)
|
||||||
|
|
||||||
|
def test_normal_pending_still_filtered(self, tmp_path):
|
||||||
|
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
|
||||||
|
db.add_memo(title="未来任务", content="c", reminder_time="2099-01-01 00:00:00")
|
||||||
|
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
|
||||||
|
assert pending == []
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Q8:对话文件原子写 ====================
|
||||||
|
|
||||||
|
class TestQ8AtomicWrites:
|
||||||
|
def test_no_tmp_leftover_and_valid_json(self, tmp_path):
|
||||||
|
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
|
||||||
|
h.current_conversation = {
|
||||||
|
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
|
||||||
|
"topic": "t", "messages": [], "tool_calls": [], "stats": {},
|
||||||
|
}
|
||||||
|
h.save_current_conversation()
|
||||||
|
assert h.current_file.exists() # 文件名可能被用户配置覆盖,用实例属性
|
||||||
|
import json
|
||||||
|
json.loads(h.current_file.read_text(encoding="utf-8")) # 不抛即合法 JSON
|
||||||
|
h.archive_current_conversation() # 归档后 current 文件按设计移除
|
||||||
|
assert list(Path(tmp_path).rglob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== F6:WS 命令专用线程池 ====================
|
||||||
|
|
||||||
|
class TestF6CmdExecutor:
|
||||||
|
def test_handler_has_dedicated_executor(self):
|
||||||
|
from aibox import WebSocketHandler
|
||||||
|
h = WebSocketHandler(ai_manager=None)
|
||||||
|
assert h._cmd_executor is not None
|
||||||
|
h.shutdown()
|
||||||
|
assert h._cmd_executor is None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== limit=0 语义 ====================
|
||||||
|
|
||||||
|
class TestLimitZeroSemantics:
|
||||||
|
def test_pending_memos_limit_zero_returns_none(self, tmp_path):
|
||||||
|
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
|
||||||
|
db.add_memo(title="t", content="c", reminder_time="2026-01-01 00:00:00")
|
||||||
|
assert db.get_pending_memos(limit=0, before_time="2026-08-18 00:00:00") == []
|
||||||
|
|
||||||
|
def test_immediate_memos_limit_zero_returns_none(self, tmp_path):
|
||||||
|
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
|
||||||
|
db.add_memo(title="t", content="c", is_immediate=True)
|
||||||
|
assert db.get_immediate_memos(limit=0) == []
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""aibox 模型接入点管理工具(model_config.py)单元测试(2026-08-18)
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- 列表归一化(默认标记/免密/失败转移展示字段)
|
||||||
|
- 添加:字段校验(档案名/地址/模型名/重复)、Key 两种方式、温度/超时钳制、
|
||||||
|
失败转移引用校验、设为默认、落盘(含脱敏)
|
||||||
|
- 删除:物理移除、fallback 悬空引用清理、默认档案标记清除
|
||||||
|
- 连通性验证:成功 / 失败 / 档案不存在
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if "websockets" not in sys.modules:
|
||||||
|
sys.modules["websockets"] = types.ModuleType("websockets")
|
||||||
|
|
||||||
|
import model_config # noqa: E402
|
||||||
|
from common import ConfigManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(tmp_path, initial: dict = None) -> ConfigManager:
|
||||||
|
"""构造指向临时配置文件的 ConfigManager(避免碰真实 ~/.aibox)"""
|
||||||
|
cm = ConfigManager()
|
||||||
|
cm._config_path = str(tmp_path / "config.json")
|
||||||
|
cm._config = initial if initial is not None else {}
|
||||||
|
return cm
|
||||||
|
|
||||||
|
|
||||||
|
def _base_models():
|
||||||
|
return {
|
||||||
|
"models": {
|
||||||
|
"deepseek": {
|
||||||
|
"api_url": "https://api.deepseek.com/v1/chat/completions",
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"api_key_env": "DEEPSEEK_API_KEY",
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"api_url": "http://localhost:11434/v1/chat/completions",
|
||||||
|
"model": "qwen3",
|
||||||
|
"api_key_env": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"api": {"default_model": "deepseek"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 列表 ====================
|
||||||
|
|
||||||
|
class TestListProfiles:
|
||||||
|
def test_list_normalized(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
profiles = model_config.list_profiles(cm)
|
||||||
|
assert len(profiles) == 2
|
||||||
|
ds = next(p for p in profiles if p["name"] == "deepseek")
|
||||||
|
assert ds["is_default"] is True
|
||||||
|
assert ds["api_key_env"] == "DEEPSEEK_API_KEY"
|
||||||
|
local = next(p for p in profiles if p["name"] == "local")
|
||||||
|
assert local["api_key_env"] == ""
|
||||||
|
assert local["is_default"] is False
|
||||||
|
|
||||||
|
def test_list_empty_models_section(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, {})
|
||||||
|
assert model_config.list_profiles(cm) == []
|
||||||
|
|
||||||
|
def test_list_ignores_malformed_entries(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, {"models": {"bad": "not-a-dict"}})
|
||||||
|
assert model_config.list_profiles(cm) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 校验 ====================
|
||||||
|
|
||||||
|
class TestValidateNewProfile:
|
||||||
|
def test_name_rules(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
assert model_config.validate_new_profile(cm, "", "https://x", "m")
|
||||||
|
assert model_config.validate_new_profile(cm, "坏名字!", "https://x", "m")
|
||||||
|
assert model_config.validate_new_profile(cm, "deepseek", "https://x", "m") # 已存在
|
||||||
|
assert model_config.validate_new_profile(cm, "ok_name", "https://x", "m") is None
|
||||||
|
|
||||||
|
def test_url_scheme_and_model_required(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
assert model_config.validate_new_profile(cm, "kimi", "ftp://x", "m")
|
||||||
|
assert model_config.validate_new_profile(cm, "kimi", "https://x", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 添加 ====================
|
||||||
|
|
||||||
|
class TestAddProfile:
|
||||||
|
def test_add_with_env_key_and_default(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
msgs = model_config.add_profile(
|
||||||
|
cm, "kimi", "https://api.moonshot.cn/v1/chat/completions",
|
||||||
|
"moonshot-v1-8k", api_key_env="KIMI_KEY",
|
||||||
|
display_name="Kimi", set_default=True)
|
||||||
|
assert any("已添加" in m for m in msgs)
|
||||||
|
assert any("设为默认" in m for m in msgs)
|
||||||
|
assert cm.get("api.default_model") == "kimi"
|
||||||
|
assert cm.get("models.kimi.api_key_env") == "KIMI_KEY"
|
||||||
|
assert cm.get("models.kimi.api_url").startswith("https://")
|
||||||
|
# 落盘文件存在且可解析
|
||||||
|
import json
|
||||||
|
disk = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||||
|
assert disk["models"]["kimi"]["model"] == "moonshot-v1-8k"
|
||||||
|
|
||||||
|
def test_add_without_key_is_exempt_endpoint(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, {})
|
||||||
|
msgs = model_config.add_profile(
|
||||||
|
cm, "local", "http://localhost:11434/v1/chat/completions",
|
||||||
|
"qwen3", api_key_env="")
|
||||||
|
assert any("免密" in m for m in msgs)
|
||||||
|
assert cm.get("models.local.api_key_env") == ""
|
||||||
|
|
||||||
|
def test_add_rejects_invalid_fallback(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
msgs = model_config.add_profile(
|
||||||
|
cm, "kimi", "https://x/v1", "m1",
|
||||||
|
fallback=["ghost", "deepseek"])
|
||||||
|
assert any("失败转移" in m for m in msgs)
|
||||||
|
assert cm.get("models.kimi") is None # 未写入
|
||||||
|
|
||||||
|
def test_add_temperature_timeout_clamped(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, {})
|
||||||
|
model_config.add_profile(cm, "a", "https://x/v1", "m1",
|
||||||
|
temperature=9.9, timeout=1)
|
||||||
|
assert cm.get("models.a.temperature") == 2.0
|
||||||
|
assert cm.get("models.a.timeout") == 5
|
||||||
|
|
||||||
|
def test_add_invalid_temperature_rejected(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, {})
|
||||||
|
msgs = model_config.add_profile(cm, "a", "https://x/v1", "m1",
|
||||||
|
temperature="abc")
|
||||||
|
assert any("温度" in m for m in msgs)
|
||||||
|
assert cm.get("models.a") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 删除 ====================
|
||||||
|
|
||||||
|
class TestDeleteProfile:
|
||||||
|
def test_delete_cleans_fallback_refs(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, {
|
||||||
|
"models": {
|
||||||
|
"main": {"api_url": "https://a", "model": "m1",
|
||||||
|
"fallback": ["backup"]},
|
||||||
|
"backup": {"api_url": "https://b", "model": "m2"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
msgs = model_config.delete_profile(cm, "backup")
|
||||||
|
assert any("已删除" in m for m in msgs)
|
||||||
|
assert any("失败转移引用" in m for m in msgs)
|
||||||
|
assert cm.get("models.main.fallback") == []
|
||||||
|
|
||||||
|
def test_delete_default_clears_default_flag(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
model_config.delete_profile(cm, "deepseek")
|
||||||
|
assert cm.get("api.default_model") is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
msgs = model_config.delete_profile(cm, "ghost")
|
||||||
|
assert any("不存在" in m for m in msgs)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 连通性验证 ====================
|
||||||
|
|
||||||
|
class TestTestProfile:
|
||||||
|
def test_connectivity_ok(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
fake = mock.MagicMock()
|
||||||
|
fake.chat_completion.return_value = "pong"
|
||||||
|
with mock.patch("llm_client.create_llm_client", return_value=fake):
|
||||||
|
r = model_config.test_profile(cm, "deepseek")
|
||||||
|
assert "连通正常" in r and "pong" in r
|
||||||
|
|
||||||
|
def test_connectivity_failure(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
fake = mock.MagicMock()
|
||||||
|
fake.chat_completion.side_effect = RuntimeError("连接被拒绝")
|
||||||
|
with mock.patch("llm_client.create_llm_client", return_value=fake):
|
||||||
|
r = model_config.test_profile(cm, "deepseek")
|
||||||
|
assert "验证失败" in r and "连接被拒绝" in r
|
||||||
|
|
||||||
|
def test_connectivity_missing_profile(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, _base_models())
|
||||||
|
assert "不存在" in model_config.test_profile(cm, "ghost")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 落盘失败感知 ====================
|
||||||
|
|
||||||
|
class TestSaveFailure:
|
||||||
|
def test_add_reports_save_failure(self, tmp_path):
|
||||||
|
cm = _cfg(tmp_path, {})
|
||||||
|
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
|
||||||
|
msgs = model_config.add_profile(
|
||||||
|
cm, "a", "https://x/v1", "m1", api_key_env="K")
|
||||||
|
assert any("保存失败" in m for m in msgs)
|
||||||
@@ -96,6 +96,16 @@ class ReadFileTool(FileToolBase):
|
|||||||
|
|
||||||
# 限制max_lines上限,防止请求超大范围
|
# 限制max_lines上限,防止请求超大范围
|
||||||
max_lines = min(max_lines, 500)
|
max_lines = min(max_lines, 500)
|
||||||
|
# L1(审计 2026-08-18):start/end_line 为 LLM 可控参数,此前未强转
|
||||||
|
# 直拼 sed 单引号模板——LLM 传含引号的字符串可闭合引号注入命令。
|
||||||
|
# 强转 int(非法输入在此拦截)并夹取合理范围。
|
||||||
|
try:
|
||||||
|
start_line = int(start_line)
|
||||||
|
end_line = int(end_line)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "❌ 参数错误: start_line/end_line 必须为整数"
|
||||||
|
start_line = max(1, start_line)
|
||||||
|
end_line = max(0, min(end_line, 10_000_000))
|
||||||
quoted = shlex.quote(abs_path)
|
quoted = shlex.quote(abs_path)
|
||||||
|
|
||||||
# 构造等价的命令行读取命令
|
# 构造等价的命令行读取命令
|
||||||
@@ -320,6 +330,12 @@ class SimpleWebCrawlerTool(Tool):
|
|||||||
if action not in ("get", "text", "title", "links", "search"):
|
if action not in ("get", "text", "title", "links", "search"):
|
||||||
return f"❌ 未知操作: {action}"
|
return f"❌ 未知操作: {action}"
|
||||||
|
|
||||||
|
# L10(审计 2026-08-18):url 为 LLM 可控输入(可被网页内容诱导),
|
||||||
|
# 此前不限 scheme——file:// 可经生成的 curl 指引读取本地文件(SSRF)。
|
||||||
|
# 白名单 http/https 后其余协议直接拒绝。
|
||||||
|
if not url or not str(url).strip().lower().startswith(("http://", "https://")):
|
||||||
|
return "❌ 仅支持 http/https 协议地址(已拦截 file:// 等非网络协议)"
|
||||||
|
|
||||||
quoted_url = shlex.quote(url)
|
quoted_url = shlex.quote(url)
|
||||||
timeout = constants.WEB_TIMEOUT
|
timeout = constants.WEB_TIMEOUT
|
||||||
user_agent = shlex.quote(constants.WEB_USER_AGENT)
|
user_agent = shlex.quote(constants.WEB_USER_AGENT)
|
||||||
|
|||||||
@@ -752,7 +752,9 @@ class MemoTool(Tool):
|
|||||||
|
|
||||||
memo_id = kwargs.get("memo_id")
|
memo_id = kwargs.get("memo_id")
|
||||||
if memo_id:
|
if memo_id:
|
||||||
if self.memo_db.complete_memo(memo_id, force_complete=True):
|
# M5:真实物理删除(此前调 complete_memo 只标记完成,行不删除,
|
||||||
|
# 重复任务被推进而非移除,stats 计数失真)
|
||||||
|
if self.memo_db.delete_memo(memo_id):
|
||||||
return self.format_result(True, f"任务 #{memo_id} 已删除")
|
return self.format_result(True, f"任务 #{memo_id} 已删除")
|
||||||
else:
|
else:
|
||||||
return f"❌ 无法删除任务 #{memo_id}"
|
return f"❌ 无法删除任务 #{memo_id}"
|
||||||
|
|||||||
@@ -39,10 +39,13 @@ class AskUserTool(Tool):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": []
|
# L8:此前 required 为空,LLM 不传 question 时 str + None 抛 TypeError
|
||||||
|
"required": ["question"]
|
||||||
}
|
}
|
||||||
|
|
||||||
def execute(self, question: str, **kwargs) -> str:
|
def execute(self, question: str = None, **kwargs) -> str:
|
||||||
|
if not question:
|
||||||
|
return "❌ 需要提供问题内容(question 参数)"
|
||||||
return (
|
return (
|
||||||
"你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。"
|
"你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。"
|
||||||
)
|
)
|
||||||
@@ -56,6 +56,11 @@ class UpdateIdentityTool(Tool):
|
|||||||
if not content:
|
if not content:
|
||||||
return "❌ 需要提供身份信息内容"
|
return "❌ 需要提供身份信息内容"
|
||||||
|
|
||||||
|
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
|
||||||
|
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
|
||||||
|
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
|
||||||
|
f"身份文件作为系统提示词加载,请精简后重试")
|
||||||
|
|
||||||
if not confirm:
|
if not confirm:
|
||||||
return "❌ 身份更新已取消,如需更新请设置 confirm=true"
|
return "❌ 身份更新已取消,如需更新请设置 confirm=true"
|
||||||
|
|
||||||
@@ -119,6 +124,11 @@ class UpdateSoulTool(Tool):
|
|||||||
if not content:
|
if not content:
|
||||||
return "❌ 需要提供智能体自我认识定义内容"
|
return "❌ 需要提供智能体自我认识定义内容"
|
||||||
|
|
||||||
|
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
|
||||||
|
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
|
||||||
|
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
|
||||||
|
f"灵魂文件作为系统提示词加载,请精简后重试")
|
||||||
|
|
||||||
if not confirm:
|
if not confirm:
|
||||||
return "❌ 自我认识更新已取消,如需更新请设置 confirm=true"
|
return "❌ 自我认识更新已取消,如需更新请设置 confirm=true"
|
||||||
|
|
||||||
@@ -223,6 +233,11 @@ class SaveMemoryAndEndConversationTool(Tool):
|
|||||||
status_content += f"【下一步计划】\n{next_goals}\n\n"
|
status_content += f"【下一步计划】\n{next_goals}\n\n"
|
||||||
status_content += f"【对话总结】\n{summary}"
|
status_content += f"【对话总结】\n{summary}"
|
||||||
|
|
||||||
|
# N9:状态文件作为系统提示词加载,超长拒绝写入(约束注入规模)
|
||||||
|
if len(status_content) > PromptManager.MAX_CONTENT_LENGTH:
|
||||||
|
return (f"❌ 状态内容过长({len(status_content)} > "
|
||||||
|
f"{PromptManager.MAX_CONTENT_LENGTH} 字符),请精简 current_status/summary 后重试")
|
||||||
|
|
||||||
# 更新状态文件
|
# 更新状态文件
|
||||||
status_updated = self.prompt_manager.update_status(status_content)
|
status_updated = self.prompt_manager.update_status(status_content)
|
||||||
|
|
||||||
|
|||||||
+112
-6
@@ -3,18 +3,26 @@
|
|||||||
tools.proxy_tools - 代理配置工具
|
tools.proxy_tools - 代理配置工具
|
||||||
|
|
||||||
抽离自 aibox.py,包含 2 个工具:
|
抽离自 aibox.py,包含 2 个工具:
|
||||||
- SetProxyTool : 设置 SOCKS5 代理
|
- SetProxyTool : 设置 SOCKS5 代理(设置前验证连通性)
|
||||||
- GetProxyTool : 查看当前代理配置
|
- GetProxyTool : 查看当前代理配置
|
||||||
|
|
||||||
依赖:common (ConfigManager) + tools.base.Tool
|
依赖:common (ConfigManager) + tools.base.Tool
|
||||||
不反向依赖 aibox 任何符号。
|
不反向依赖 aibox 任何符号。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict
|
import os
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from common import ConfigManager
|
from common import ConfigManager
|
||||||
from .base import Tool
|
from .base import Tool
|
||||||
|
|
||||||
|
# 探测端点:无内容的轻量响应,验证完整 SOCKS5 握手(含认证)与出网连通性
|
||||||
|
_PROBE_URL = "http://www.gstatic.com/generate_204"
|
||||||
|
# (连接超时, 读超时):验证总耗时上限约 10s,避免阻塞工具调用过久
|
||||||
|
_PROBE_TIMEOUT = (4, 6)
|
||||||
|
|
||||||
|
|
||||||
class SetProxyTool(Tool):
|
class SetProxyTool(Tool):
|
||||||
"""设置 SOCKS5 代理的工具"""
|
"""设置 SOCKS5 代理的工具"""
|
||||||
@@ -25,6 +33,8 @@ class SetProxyTool(Tool):
|
|||||||
def get_description(self) -> str:
|
def get_description(self) -> str:
|
||||||
return """设置智能体的 SOCKS5 代理配置。
|
return """设置智能体的 SOCKS5 代理配置。
|
||||||
调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。
|
调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。
|
||||||
|
启用代理时会先验证连通性(完整 SOCKS5 握手 + 探测请求),验证失败不保存配置;
|
||||||
|
代理暂未启动等场景可用 skip_check=true 跳过验证强制保存。
|
||||||
可随时修改,即时生效。"""
|
可随时修改,即时生效。"""
|
||||||
|
|
||||||
def get_parameters(self) -> Dict:
|
def get_parameters(self) -> Dict:
|
||||||
@@ -37,15 +47,106 @@ class SetProxyTool(Tool):
|
|||||||
"password": {"type": "string", "description": "密码(可选,明文仅本次会话有效,不落盘)"},
|
"password": {"type": "string", "description": "密码(可选,明文仅本次会话有效,不落盘)"},
|
||||||
"password_env": {"type": "string", "description": "存有密码的环境变量名(可选,跨重启持久的推荐方式)"},
|
"password_env": {"type": "string", "description": "存有密码的环境变量名(可选,跨重启持久的推荐方式)"},
|
||||||
"enabled": {"type": "boolean", "description": "是否启用代理,默认 true"},
|
"enabled": {"type": "boolean", "description": "是否启用代理,默认 true"},
|
||||||
"save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"}
|
"save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"},
|
||||||
|
"skip_check": {"type": "boolean", "description": "跳过连通性验证强制保存(默认 false)", "default": False}
|
||||||
},
|
},
|
||||||
"required": ["host", "port"]
|
"required": ["host", "port"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_password(password: Optional[str], password_env: Optional[str]) -> Optional[str]:
|
||||||
|
"""对齐 ConfigManager._resolve_proxy_password:明文优先,否则查环境变量"""
|
||||||
|
if password:
|
||||||
|
return password
|
||||||
|
if password_env:
|
||||||
|
return os.environ.get(password_env) or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_connectivity(self, host: str, port: int,
|
||||||
|
username: Optional[str], password: Optional[str]) -> Tuple[bool, str, str]:
|
||||||
|
"""验证代理连通性:走一次完整 SOCKS5 握手 + 探测请求。
|
||||||
|
|
||||||
|
只做 TCP connect 验不了用户名/密码,必须发真实请求。
|
||||||
|
返回 (ok, error_kind, detail);error_kind 取值:
|
||||||
|
auth_failed / timeout / connect_failed / probe_failed / error
|
||||||
|
"""
|
||||||
|
if username and password:
|
||||||
|
proxy_url = f"socks5://{username}:{password}@{host}:{port}"
|
||||||
|
else:
|
||||||
|
proxy_url = f"socks5://{host}:{port}"
|
||||||
|
try:
|
||||||
|
# trust_env=False:排除系统环境变量代理(http_proxy 等)干扰,只验证目标代理
|
||||||
|
session = requests.Session()
|
||||||
|
session.trust_env = False
|
||||||
|
resp = session.get(
|
||||||
|
_PROBE_URL,
|
||||||
|
proxies={"http": proxy_url, "https": proxy_url},
|
||||||
|
timeout=_PROBE_TIMEOUT,
|
||||||
|
)
|
||||||
|
# generate_204 正常返回 204;部分代理/目标返回 200 也视为连通
|
||||||
|
if resp.status_code in (200, 204):
|
||||||
|
return True, "", f"探测成功 HTTP {resp.status_code}"
|
||||||
|
return False, "probe_failed", f"探测端点返回 HTTP {resp.status_code}"
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
# 含 ConnectTimeout(连接阶段超时):异常类型与系统语言无关,优先于消息匹配
|
||||||
|
return False, "timeout", str(e)
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
# 连接层失败(拒绝/不可达/中断)。PySocks 依赖缺失与认证失败
|
||||||
|
# 也会以 ConnectionError 形态冒出,按消息细分。
|
||||||
|
msg = str(e)
|
||||||
|
low = msg.lower()
|
||||||
|
if "missing dependencies for socks" in low:
|
||||||
|
return False, "missing_dependency", msg
|
||||||
|
if "authentication" in low or "auth" in low:
|
||||||
|
return False, "auth_failed", msg
|
||||||
|
return False, "connect_failed", msg
|
||||||
|
except Exception as e:
|
||||||
|
# 兜底:仅按库自身(非本地化)消息关键词分类
|
||||||
|
msg = str(e)
|
||||||
|
low = msg.lower()
|
||||||
|
if "missing dependencies for socks" in low:
|
||||||
|
return False, "missing_dependency", msg
|
||||||
|
if "authentication" in low or "auth" in low:
|
||||||
|
return False, "auth_failed", msg
|
||||||
|
if "timed out" in low or "timeout" in low:
|
||||||
|
return False, "timeout", msg
|
||||||
|
if "refused" in low or "unreachable" in low or "closed" in low:
|
||||||
|
return False, "connect_failed", msg
|
||||||
|
return False, "error", msg
|
||||||
|
|
||||||
def execute(self, host: str, port: int, username: str = None, password: str = None,
|
def execute(self, host: str, port: int, username: str = None, password: str = None,
|
||||||
password_env: str = None, enabled: bool = True, save: bool = True, **kwargs) -> str:
|
password_env: str = None, enabled: bool = True, save: bool = True,
|
||||||
|
skip_check: bool = False, **kwargs) -> str:
|
||||||
|
# 设置前连通性验证:坏地址/凭据在入口一次性暴露,避免静默落盘后在
|
||||||
|
# 后续 web/API 调用中离场景报错、LLM 难以归因。skip_check 留给
|
||||||
|
# "代理暂未启动但先写配置"的场景;禁用代理无需验证。
|
||||||
|
check_detail = ""
|
||||||
|
if enabled and not skip_check:
|
||||||
|
pw = self._resolve_password(password, password_env)
|
||||||
|
# 指定了 password_env 但环境变量为空:认证凭据缺失,提前给出精准提示
|
||||||
|
if username and not password and password_env and pw is None:
|
||||||
|
return self.format_result(False, "代理验证失败,未保存配置", {
|
||||||
|
"错误类型": "环境变量为空",
|
||||||
|
"详情": f"环境变量 {password_env} 未设置或为空",
|
||||||
|
"提示": "请先设置该环境变量,或改用 password 参数(仅本次会话有效)",
|
||||||
|
})
|
||||||
|
ok, kind, detail = self._check_connectivity(host, port, username, pw)
|
||||||
|
if not ok:
|
||||||
|
hints = {
|
||||||
|
"auth_failed": "用户名/密码错误,请核对凭据",
|
||||||
|
"timeout": "代理不可达(超时),请检查地址与网络",
|
||||||
|
"connect_failed": "连接被拒绝或不可达,请检查 host/port",
|
||||||
|
"missing_dependency": "缺少 SOCKS 支持库,请安装:pip install pysocks",
|
||||||
|
}
|
||||||
|
return self.format_result(False, "代理不可达,未保存配置", {
|
||||||
|
"错误类型": kind,
|
||||||
|
"详情": detail[:300],
|
||||||
|
"提示": hints.get(kind, "如确认配置无误可设置 skip_check=true 跳过验证"),
|
||||||
|
})
|
||||||
|
check_detail = detail
|
||||||
|
|
||||||
config = ConfigManager()
|
config = ConfigManager()
|
||||||
config.set_proxy(host, port, username, password, password_env, enabled, save)
|
saved = config.set_proxy(host, port, username, password, password_env, enabled, save)
|
||||||
proxy_info = config.get_proxy_info()
|
proxy_info = config.get_proxy_info()
|
||||||
if proxy_info.get("password_env"):
|
if proxy_info.get("password_env"):
|
||||||
pw_source = "环境变量 " + proxy_info["password_env"]
|
pw_source = "环境变量 " + proxy_info["password_env"]
|
||||||
@@ -58,8 +159,13 @@ class SetProxyTool(Tool):
|
|||||||
"服务器": f"{proxy_info['host']}:{proxy_info['port']}",
|
"服务器": f"{proxy_info['host']}:{proxy_info['port']}",
|
||||||
"认证": "有" if proxy_info.get("username") else "无",
|
"认证": "有" if proxy_info.get("username") else "无",
|
||||||
"密码来源": pw_source,
|
"密码来源": pw_source,
|
||||||
"持久化": "已保存" if save else "仅内存"
|
"持久化": "已保存" if saved else "保存失败(仅本次会话有效,重启后丢失)"
|
||||||
}
|
}
|
||||||
|
if check_detail:
|
||||||
|
data["连通性"] = check_detail
|
||||||
|
# L2:落盘失败(磁盘只读等)如实报告,不再静默报"已保存"
|
||||||
|
if save and not saved:
|
||||||
|
return self.format_result(False, "代理已配置但保存失败(仅本次会话有效,重启后丢失)", data)
|
||||||
return self.format_result(True, "代理设置成功", data)
|
return self.format_result(True, "代理设置成功", data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ from common import Logger, constants, i18n
|
|||||||
from .base import Tool
|
from .base import Tool
|
||||||
|
|
||||||
# 工具参数名命中这些关键词时,debug 日志里打码(防密码/密钥进日志文件)
|
# 工具参数名命中这些关键词时,debug 日志里打码(防密码/密钥进日志文件)
|
||||||
_SENSITIVE_PARAM_KEYS = {"password", "api_key", "token", "secret", "authorization"}
|
# L11(审计 2026-08-18):精确匹配漏掉 apiKey/access_token/passwd 等变体,
|
||||||
|
# 改包含匹配——日志场景下过度打码可接受,漏打码才是问题。
|
||||||
|
_SENSITIVE_PARAM_KEYS = (
|
||||||
|
"password", "passwd", "api_key", "apikey", "token", "secret",
|
||||||
|
"authorization", "credential",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_kwargs(kwargs: Dict) -> Dict:
|
def _sanitize_kwargs(kwargs: Dict) -> Dict:
|
||||||
"""复制一份参数并把敏感键的值替换为 ***(仅用于日志输出)"""
|
"""复制一份参数并把敏感键的值替换为 ***(仅用于日志输出)"""
|
||||||
return {
|
return {
|
||||||
k: ("***" if str(k).lower() in _SENSITIVE_PARAM_KEYS else v)
|
k: ("***" if any(s in str(k).lower() for s in _SENSITIVE_PARAM_KEYS) else v)
|
||||||
for k, v in kwargs.items()
|
for k, v in kwargs.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ tools.skill_tools - 技能相关工具
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from common import constants, i18n, Logger
|
from common import constants, i18n, Logger
|
||||||
@@ -253,10 +254,14 @@ class InstallSkillTool(Tool):
|
|||||||
skills_dir = constants.SKILLS_DIR
|
skills_dir = constants.SKILLS_DIR
|
||||||
|
|
||||||
# 如果给了具体路径,在说明里带入,否则用占位符
|
# 如果给了具体路径,在说明里带入,否则用占位符
|
||||||
src = skill_path.strip() if skill_path else "/path/to/your_skill.md"
|
raw_src = skill_path.strip() if skill_path else ""
|
||||||
# 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名
|
# 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名
|
||||||
base = os.path.splitext(os.path.basename(src))[0] if src else "your_skill"
|
base = os.path.splitext(os.path.basename(raw_src))[0] if raw_src else "your_skill"
|
||||||
dst = os.path.join(skills_dir, base + ".md")
|
dst = os.path.join(skills_dir, base + ".md")
|
||||||
|
# L7(审计 2026-08-18):src 为 LLM 可控输入,含空格/元字符时
|
||||||
|
# 未经引用的命令会被 shell 拆词/注入(指引由子代理经 run_command 执行)
|
||||||
|
src = shlex.quote(raw_src) if raw_src else "/path/to/your_skill.md"
|
||||||
|
dst_q = shlex.quote(dst)
|
||||||
|
|
||||||
guide = f"""📦 扩展技能安装指引
|
guide = f"""📦 扩展技能安装指引
|
||||||
{"=" * 55}
|
{"=" * 55}
|
||||||
@@ -282,7 +287,7 @@ class InstallSkillTool(Tool):
|
|||||||
|
|
||||||
调用 create_subagent 派生子代理执行复制命令(目标文件名统一补 .md 扩展名):
|
调用 create_subagent 派生子代理执行复制命令(目标文件名统一补 .md 扩展名):
|
||||||
|
|
||||||
cp {src} {dst}
|
cp {src} {dst_q}
|
||||||
|
|
||||||
⚠️ 注意:目标文件名必须带 .md 扩展名,
|
⚠️ 注意:目标文件名必须带 .md 扩展名,
|
||||||
系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。
|
系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# aibox 模块安全与质量审计报告
|
# aibox 模块安全与质量审计报告
|
||||||
|
|
||||||
- **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)
|
- **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)/ 2026-08-18(第四轮:3 路并行审读 + Batch H 修复)
|
||||||
- **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)
|
- **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)→ 第四轮 HEAD `d1f780e`(Batch G + SOCKS5 验证后)
|
||||||
- **审计范围**:`aibox.py`(~3026 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件)
|
- **审计范围**:`aibox.py`(~3310 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件)
|
||||||
- **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查
|
- **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查;第三轮遗留项复核 + v3.4 专审;第四轮 3 路并行审读 + 主审逐条复核 + Linux PoC 实证(6/6)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -344,3 +344,94 @@ N3/N13/N14 为 async WebSocket 路径,依赖事件循环与真实连接对象
|
|||||||
### 10.3 测试基线
|
### 10.3 测试基线
|
||||||
|
|
||||||
全量 **375 passed**(22.5s,Windows 开发机 `py -3`)= 原 355 + Batch G 新增 20。零回归。
|
全量 **375 passed**(22.5s,Windows 开发机 `py -3`)= 原 355 + Batch G 新增 20。零回归。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、第四轮审计(2026-08-18,HEAD `d1f780e`)
|
||||||
|
|
||||||
|
方法:3 路并行审读(aibox.py 全文 / llm_client+common+tools / subsystems+tests)+ 主审对全部候选发现逐条代码复核,关键项 Linux 动态实证(PoC 6/6)。
|
||||||
|
|
||||||
|
### 11.1 新发现与修复(Batch H,13 项全落地)
|
||||||
|
|
||||||
|
| # | 级别 | 问题 | 位置 | 修复 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| H1 | **高危** | **N5 修复引入回归**:子代理共享父取消事件后,首轮 `_think_and_respond_impl` 无条件 `clear()` 吞掉父级已置位的取消信号(用户中断 / --once 超时恰落在子代理生成窗口时被清,观察者线程随之失效)——取消传播被绕过,子代理跑完全程 | `aibox.py:675, 1470` | 共享事件时置 `_shared_cancel_event` 跳过 clear;赋事件后立即检查 is_set 直接返回取消 |
|
||||||
|
| M1 | 中 | `/reload_prompts` 在事件循环线程同步获取 `_msg_lock` 冻结整个事件循环(N3 同类遗漏,只修了 `/new`) | `aibox.py:2492` | 移入 `run_in_executor`(与 /new 一致) |
|
||||||
|
| M2 | 中 | SkillManager 懒加载在事件循环线程串行执行 `--help` 子进程(每技能 ≤10s),首条消息/首个任务触发时冻结服务 | `aibox.py:2885-2891` | 启动前预热 WS 与调度器两个共享实例 |
|
||||||
|
| M3 | 中 | 调度器任务无超时:AI think 卡死 → `await ai_future` 永久阻塞 → 调度循环停摆、后续任务全部丢失(已 mark_as_triggered 不重试) | `aibox.py:333` | `call_later` 任务级超时(`scheduler.task_timeout`,默认 600s)→ `ai.cancel()`,循环继续 |
|
||||||
|
| M4 | 中 | `get_recent_memories(limit=None)` 无 LIMIT 全表加载(db_tools 分页传 None),记忆库增长后单次调用全量入内存 | `db.py:234` | None → 默认上限(对齐 search_memories 语义) |
|
||||||
|
| M5 | 中 | `memo delete` 实为 `complete_memo(force)`——只标记完成不删行,重复任务被推进而非移除,stats 计数失真 | `db_tools.py:747` | db 层新增 `delete_memo` 物理删除,工具改调 |
|
||||||
|
| M6 | 中 | `PromptManager.reload()` 先清空 `_prompts` 再逐文件重读:并发连接构建 system 消息时读到空/半截列表(N3 移线程池后放大) | `prompt_manager.py:298` | 本地构建新列表后原子替换 |
|
||||||
|
| L1 | 低 | read_file 的 `start_line/end_line` 未强转直拼 sed 单引号模板——LLM 传含引号字符串可闭合引号注入生成的指引命令 | `basic_tools.py:97` | int() 强转 + 范围夹取 |
|
||||||
|
| L2 | 低 | `_save_config` 吞异常仅 print:磁盘只读时 set_proxy 仍报"已保存",重启后配置丢失无感知 | `config.py:284` | `_save_config` 返回落盘结果,set_proxy 透传,工具如实报告 |
|
||||||
|
| L3 | 低 | SSE 行 `decode("utf-8")` 遇非 UTF-8 字节抛 UnicodeDecodeError,整条流中断且不触发 failover | `llm_client.py:222` | `errors="replace"` |
|
||||||
|
| L4 | 低 | persona 路径校验用 abspath 未解析符号链接:skills 目录内软链指向任意文件可绕过前缀检查被读取注入 | `aibox.py:536` | realpath 后再做前缀校验 |
|
||||||
|
| L5 | 低 | 1MB 截断按字节切后 `errors='replace'` 解码,行尾出现半个字符 � | `executors.py:150` | 改 `errors='ignore'` |
|
||||||
|
| L6 | 低 | compressor 压缩块全空内容时 `original_tokens=0` → 除零崩溃 | `conversation_compressor.py:517` | `max(original_tokens, 1)` |
|
||||||
|
| L7 | 低 | InstallSkillTool 指引命令 `ls -l {src}`/`cp {src} {dst}` 未引用:LLM 可控路径含空格/元字符时 shell 拆词/注入 | `skill_tools.py:269-285` | shlex.quote 源与目标路径 |
|
||||||
|
| L8 | 低 | ask_user 的 required 为空,LLM 不传 question 时 `str + None` 抛 TypeError | `misc_tools.py:42` | required 补 question + 缺参明确报错 |
|
||||||
|
| L9 | 低 | Linux 孙进程持管道泄漏(N4 残余变体):`(sleep 300 &)` 形态 shell 已死但孙进程持写端,reader 阻塞无 EOF;**实证修复前孙进程残留** | `executors.py` | POSIX `start_new_session` + 进程组信号(组 ID 在 Popen 后立即捕获,shell 死亡后 getpgid 不可用);宽限窗口到期强杀进程组 |
|
||||||
|
| L10 | 低 | web 工具 url 无 scheme 校验(M3 遗留):`file://` 等经 curl 指引可达(SSRF) | `basic_tools.py:319` | http/https 白名单 |
|
||||||
|
| L11 | 低 | registry 日志打码精确匹配:apiKey/passwd 等变体漏网 | `registry.py:19` | 子串包含匹配(含 apiKey/passwd/credential) |
|
||||||
|
| L12 | 低 | FailoverClient 异常消息经 `_log` 落 ai.log:网关错误页若在 200 字符内回显 Authorization 头则密钥进日志 | `llm_client.py` | `_log`/on_failover 前 Bearer 令牌正则脱敏 |
|
||||||
|
|
||||||
|
### 11.2 修复实证(Linux PoC 6/6 PASS)
|
||||||
|
|
||||||
|
```
|
||||||
|
[PASS] L-1 无输出命令超时: 3.0s 返回,含超时(原 PoC-1 为 Windows powershell 命令)
|
||||||
|
[PASS] L-3 1MB截断: 结果 1.00MB(Linux 等价命令)
|
||||||
|
[PASS] L-4 cancel中断: cancel 后 2.0s 结束
|
||||||
|
[PASS] L-5 工具层1MB贯通: 结果 1.00MB
|
||||||
|
[PASS] L-9 孙进程持管道不悬挂: 1.1s 返回,reader 正常收尾
|
||||||
|
[PASS] L-9 孙进程已被清理: 残留无(修复前 `(sleep 300 &)` 孙进程残留至自然退出)
|
||||||
|
```
|
||||||
|
|
||||||
|
L-9 实证过程说明:首版实现按"break 后 killpg"处理,但 shell 已死时 `os.getpgid(pid)` 抛 ProcessLookupError,组 ID 无法反查,孙进程仍残留(实证 FAIL 抓出)。修正为 **Popen 成功后立即捕获组 ID 并保存**,killpg 用保存值——6/6 通过。
|
||||||
|
|
||||||
|
### 11.3 遗留项(维持原判 / 择机)
|
||||||
|
|
||||||
|
- **N9**(prompt 注入存储):confirm 默认 True、100_STATUS 直写无门槛——涉及交互设计(用户确认门槛),维持 G6 择机项
|
||||||
|
- **M4/M3(旧编号)**:shlex Windows 转义(Linux 部署不受影响)、Q9 代理认证 curl 指引(修复需把密码嵌入命令串,反而引入密码进日志/会话史的新泄漏,维持现状)
|
||||||
|
- **F5** express_emotion 不可中断、**F6** /new 占用默认线程池、**F8** WS 限速/空闲回收、**F9** --once 软超时(H1 修复后取消可传播,残余窗口受 timeout 有界)、**Q7a** stream_chat 取消空窗(受 timeout 有界)、Q4 busy_timeout 重试、Q5 提醒格式异常静默、Q7b load_conversation 无调用面、Q8 对话文件原子写、compressor 裸 print / token 双计、单例 __new__ 无锁、db.py `if limit:` limit=0 边界——均为低危/信息级,下批处理
|
||||||
|
- **测试环境注记**:`test_executors.py::test_execute_timeout` 与 `test_models_profile.py::test_flat_api_still_reads_key_file` 为 Windows 向用例(ping -n 语法 / 依赖 ~/.cert/deepseek.key),Linux 环境预存失败,与本轮改动无关(git stash 实证)
|
||||||
|
|
||||||
|
### 11.4 测试基线(Linux,`aibox/venv`)
|
||||||
|
|
||||||
|
全量 **408 passed** = 原 382(Linux 环境)+ Batch H 新增 26。零回归;2 个预存环境性失败同前。PoC 回归脚本为 Windows 命令集,Linux 用等价命令实证(见 11.2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、Batch I 修复落地(2026-08-18,遗留项收尾)
|
||||||
|
|
||||||
|
按 §11.3 遗留清单分 3 波实施:安全项 → 稳定性项 → 信息级顺手项。
|
||||||
|
|
||||||
|
### 12.1 修复清单
|
||||||
|
|
||||||
|
| # | 级别 | 修复内容 | 文件/位置 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| N9 | 中 | prompt 写入内容长度上限(持久化注入面约束):`PromptManager.MAX_CONTENT_LENGTH=10000`,update_identity/update_soul/update_status 超长拒绝(manager 兜底 + 工具友好提示);save_memory_and_end_conversation 状态内容超长拒绝 | `prompt_manager.py`、`prompt_tools.py` |
|
||||||
|
| Q7b | 低 | `load_conversation` realpath 前缀校验,`../` 越界/软链逃逸返回 None(当前无 LLM 调用面,防御性加固) | `conversation_history.py:348` |
|
||||||
|
| F5 | 低 | 情绪子代理取消传播:`run_light_subagent` 新增 `cancel_event`,改走 stream_chat(可被 cancel 强关在途 HTTP)+ 观察者线程;父已取消早退;`ExpressEmotionTool` 构造注入父 `_cancel_event` | `aibox.py:513, 608-660, 1177` |
|
||||||
|
| Q4 | 低 | `mark_as_triggered` BEGIN IMMEDIATE 前 `PRAGMA busy_timeout = 10000`——多进程同库(CLI+服务)写竞争等待锁释放而非 5s 后直接报 locked | `db.py:527` |
|
||||||
|
| Q5 | 低 | 提醒时间格式异常任务不再静默丢弃:去掉 SQL 层 `reminder_time <= ?` 预过滤(字符串比较会静默排除格式异常行),时间过滤改由 Python 二次验证权威判断;格式异常按"到期"返回并告警 | `db.py:405-411, 442-444` |
|
||||||
|
| Q8 | 低 | 对话文件原子写:`_atomic_write_json`(tmp + os.replace)覆盖历史索引/当前对话/归档三处写路径,崩溃不留半截 JSON | `conversation_history.py` |
|
||||||
|
| F6 | 低 | WS 命令专用线程池:`WebSocketHandler._cmd_executor`(max_workers=2),`/new`、`/reload_prompts` 不再占用默认池(繁忙 AI 时多连接并发 /new 不再耗尽线程池);服务退出时 shutdown | `aibox.py` |
|
||||||
|
| F9 | 低 | --once 硬超时兜底:软超时 cancel 后若 AI 卡死在不可中断路径(HTTP 空窗、忽略信号的子进程),宽限一个超时周期后 `os._exit(4)` 强杀,保证硬性结束语义 | `aibox.py:3283-3299` |
|
||||||
|
| — | 信息 | PromptManager 单例 `__new__` 加锁(并发首例化竞态防护) | `prompt_manager.py:49-53` |
|
||||||
|
| — | 信息 | `get_pending_memos`/`get_immediate_memos` 的 `if limit:` → `if limit is not None`(limit=0 语义修正,None 才不加 LIMIT) | `db.py:420, 464` |
|
||||||
|
|
||||||
|
### 12.2 维持原判(未改动)
|
||||||
|
|
||||||
|
- **Q7a**(stream_chat 取消空窗):POST 返回前 `_current_response` 未赋值,cancel 无法中断——受 timeout 有界;根治需线程化 POST + 轮询取消,复杂度高,维持记录
|
||||||
|
- **M4(旧编号)**(shlex Windows 转义):Linux 部署不受影响
|
||||||
|
- **Q9(代理认证 curl 指引)**:修复会把密码嵌入命令串(进日志/会话史),反而引入新泄漏,维持现状
|
||||||
|
- **F8**(WS 限速/空闲回收):本地单用户产品设计项
|
||||||
|
- compressor 裸 print(终端过程展示,保留)、`estimate_tokens` 英文双计(测试锚定既有算法,仅影响压缩阈值)
|
||||||
|
|
||||||
|
### 12.3 新增测试(`tests/test_i_fixes.py` 16 例,全部通过)
|
||||||
|
|
||||||
|
N9 ×5(manager 兜底/身份/灵魂/状态超长拒绝 + 正常写入)、Q7b ×2(越界拒绝 + 正常加载)、F5 ×3(父已取消早退/流式中断/注册注入链路)、Q5 ×2(格式异常按到期返回 + 正常过滤不回归)、Q8 ×1(无 .tmp 残留 + JSON 合法)、F6 ×1(专用线程池存在与释放)、limit=0 ×2。
|
||||||
|
|
||||||
|
### 12.4 测试基线(Linux,`aibox/venv`)
|
||||||
|
|
||||||
|
全量 **424 passed** = 原 408 + Batch I 新增 16。零回归;2 个预存环境性失败同前。
|
||||||
@@ -416,9 +416,9 @@ function renderMarkdown(element, content) {
|
|||||||
html: false,
|
html: false,
|
||||||
highlight: function(code, lang) {
|
highlight: function(code, lang) {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
return `<pre><code class="hljs language-${lang}">${hljs.highlight(code, {language: lang}).value}</code></pre>`
|
return `<pre><code class="hljs language-${lang}"><div style="font-size: 11px;">${lang}</div>${hljs.highlight(code, {language: lang}).value}</code></pre>`
|
||||||
} else {
|
} else {
|
||||||
return `<pre><code class="hljs">${md.utils.escapeHtml(code)}</code></pre>`
|
return `<pre><code class="hljs"><div style="font-size: 11px;">${lang}</div>\n${md.utils.escapeHtml(code)}</code></pre>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user