feat(aibox): 创建/安装技能工具统一生成 .md 扩展名 + 下线旧版 smtp.py

- CreateSkillTool/InstallSkillTool 改为生成带 .md 扩展名的技能文件,与人格技能文件规范一致
- 同步更新 7_extension_skills.md 模板与工具描述
- 删除旧版 smtp.py(P0-1)并同步审计文档与 README
This commit is contained in:
qing committed 2026-08-04 08:26:57 +08:00
1 parent fb485ebac4
commit 53ddcb364f
6 files changed
+243 -195

No files matched your search

+10
View File
@@ -15,6 +15,7 @@
- 🔧 **扩展技能**:动态加载 `~/.aibox/skills/` 下的可执行脚本或 Markdown 技能文件
- 🌍 **多语言支持**:自动检测系统语言,支持中英文
- 🧬 **智能体自我进化**:通过 `update_soul` / `update_identity` 工具动态更新角色定位与用户画像
- 🤖 **子代理**:通过 `create_subagent` 工具派生出拥有独立上下文/角色设定的轻量子代理,主代理可分解长任务、隔离上下文,子代理可选用不同模型
- 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能
- 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化
- 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词)
@@ -146,6 +147,7 @@ AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,
| `calculator` | 数学计算 |
| `set_proxy` | 设置 SOCKS5 代理(host/port/可选账号密码,可持久化,即时生效) |
| `get_proxy` | 查看当前 SOCKS5 代理配置 |
| `create_subagent` | 派生子代理:独立上下文/角色设定,轻量无工具、可选独立模型,用于长任务分解、上下文隔离、独立角色专注处理 |
## 💬 WebSocket API
@@ -719,6 +721,14 @@ elif command == "/mycommand":
## 📄 更新日志
### v2.7
- ✅ **新增子代理功能(`create_subagent` 工具)**:
- 主代理可派生出拥有**独立上下文窗口**和**独立角色设定**的轻量子代理,不污染主对话上下文
- 轻量子代理:无工具,通过一次 LLM 补全返回结果,适合长任务分解、上下文隔离、独立角色专注处理(如代码审查、文本总结、资料研究)
- 子代理可选用**不同模型**(复用 `llm_client` 抽象层,`create_llm_client` 新增 `model` 参数)
- 参数:`role`(角色/系统提示词,必填)、`task`(任务,必填)、`model`/`temperature`/`max_tokens`(可选)
- `max_tokens` 已钳制到 `[1, 主配置 DEFAULT_MAX_TOKENS]`,防止意外超限消耗
### v2.6
- ✅ **修复 Ctrl+C 中断无法停止服务器的问题**:
- 新增 `interrupt` WebSocket 消息类型处理(BotOps 前端 Ctrl+C 发送此消息),立即回复 `interrupt_ack` 确认
+212 -94
View File
@@ -828,40 +828,38 @@ run_command command="python script.py" stream=true work_dir="~/.aibox/workspace"
'content': """# 扩展技能能力
## 动态加载
系统会自动扫描 `~/.aibox/skills/` 目录下的可执行文件作为扩展技能。
系统会自动扫描 `~/.aibox/skills/` 目录下的文件作为扩展技能,其中:
- Markdown 文件(.md):第一行是一级标题(# 技能名),作为技能摘要,其余为技能内容
- 可执行文件:执行 `--help`,第一行为一句话简介
## 技能接口要求
每个技能只需支持一个接口:
- `--help`:输出该技能的完整使用说明,**第一行为一句话简介**,其余为参数说明和示例
系统启动时会对所有技能执行 `--help`,取第一行作为简介摘要,无需再实现 `--description`、`--parameters`、`--execute` 接口。
## 技能文件要求
技能文件统一带 `.md` 扩展名,格式如下:
- 第一行为一级标题 `# 技能名`,作为该技能的一句话简介
- 其余内容为技能的用途、使用方式、注意事项和典型示例
## 如何搜索和调用扩展技能
1. 使用 `search_skills` 工具搜索关键字,获取匹配技能的名称和简介列表(传入空字符串可列出全部)。
2. 对目标技能,通过 `run_command` 执行 `技能名 --help`,获取完整命令手册。
3. 按照手册说明,使用 `run_command` 以正确的命令行格式调用该技能。
2. 根据技能内容,按其说明执行相应操作。
## 如何创建新技能(AI自主编写)
调用 `create_skill` 工具,工具会返回一段指令提示词,指导你自行完成脚本的编写和部署:
调用 `create_skill` 工具,工具会返回一段指令提示词,指导你自行完成技能文件的编写和部署:
```
create_skill skill_name="my-tool" description="一个将摄氏度转换为华氏度的工具,接受 -t 参数输入温度值"
```
- `skill_name`:技能名称(字母/数字/连字符/下划线,无扩展名)
- `skill_name`:技能名称(字母/数字/连字符/下划线,无扩展名,系统会自动补 .md)
- `description`:对技能功能的详细需求描述,越详细越好
收到提示词后,你需要依次:
1. 自行编写满足接口要求的 Python 脚本
2. 用 `write_file` 写入 `~/.aibox/skills/<skill_name>`
3. 用 `run_command` 执行 `chmod +x` 赋权
4. 用 `run_command` 执行 `<skill_name> --help` 验证
5. 调用 `reload_skills` 热重载
1. 自行编写满足格式要求的 Markdown 技能文件
2. 用 `write_file` 写入 `~/.aibox/skills/<skill_name>.md`
3. 调用 `reload_skills` 热重载
## 如何手动安装新技能
调用 `install_skill` 工具,获取完整的安装步骤指引(可选传入技能文件路径):
```
install_skill skill_path="/path/to/your_skill.py"
install_skill skill_path="/path/to/your_skill.md"
```
指引内容包括:可执行性检查、`--help` 验证、复制到技能目录并去掉扩展名、热重载。
指引内容包括:文件检查、格式校验、复制到技能目录并统一为 `.md` 扩展名、热重载。
## 热重载
添加、修改或删除技能后,可以使用 `reload_skills` 工具立即生效,无需重启系统。
@@ -954,15 +952,25 @@ update_soul content="我是...(新的自我定义)"
'content': """# 情绪管理能力
## 可用工具
- **express_emotion**: 表达并记录当前情绪状态
- **express_emotion**: 自动派生子代理,以指定人格(persona)和情绪(emotion)生成情绪化回应;支持独立人格设定,不污染主对话上下文
## 触发场景(自动调用)
当检测到以下情况时,应**自动调用 express_emotion**:
- 用户表现出明显情绪(开心/生气/焦虑/沮丧/期待等)
- 用户明确要求特定情绪模式(生气/傲娇/撒娇/关心等)
- 用户情绪波动,需要以更鲜活的角色化回应来增强互动
## 使用方法
- `express_emotion emotion="anger" intensity=3 reason="用户反复提问相同问题"`
- 基本:`express_emotion emotion="anger" intensity=3 reason="用户反复提问相同问题"`
- 指定人格(推荐):`express_emotion emotion="anger" intensity=3 reason="..." persona="angry" context="用户最近说:..."`
- `persona` 对应 ~/.aibox/skills/ 下的技能文件(如 angry/coquettish/tsundere),加载后作为情绪子代理的人格设定
- `context` 传入用户最近的表述或对话要点,使子代理回应更贴合语境
## 注意事项
- 情绪表达应服务于任务,不应影响判断力
- 情绪状态会影响记忆搜索权重(如高情绪强度下的记忆更易被触发)
- 表达情绪后,应继续保持解决问题的专注力
- 用户表达严肃需求或紧急问题时,立即退出情绪模式,认真对待
"""
}
]
@@ -4526,7 +4534,9 @@ class ExpressEmotionTool(Tool):
def get_description(self) -> str:
return """表达情绪并记录当前情绪状态,用于增强交互真实感。
支持的emoji: anger, joy, sadness, surprise。
调用后会在对话中输出相应表情符号和简短语句,并将情绪记录到当前对话元数据中。"""
可指定 persona 从 ~/.aibox/skills/ 加载对应人格技能文件(如 angry/coquettish/tsundere),
自动派生一个携带该人格的情绪子代理来生成回应,保证情绪人格独立、不污染主对话上下文。
建议在 context 中传入用户最近的表述或对话要点,使子代理回应更贴合语境。"""
def get_parameters(self) -> Dict:
return {
@@ -4534,15 +4544,63 @@ class ExpressEmotionTool(Tool):
"properties": {
"emotion": {"type": "string", "enum": ["anger", "joy", "sadness", "surprise"]},
"intensity": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3},
"reason": {"type": "string", "description": "触发情绪的原因"}
"reason": {"type": "string", "description": "触发情绪的原因"},
"persona": {"type": "string", "description": "可选的人格技能名(对应 ~/.aibox/skills/<persona>.md),如 angry/coquettish/tsundere,加载后作为情绪子代理的人格设定"},
"context": {"type": "string", "description": "可选:当前对话上下文,用户最近的表述或需要回应的内容要点,供子代理贴合语境回应"}
},
"required": ["emotion"]
}
def execute(self, emotion: str, intensity: int, reason: str, **kwargs) -> str:
return (
"你现在出现了这种情绪:" + emotion + "\n强烈程度:" + str(intensity) + "\n原因:" + reason + "\n你现在必须模拟出这种情绪。"
# 情绪 → 默认人格技能文件映射(persona 未指定时按情绪取默认人格)
_DEFAULT_PERSONA_FILES = {
"anger": "angry",
"joy": "coquettish",
"sadness": "",
"surprise": "",
}
def _load_persona(self, persona: str, emotion: str) -> str:
"""加载人格技能文件内容。persona 优先,否则按 emotion 映射默认文件。"""
name = (persona or "").strip()
if not name:
name = self._DEFAULT_PERSONA_FILES.get(emotion, "") or ""
if not name:
return ""
path = os.path.join(constants.SKILLS_DIR, name + ".md")
if not os.path.isfile(path):
self.logger.warning(f"未找到人格技能文件: {path}")
return ""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception as e:
self.logger.warning(f"加载人格技能 {name} 失败: {e}")
return ""
def execute(self, emotion: str, intensity: int, reason: str, persona: str = None,
context: str = None, **kwargs) -> str:
try:
persona_content = self._load_persona(persona, emotion)
if persona_content:
role = f"你正在扮演以下人设,请严格遵循:\n{persona_content}"
else:
role = (f"你是一个正在表现出「{emotion}」情绪的角色,情绪强度 {intensity}。"
f"请用符合该情绪的语气和口吻回应。")
task_lines = [f"触发情绪的原因:{reason or '无'}"]
if context:
task_lines.append(f"当前对话上下文(用户最近的表述或需要回应的内容):{context}")
task_lines.append(
f"请按上述人设,以「{emotion}」情绪(强度 {intensity})回应上面的对话内容,"
f"保持角色一致性,给出一段自然、符合情境的回应。"
)
task = "\n".join(task_lines)
# 情绪子代理:独立上下文/人格,生成情绪化回应
return run_light_subagent(role, task, self.config, logger=self.logger, max_tokens=2048)
except Exception as e:
self.logger.error(f"情绪子代理执行异常: {e}")
return f"❌ 情绪子代理执行失败: {str(e)}"
# ==================== 安装技能工具 ====================
@@ -4555,8 +4613,8 @@ class InstallSkillTool(Tool):
def get_description(self) -> str:
return (
"技能安装指引工具。\n"
"返回将可执行文件或脚本安装为扩展技能的完整步骤说明,"
"包括可执行性检查、--help 验证和部署方法。"
"返回将 Markdown 技能文件安装为扩展技能的完整步骤说明,"
"包括文件检查、格式校验和部署方法(目标文件名统一带 .md 扩展名)。"
)
def get_parameters(self) -> Dict:
@@ -4579,59 +4637,42 @@ class InstallSkillTool(Tool):
skills_dir = constants.SKILLS_DIR
# 如果给了具体路径,在说明里带入,否则用占位符
src = skill_path.strip() if skill_path else "/path/to/your_skill"
# 去掉扩展名作为目标名称示例
src = skill_path.strip() if skill_path else "/path/to/your_skill.md"
# 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名
base = os.path.splitext(os.path.basename(src))[0] if src else "your_skill"
dst = os.path.join(skills_dir, base)
dst = os.path.join(skills_dir, base + ".md")
guide = f"""📦 扩展技能安装指引
{"=" * 55}
要将一个可执行文件或脚本安装为扩展技能,请按以下步骤操作:
要将一个 Markdown 技能文件安装为扩展技能,请按以下步骤操作:
─────────────────────────────────────────────────────
步骤 1:确认文件可执行
步骤 1:确认文件存在且格式正确
─────────────────────────────────────────────────────
运行以下命令,确认文件有可执行权限:
运行以下命令,确认文件存在:
ls -l {src}
如果没有执行权限,使用以下命令添加:
chmod +x {src}
然后直接运行一次,确认不会报错:
{src}
技能文件格式要求:
• 文件名为纯文本 Markdown,第一行是一级标题(# 技能名),作为技能摘要
• 其余内容为技能的执行说明、使用方法和示例
• 文件应带 .md 扩展名(系统以 .md 文件作为技能加载)
─────────────────────────────────────────────────────
步骤 2:验证 --help 输出是否完整
─────────────────────────────────────────────────────
技能必须支持 --help 选项,且:
• 第一行是一句话简介(系统用它作为技能摘要)
• 其余行说明参数用法和使用示例
运行以下命令验证:
{src} --help
确认输出的第一行能清楚描述该技能的用途。
─────────────────────────────────────────────────────
步骤 3:复制到技能目录并去掉扩展名
步骤 2:复制到技能目录并统一为 .md 扩展名
─────────────────────────────────────────────────────
技能目录:{skills_dir}
复制命令(自动去掉扩展名):
复制命令(目标文件名统一补 .md 扩展名):
cp {src} {dst}
chmod +x {dst}
⚠️ 注意:目标文件名不能有扩展名(如 .py、.sh),
系统以纯文件名作为技能名称。
⚠️ 注意:目标文件名必须带 .md 扩展名,
系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。
─────────────────────────────────────────────────────
步骤 4:让系统识别新技能
步骤 3:让系统识别新技能
─────────────────────────────────────────────────────
复制完成后,调用热重载工具使其立即生效:
@@ -4642,7 +4683,7 @@ class InstallSkillTool(Tool):
search_skills keyword="{base}"
{"=" * 55}
完成以上步骤后,即可通过 run_command 直接调用该技能。"""
完成以上步骤后,即可通过该技能执行相应操作。"""
return guide
@@ -4650,14 +4691,13 @@ class InstallSkillTool(Tool):
# ==================== 创建扩展技能工具 ====================
class CreateSkillTool(Tool):
"""创建扩展技能工具 - 返回提示词,驱动智能体自行编写并部署可执行脚本。
"""创建扩展技能工具 - 返回提示词,驱动智能体自行编写并部署 Markdown 技能文件。
工具本身不调用 LLM,只输出一段结构化的指令提示词。
智能体读到提示词后,会自行:
1. 编写满足接口要求的 Python 脚本
2. 用 write_file 写入 ~/.aibox/skills/<skill_name>
3. 用 run_command 赋予可执行权限
4. 调用 reload_skills 热重载
1. 编写满足格式要求的 Markdown 技能文件
2. 用 write_file 写入 ~/.aibox/skills/<skill_name>.md
3. 调用 reload_skills 热重载
"""
def get_name(self) -> str:
@@ -4666,12 +4706,11 @@ class CreateSkillTool(Tool):
def get_description(self) -> str:
return (
"创建扩展技能工具。\n"
"返回一段指令提示词,驱动你自行编写并部署一个满足技能接口要求的可执行脚本。\n\n"
"脚本接口要求:\n"
" • 首行 shebang:#!/usr/bin/env python3\n"
" • 支持 --help 选项(使用 argparse)\n"
" • --help 输出的第一行(argparse description)必须是该技能的一句话简介\n"
" • epilog 中包含完整的参数说明和至少一个使用示例"
"返回一段指令提示词,驱动你自行编写并部署一个 Markdown 技能文件(带 .md 扩展名)。\n\n"
"技能文件格式要求:\n"
" • 文件名必须带 .md 扩展名(如 fetch-weather.md、calc-bmi.md)\n"
" • 第一行为一级标题(# 技能名),作为该技能的一句话简介\n"
" • 其余内容为技能的执行说明、使用方法和示例,供智能体参考执行"
)
def get_parameters(self) -> Dict:
@@ -4681,7 +4720,7 @@ class CreateSkillTool(Tool):
"skill_name": {
"type": "string",
"description": (
"技能名称(部署后的文件名,不含扩展名)。"
"技能名称(部署后的文件名,不含扩展名;系统会自动补齐 .md)。"
"只能包含字母、数字、连字符和下划线,例如:fetch-weather、calc-bmi。"
)
},
@@ -4707,7 +4746,8 @@ class CreateSkillTool(Tool):
skill_name = skill_name.strip()
skills_dir = constants.SKILLS_DIR
skill_path = os.path.join(skills_dir, skill_name)
# 技能文件名统一带 .md 扩展名
skill_path = os.path.join(skills_dir, skill_name + ".md")
# ---- 返回给智能体的指令提示词 ----
prompt = f"""【任务】创建扩展技能:{skill_name}
@@ -4715,43 +4755,32 @@ class CreateSkillTool(Tool):
请按照以下步骤,自行完成该扩展技能的编写和部署:
─────────────────────────────────────────────────
步骤 1:编写 Python 脚本
步骤 1:编写 Markdown 技能文件
─────────────────────────────────────────────────
根据以下需求,编写一个完整可运行的 Python 3 脚本:
根据以下需求,编写一个 Markdown 技能文件:
需求描述:
{description.strip()}
脚本必须满足的接口约定:
1. 第一行为 shebang:#!/usr/bin/env python3
2. 使用 argparse 实现 --help 选项,其中:
- ArgumentParser 的 description 参数填写对该脚本功能的一句话简介(这是 --help 的第一行,系统会用它作为技能摘要)
- epilog 参数中包含完整的参数说明和至少一个使用示例
3. 功能完整可运行,包含必要的错误处理
技能文件必须满足的格式约定:
1. 第一行为一级标题 `# {skill_name}`,用于对该技能功能做一句话简介(系统用第一行作为技能摘要)
2. 其余内容清晰说明该技能的用途、使用方式、注意事项和典型示例
3. 内容完整可执行(即按内容操作即可完成该技能要完成的事)
─────────────────────────────────────────────────
步骤 2:写入技能目录
─────────────────────────────────────────────────
使用 write_file 工具,将完整脚本内容写入:
使用 write_file 工具,将完整内容写入(文件名必须带 .md 扩展名):
{skill_path}
─────────────────────────────────────────────────
步骤 3:赋予可执行权限
步骤 3:热重载
─────────────────────────────────────────────────
使用 run_command 工具执行:
chmod +x {skill_path}
─────────────────────────────────────────────────
步骤 4:验证并热重载
─────────────────────────────────────────────────
使用 run_command 工具执行以下命令验证 --help 输出是否正常:
{skill_path} --help
确认无误后,调用 reload_skills 工具使新技能立即生效:
调用 reload_skills 工具使新技能立即生效:
reload_skills confirm=true
─────────────────────────────────────────────────
完成后请告知用户技能已创建成功,并展示 --help 的第一行简介。"""
完成后请告知用户技能已创建成功,并展示技能的第一行简介。"""
return prompt
@@ -6489,6 +6518,92 @@ class CommandLineTool(Tool):
except Exception as e:
return f"❌ 执行错误: {str(e)}"
def run_light_subagent(role, task, config, logger=None, model=None,
temperature=None, max_tokens=2048) -> str:
"""轻量子代理执行:独立上下文/角色,无工具,一次 LLM 补全返回结果。
供 create_subagent 工具与 express_emotion 情绪子代理复用。
返回子代理生成的完整文本;失败时返回带 ❌ 的错误信息。
"""
try:
# 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS))
temp = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE
# 子代理使用独立的 LLM 客户端(可选不同模型),独立上下文,不污染主对话
sub_client = create_llm_client(config, api_key=None, logger=logger, model=model)
messages = [
{"role": "system", "content": role},
{"role": "user", "content": task}
]
if logger is not None:
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():
return "❌ 子代理执行失败:未获得有效响应"
return str(result).strip()
except Exception as e:
if logger is not None:
logger.error(f"子代理执行异常: {e}")
return f"❌ 子代理执行失败: {str(e)}"
class SubAgentTool(Tool):
"""派生子代理工具 - 主代理可派生拥有独立上下文/角色设定的轻量子代理执行任务。
轻量子代理:无工具、独立上下文窗口(不污染主对话)、可选独立模型,
通过一次 LLM 补全返回结果。适合长任务分解、独立角色专注处理(如代码审查、
文本总结、资料研究)等需要隔离上下文或并行派发的场景。
"""
def get_name(self) -> str:
return "create_subagent"
def get_description(self) -> str:
return """派生子代理执行任务。子代理拥有独立的上下文窗口和角色设定,不会污染主对话上下文。
适用场景:长文本/大任务分解、独立角色专注处理(如代码审查、文本总结、资料研究)、需要隔离上下文的子任务。
子代理不带工具,仅返回独立回答。参数说明:
- role:子代理的角色设定(作为系统提示词),如"你是一位资深代码审查专家"
- task:要交给子代理执行的具体任务
- model:子代理使用的模型(可选,默认与主代理一致)
- temperature:采样温度(可选,默认使用主配置)
- max_tokens:最大输出 token 数(可选,默认 2048)"""
def get_parameters(self) -> Dict:
return {
"type": "object",
"properties": {
"role": {
"type": "string",
"description": "子代理的角色设定(作为系统提示词),如'你是一位资深代码审查专家'"
},
"task": {
"type": "string",
"description": "要交给子代理执行的具体任务"
},
"model": {
"type": "string",
"description": "子代理使用的模型名称,默认与主代理一致"
},
"temperature": {
"type": "number",
"description": "采样温度,默认使用主配置"
},
"max_tokens": {
"type": "integer",
"description": "最大输出 token 数,默认 2048"
}
},
"required": ["role", "task"]
}
def execute(self, role: str = None, task: str = None, model: str = None,
temperature: float = None, max_tokens: int = 2048, **kwargs) -> str:
if error := self.validate_params(["role", "task"], role=role, task=task):
return error
return run_light_subagent(role, task, self.config, logger=self.logger,
model=model, temperature=temperature, max_tokens=max_tokens)
class SetProxyTool(Tool):
"""设置 SOCKS5 代理的工具"""
@@ -6692,6 +6807,9 @@ class DeepSeekChat:
builtin_tools.append(SetProxyTool(self.logger))
builtin_tools.append(GetProxyTool(self.logger))
# 子代理工具:主代理可派生独立上下文/角色的轻量子代理执行任务
builtin_tools.append(SubAgentTool(self.logger))
# 注册内置工具(标记为内置)
self.tool_registry.register_many(builtin_tools, is_builtin=True)
@@ -6992,7 +7110,7 @@ class DeepSeekChat:
enhanced_input = input_text
else:
# 首轮回话:附加简洁的工作流程提醒(不重复 system prompt 中已有的详细说明)
pre_prompt = "【工作流程提醒】\n1. 如果用户提出新任务,先用 search_skills 搜索是否有现成技能可用\n2. 根据话题相关性决定是否 memory search(简单问候/确认无需搜索)\n3. 信息不足时用 ask_user 提问,不要猜测\n4. 遇到大文件用分页读取(head/tail/start_line/end_line),不要直接打开大文件\n5. 话题完成时用 save_memory_and_end_conversation 保存记忆并结束\n\n【情绪感知提醒】\n1. 注意识别用户当前的情绪状态(开心/焦虑/沮丧/生气/困惑/期待等),并在回应中体现共情\n2. 用户情绪低落或焦虑时,优先安抚情绪再解决问题,语气温暖包容\n3. 用户开心或兴奋时,分享喜悦,保持积极回应的节奏\n4. 用户表现出愤怒或不满时,先道歉再解决问题,不要辩解或说教\n5. 用户要求特定情绪模式(生气/傲娇/撒娇/关心)时,用 search_skills 搜索对应技能并严格遵循"
pre_prompt = "【工作流程提醒】\n1. 如果用户提出新任务,先用 search_skills 搜索是否有现成技能可用\n2. 根据话题相关性决定是否 memory search(简单问候/确认无需搜索)\n3. 信息不足时用 ask_user 提问,不要猜测\n4. 遇到大文件用分页读取(head/tail/start_line/end_line),不要直接打开大文件\n5. 话题完成时用 save_memory_and_end_conversation 保存记忆并结束\n\n【情绪感知提醒】\n1. 注意识别用户当前的情绪状态(开心/焦虑/沮丧/生气/困惑/期待等),并在回应中体现共情\n2. 用户情绪低落或焦虑时,优先安抚情绪再解决问题,语气温暖包容\n3. 用户开心或兴奋时,分享喜悦,保持积极回应的节奏\n4. 用户表现出愤怒或不满时,先道歉再解决问题,不要辩解或说教\n5. 检测到用户有明显情绪、或用户要求特定情绪模式(生气/傲娇/撒娇/关心)时,自动调用 express_emotion 工具:emotion 传入情绪(anger/joy/sadness/surprise)、reason 说明原因、persona 指定人格(如 angry/coquettish/tsundere)、context 传入用户最近表述,让情绪子代理以贴合语境的人格化方式回应"
enhanced_input = pre_prompt + "\n\n" + input_text
# 记录本轮开始前的消息长度,用于 API 失败时回滚,防止坏消息污染对话历史
+11 -3
View File
@@ -241,11 +241,19 @@ def resolve_api_key(api_key: Optional[str], config=None) -> str:
def create_llm_client(config, api_key: Optional[str] = None,
logger=None) -> LLMClient:
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。"""
logger=None, model: Optional[str] = None) -> LLMClient:
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。
Args:
config: 配置对象(需提供 get 方法)。
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
logger: 可选日志对象。
model: 可选模型名覆盖;为 None 时使用配置中的 `api.deepseek_model`。
子代理等场景可通过此参数显式指定与主代理不同的模型。
"""
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
base_url = config.get("api.deepseek_api_url")
model = config.get("api.deepseek_model", "deepseek-v4-flash")
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
timeout = config.get("api.timeout", 30)
resolved_key = resolve_api_key(api_key, config)
+9 -12
View File
@@ -15,15 +15,15 @@
| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 |
|------|------|--------|----------|--------|--------|
| P0 | 2 | 0 | 1 | 1 | 25% (部分) |
| P0 | 2 | 1 | 0 | 1 | 50% |
| P1 | 2 | 1 | 0 | 1 | 50% |
| **合计** | **4** | **1** | **1** | **2** | **37.5%** (部分) |
| **合计** | **4** | **2** | **0** | **2** | **50%** |
### 逐条验证
| 编号 | 状态 | 代码位置 | 验证说明 |
|------|------|----------|----------|
| P0-1 | ⚠️ 部分 | `_run.sh` L6, `smtp.py` | `_run.sh` 已确认使用 `smtp_v2.py`,但旧版 `smtp.py` 仍存在于代码库中,可被误启动 |
| P0-1 | ✅ 已修复 | ~~`smtp.py`~~ | 旧版 `smtp.py` 已删除下线(2026-08-04),杜绝误启动;`_run.sh` 使用 `smtp_v2.py` |
| P0-2 | ❌ 未修复 | `smtp_v2.py` L113-117 | 使用 pymsg RpcServer,无认证机制(与 pymsg P0-3 同源),依赖 127.0.0.1 网络隔离 |
| P1-1 | ❌ 未修复 | `smtp_v2.py` L90 | 验证码存储在进程内存 `code_store` dict,服务重启后全部失效 |
| P1-2 | ✅ 已修复 | `smtp_v2.py` L82, L191 | `SMTP_HOURLY_LIMIT` 从环境变量正确加载(默认 100),全局熔断逻辑正确生效 |
@@ -34,15 +34,15 @@
| 优先级 | 总数 | 已修复 | 待修复 |
|--------|------|--------|--------|
| P0 | 2 | 1(v2已修复) | 1(旧版需下线) |
| P0 | 2 | 2 | 0 |
| P1 | 2 | 1 | 1 |
---
## 🔴 P0 — 严重问题
### P0-1: 旧版 smtp.py 存在大量致命漏洞(需立即下线) ⚠️ 部分修复
- **位置**: `smtp.py`、`_run.sh`
### P0-1: 旧版 smtp.py 存在大量致命漏洞(需立即下线) ✅ 已修复
- **位置**: ~~`smtp.py`~~、`_run.sh`
- **问题**: 旧版 smtp.py 缺乏关键防护:
1. 无发送速率限制(可被用于邮件炸弹)
2. 无同邮箱 60 秒发送冷却
@@ -50,10 +50,7 @@
4. 无连续失败锁定机制
5. 存在 `NameError` 风险:`server.quit()` 在 finally 块中,若 SMTP 连接失败导致 `server` 未赋值则崩溃
- **已确认**: `_run.sh` L6 使用 `exec python3 smtp_v2.py`,生产环境已切换至新版
- **仍缺失**: 旧版 `smtp.py` 仍存在于代码库中,可被误启动
- **修复建议**:
1. 删除或重命名旧版 `smtp.py`(如 `smtp.py.deprecated`)防止误用
2. 在代码注释中明确标记废弃
- **修复 (2026-08-04)**: 旧版 `smtp.py` 已**删除下线**,杜绝误启动;README 中相关描述已同步更新
### P0-2: 依赖 pymsg 无认证(缓解:绑定 127.0.0.1)
- **说明**: 与其他 pymsg 服务相同,当前依赖网络层隔离
@@ -85,8 +82,8 @@
## 修复路线图
### 立即(24小时)
- [ ] 确认 smtp 服务运行的是 smtp_v2.py,旧版下线
- [ ] 删除或隔离 smtp.py 防止误用
- [x] 确认 smtp 服务运行的是 smtp_v2.py,旧版下线 ✅ 已确认(`_run.sh` 使用 `smtp_v2.py`)
- [x] 删除或隔离 smtp.py 防止误用 ✅ 已删除(2026-08-04)
### 短期
- [ ] 验证全局熔断配置生效
+1 -2
View File
@@ -144,7 +144,6 @@ python3 smtp_v2.py --addr 127.0.0.1 --port 8008
```
smtp/
├── smtp_v2.py # 服务主程序(pymsg 协议,当前有效版本)
├── smtp.py # 旧版 Flask HTTP 实现(已废弃,仅作参考)
├── log_client.py # 集中日志推送 SDK
├── _set.sh # 初始化脚本(创建 venv + 安装依赖)
├── _run.sh # 启动脚本(exec python3 smtp_v2.py)
@@ -156,7 +155,7 @@ smtp/
- 验证码存储在进程内存中,服务重启后全部失效;不支持多实例水平扩展。
- 默认仅监听 `127.0.0.1`,仅供本机调用;浏览器侧不直接访问本服务。
- 旧版 `smtp.py`(Flask HTTP 实现)已废弃,`_run.sh` 当前启动的是 `smtp_v2.py`。旧版存在无冷却、无失败回滚、无防枚举等安全问题,且 `verify_email` 在 email 不存在时有 KeyError 风险,不应在生产环境使用。
- 旧版 `smtp.py`(Flask HTTP 实现)因存在无冷却、无失败回滚、无防枚举、`verify_email` 在 email 不存在时 KeyError、`server.quit()` NameError 等安全问题,已**删除下线**。`_run.sh` 当前启动的是 `smtp_v2.py`。
- 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,`MAIL_FROM` 仅当服务商允许自定义发件身份时才起作用。
- 验证码为纯 6 位数字,有效期 600 秒。
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env python3
from flask import Flask, request, jsonify
from email.mime.text import MIMEText
from email import header
from log_client import logs
from dotenv import load_dotenv
import waitress
import smtplib
import random
import time
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
app = Flask(__name__)
captchas: dict[str, list[str, str]] = {}
@app.route("/send_email", methods=["POST"])
def send_email():
def build_captcha() -> str:
captcha: str = ""
for i in range(0,6):
digit: int = random.randint(0,9)
captcha += str(digit)
return captcha
data = request.get_json()
recipient: str = data.get("email")
subject: str = "DNFS 邮箱验证"
captcha: str = build_captcha()
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
captchas[recipient] = ["", ""]
captchas[recipient][0] = captcha
captchas[recipient][1] = str(int(time.time())+60)
message = MIMEText(content, "plain", "UTF-8")
message["From"] = "dnfs@qsont.me"
message["To"] = recipient
message["Subject"] = subject
try:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
server.login(SMTP_USER, __PASSWORD)
server.sendmail(SMTP_USER, [recipient], message.as_string())
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
return jsonify({"success": "验证码发送成功"}), 200
except Exception as err:
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
return jsonify({"error": f"验证码发送失败:{err}"}), 400
finally:
server.quit()
@app.route("/verify_email", methods=["POST"])
def verify_email():
data = request.get_json()
for i in list(captchas.keys()):
if int(captchas[i][1]) < int(time.time()):
del captchas[i]
email: str = data.get("email")
input_captcha: str = data.get("input_captcha")
if captchas[email][0] == input_captcha:
logs.info("smtp", f"{email}: 验证通过")
return jsonify({"success": "验证通过"}), 200
else:
logs.info("smtp", f"{email}: 验证失败")
return jsonify({"error": "验证失败"}), 400
if __name__ == "__main__":
logs.info("smtp", "开始监听http://127.0.0.1:8008")
print("Listen at http://127.0.0.1:8008")
waitress.serve(app, host="127.0.0.1", port=8008)