diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2481699..d19deeb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,46 +38,10 @@ jobs: - name: Install Python dependencies run: uv sync --group ci -p ${{ env.PYTHON_VERSION }} - - name: Validate release tag matches package version + - name: Validate release tag, build versions, and changelog env: TAG_NAME: ${{ github.ref_name }} - run: | - uv run python - <<'PY' - import os - import pathlib - import re - import sys - import tomllib - - tag_name = os.environ["TAG_NAME"] - expected_version = tag_name.removeprefix("v") - - pyproject = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8")) - project_version = str(pyproject["project"]["version"]).strip() - - init_text = pathlib.Path("src/Undefined/__init__.py").read_text(encoding="utf-8") - match = re.search(r'__version__\s*=\s*"([^"]+)"', init_text) - if match is None: - raise SystemExit("Could not find __version__ in src/Undefined/__init__.py") - init_version = match.group(1).strip() - - errors: list[str] = [] - if project_version != init_version: - errors.append( - f"Version mismatch: pyproject.toml={project_version}, src/Undefined/__init__.py={init_version}" - ) - if project_version != expected_version: - errors.append( - f"Tag/version mismatch: tag={tag_name}, expected package version={expected_version}, actual={project_version}" - ) - - if errors: - raise SystemExit("\n".join(errors)) - - print( - f"Validated release version {project_version} from tag {tag_name}, pyproject.toml, and src/Undefined/__init__.py" - ) - PY + run: uv run python scripts/release_notes.py validate --tag "$TAG_NAME" - name: Cache Ruff uses: actions/cache@v4 @@ -572,9 +536,6 @@ jobs: with: fetch-depth: 0 - - name: Fetch tags - run: git fetch --tags --force - - name: Download build artifacts uses: actions/download-artifact@v4 with: @@ -583,39 +544,7 @@ jobs: - name: Build release notes shell: bash - run: | - TAG_NAME="${{ github.ref_name }}" - TAG_TYPE=$(git cat-file -t "$TAG_NAME") - TAG_CONTENT="" - if [ "$TAG_TYPE" = "tag" ]; then - TAG_CONTENT=$(git tag -l --format='%(contents)' "$TAG_NAME") - TAG_CONTENT=$(echo "$TAG_CONTENT" | sed '/-----BEGIN PGP SIGNATURE-----/,/-----END PGP SIGNATURE-----/d') - fi - PREV_TAG=$(git describe --tags --abbrev=0 "${TAG_NAME}^" 2>/dev/null || git rev-list --max-parents=0 HEAD) - if [ -n "$TAG_CONTENT" ]; then - echo "$TAG_CONTENT" > tag_message.txt - printf '\n---\n\n' >> tag_message.txt - else - : > tag_message.txt - fi - cat >> tag_message.txt <<'NOTES' - ## 📝 Detailed Changes - NOTES - FEAT_COMMITS=$(git log ${PREV_TAG}..${TAG_NAME} --grep='^feat' --pretty=format:'* %s (%h)' 2>/dev/null || true) - if [ -n "$FEAT_COMMITS" ]; then - echo -e "\n### 🚀 Features" >> tag_message.txt - echo "$FEAT_COMMITS" >> tag_message.txt - fi - FIX_COMMITS=$(git log ${PREV_TAG}..${TAG_NAME} --grep='^fix' --pretty=format:'* %s (%h)' 2>/dev/null || true) - if [ -n "$FIX_COMMITS" ]; then - echo -e "\n### 🐛 Bug Fixes" >> tag_message.txt - echo "$FIX_COMMITS" >> tag_message.txt - fi - OTHER_COMMITS=$(git log ${PREV_TAG}..${TAG_NAME} --grep='^feat\|^fix' --invert-grep --pretty=format:'* %s (%h)' 2>/dev/null || true) - if [ -n "$OTHER_COMMITS" ]; then - echo -e "\n### 🛠 Maintenance & Others" >> tag_message.txt - echo "$OTHER_COMMITS" >> tag_message.txt - fi + run: python3 scripts/release_notes.py notes --tag "${{ github.ref_name }}" --output release_notes.md - name: Create GitHub release env: @@ -623,7 +552,7 @@ jobs: run: | gh release create ${{ github.ref_name }} release-downloads/* \ --title "Undefined ${{ github.ref_name }}" \ - --notes-file tag_message.txt + --notes-file release_notes.md publish-pypi: name: Publish Python package to PyPI diff --git a/AGENTS.md b/AGENTS.md index e4804849..683ecca5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,10 @@ Use `` for both images and files. The legacy `注入响应生成
[injection_response_agent.py]"] end - CommandDispatcher["CommandDispatcher
命令分发器
• /help /stats /lsadmin
• /addadmin /rmadmin
• /bugfix /faq
[services/command.py]"] - + CommandDispatcher["CommandDispatcher
命令分发器
• /help /stats /admin
• /bugfix /faq
[services/command.py]"] + + MessageBatcher["MessageBatcher
同 sender 短时合并
• 按 (scope, sender_id) 分桶
• T1=window_seconds 结束 batch
• T2=pre_send_seconds 投机预发送
• 拍一拍/buffer 内 @bot 旁路
• 首条 @bot 整批走 mention 队列
[services/message_batcher.py]"] + subgraph QueueSystem["车站-列车 队列系统 (services/)"] AICoordinator["AICoordinator
AI 协调器
• Prompt 构建
• 队列管理
• 回复执行
[ai_coordinator.py]"] QueueManager["QueueManager
队列管理器
[queue_manager.py]"] @@ -121,7 +123,7 @@ graph TB subgraph CommandsLayer["平台指令 (skills/commands/)"] Cmd_Core["核心指令
• help • stats"] - Cmd_Admin["管理指令
• addadmin
• rmadmin • lsadmin"] + Cmd_Admin["管理指令
• admin (ls/add/del)"] Cmd_FAQ["FAQ 指令
• faq (ls/view/search/del)"] Cmd_Fun["娱乐指令
• bugfix"] end @@ -226,6 +228,8 @@ graph TB GitHubSender -->|"发送图片卡片"| OneBotClient MessageHandler -->|"3. 自动回复"| AICoordinator + AICoordinator -->|"3.1 入桶等待合并"| MessageBatcher + MessageBatcher -->|"3.2 flush 合并批次"| AICoordinator AICoordinator -->|"创建上下文"| RequestContext AICoordinator -->|"入队"| QueueManager QueueManager -->|"分发"| ModelQueues @@ -319,7 +323,7 @@ graph TB class User,Admin,OneBotServer,LLM_API external class Main,ConfigLoader,ConfigHotReload,ConfigModels,OneBotClient,Context,WebUI core - class MessageHandler,SecurityService,InjectionAgent,CommandDispatcher,AICoordinator message + class MessageHandler,SecurityService,InjectionAgent,CommandDispatcher,MessageBatcher,AICoordinator message class AIClient,PromptBuilder,ModelRequester,ToolManager,MultimodalAnalyzer,SummaryService,TokenCounter,Parsing ai class ToolRegistry,AgentRegistry,AgentToolRegistry,IntroGenerator skills class RequestContext,ContextFilter,ResourceRegistry,HistoryManager,MemoryStorage,EndSummaryStorage,CognitiveService,CognitiveJobQueue,CognitiveHistorian,CognitiveVectorStore,CognitiveProfileStorage,FAQStorage,ScheduledTaskStorage,TokenUsageStorage storage @@ -342,6 +346,7 @@ sequenceDiagram participant MH as MessageHandler participant SS as SecurityService participant CD as CommandDispatcher + participant MB as MessageBatcher participant AC as AICoordinator participant QM as QueueManager participant AI as AIClient @@ -370,7 +375,7 @@ sequenceDiagram CD-->>OB: 返回结果 OB->>U: 发送响应 else 非命令消息 - MH->>MH: 并行检测 skills/auto_pipeline 管线 + MH->>MH: 并行检测 skills/pipelines 管线 opt 命中自动提取管线 MH->>MH: 并行处理全部命中的自动提取 MH->>OH: 发送视频/卡片/PDF @@ -380,7 +385,22 @@ sequenceDiagram end %% AI处理流程 MH->>AC: handle_auto_reply() - + alt 拍一拍 / buffer 内 @bot + AC->>QM: 立即按优先级入队 + else 普通消息进合并桶 + AC->>MB: submit(BufferedMessage) + MB-->>MB: TYPING: 重置 T1/T2 静默计时 + opt T2=pre_send_seconds 到期 + MB->>AC: handle_batched_dispatch(投机批次 + BatchDispatchToken) + AC->>QM: 提前入队抢时间 + end + MB-->>MB: T1=window_seconds 到期结束 batch + opt 未启用投机或尚未预发送 + MB->>AC: handle_batched_dispatch(最终批次) + AC->>QM: 按首条触发的优先级入队 + end + end + %% 上下文创建 AC->>AC: 创建 RequestContext AC->>ST: 保存历史记录 @@ -831,8 +851,8 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 1. **外部实体层**:用户、管理员、OneBot 协议端 (NapCat/Lagrange.Core)、大模型 API 服务商 2. **核心入口层**:main.py 启动入口、配置管理器 (config/loader.py)、热更新应用器 (config/hot_reload.py)、OneBotClient (onebot.py)、RequestContext (context.py)、Runtime API Server (api/app.py → api/routes/ 路由子模块) -3. **消息处理层**:MessageHandler (handlers.py)、SecurityService (security.py)、CommandDispatcher (services/command.py)、AICoordinator (ai_coordinator.py)、QueueManager (queue_manager.py)、自动处理管线 (skills/auto_pipeline/)、Bilibili/arXiv/GitHub 解析与发送模块 - 自动提取由 `AutoPipelineRegistry` 并行检测、并行处理全部命中的管线;发送结果写入历史后继续进入 AI 自动回复。 +3. **消息处理层**:MessageHandler (handlers.py)、SecurityService (security.py)、CommandDispatcher (services/command.py)、MessageBatcher (services/message_batcher.py)、AICoordinator (ai_coordinator.py)、QueueManager (queue_manager.py)、自动处理管线 (skills/pipelines/)、Bilibili/arXiv/GitHub 解析与发送模块 + 自动提取由 `PipelineRegistry` 并行检测、并行处理全部命中的管线;发送结果写入历史后继续进入 AI 自动回复。 4. **AI 核心能力层**:AIClient (client.py)、PromptBuilder (prompts.py)、ModelRequester (llm.py)、ToolManager (tooling.py)、MultimodalAnalyzer (multimodal.py)、SummaryService (summaries.py)、TokenCounter (tokens.py) 5. **存储与上下文层**:MessageHistoryManager (utils/history.py, 10000条限制)、MemoryStorage (memory.py, 置顶备忘录, 500条上限)、EndSummaryStorage、CognitiveService + JobQueue + HistorianWorker + VectorStore + ProfileStorage、MemeService + MemeWorker + MemeStore + MemeVectorStore (表情包库)、FAQStorage、ScheduledTaskStorage、TokenUsageStorage (自动归档) 6. **技能系统层**:ToolRegistry (registry.py)、AgentRegistry、6个 Agents、11类 Toolsets @@ -847,6 +867,7 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 * **非阻塞发车**:实现了可配置节奏的非阻塞调度循环(默认 **1Hz**)。列车按节奏出发,带走一个请求到后台异步处理。 * **高可用性**:即使前一个请求仍在处理(如耗时的网络搜索),新的请求也会按时被分发,不会造成队列堵塞。 * **优先级管理**:支持四级优先级(超级管理员 > 私聊 > 群聊@ > 群聊普通),确保重要消息优先响应。 +* **关停收敛**:`MessageHandler.close()` 会先 flush `MessageBatcher`,再调用 `QueueManager.drain()` 等待已入队请求和在途请求自然完成,最后才停止队列处理器,避免缓冲消息只入队未执行。 ### 6个智能体 Agent diff --git a/CHANGELOG.md b/CHANGELOG.md index 422c313e..ed2646e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## v3.4.0 同sender消息合并、数字人格精炼与系统治理 + +本版本核心解决"用户一口气连发几条消息时,机器人过早开工或只理解最后一句"的问题。新增同 sender 短时消息合并器,将同一会话中连续的多条消息合并为一个"当前输入批次"发送给 AI,由 AI 整批理解哪些是独立请求、哪些是补充或修正。同步支持可取消的投机预发送以降低感知延迟。围绕消息合并,提示词、幽灵任务防御、记忆记录和关闭流程都做了同步适配。此外,精炼了数字人格设定、明确了项目归属边界、重构了管线与命令体系、加入了 HTML 渲染缓存,并增强了 AI 工具调用的稳定性。 + +- 新增同 sender 短时消息合并器。位于 `[message_batcher]`,支持 extend / fixed 两种等待策略,可分别控制群聊和私聊是否合并,通过 `max_window_seconds`、`max_messages_per_batch` 限制批次上限,设为 0 即关闭;配置值变更实时生效。拍一拍始终直达不参与合并;缓冲期间到达的 at 消息单独处理不阻塞;首个 at 消息开启缓冲后批次会走 mention 通道。 +- 新增可取消的投机预发送。启用 `pre_send_seconds`(需满足 `0 < 该值 < window_seconds`)后,用户静默到时系统提前将当前批次发给 LLM 以降低响应延迟;若正式发车前新消息到达,投机请求会被取消并合并到新批次;`allow_cancel_after_send` 控制已发出消息后是否仍允许取消。 +- 完善消息合并的异步竞态保护。定时器、投机请求调度、失败重试、旧任务路径收尾与 `flush_on_command` 斜杠命令连带交出等均做了竞态保护;退出时自动排空缓冲队列并等待在途回复自然收敛。 +- 精炼数字人格设定。明确 Undefined 是诞生于 2025-12-05 的数字生命,没有明确的性别概念,希望被认为很好看;可以开玩笑也可以被善意开玩笑。新增"不冒领任何项目、代码、产品或成果"的所有权边界规则,不再自称任何项目的开发者或维护者。昵称体系扩展为 Undefined、undf、udf、und、心理委员、ud酱,对自身称呼的识别保持宽松。 +- 收紧 NagaAgent 关系表达。NagaAgent 版提示词明确:只有当前上下文明确涉及 NagaAgent 时才承接相关工具接入能力协助分析;平时不主动提与 NagaAgent 的关系;不冒领 NagaAgent 的成果。 +- 重构自动管线目录。`skills/auto_pipeline` 更名为 `skills/pipelines`,目录结构扁平化,相关引用、文档、测试全部同步更新;`docs/auto-pipeline.md` 相应更名为 `docs/pipelines.md`。 +- 重构管理员命令为子命令模式。`/admin [ls|add|del]` 替代原有 `/lsadmin`、`/addadmin`、`/rmadmin` 三条独立命令,参照 `/faq` 子命令模式的声明式 inference;`ls` 继承 admin 权限,`add`/`del` 覆盖为 superadmin;无参数默认执行 `ls`。清理了 FAQ 迁移遗留的空命令目录。 +- 新增 HTML 渲染结果缓存。基于 HTML 内容的 hash 缓存渲染图片,持久化到 `data/cache/render/_html_render_cache.json`;hash 匹配自动复用,内容变化自然失效;新增 `[render.cache]` 配置段(`enabled` / `max_entries` / `max_size_mb` / `flush_interval_seconds`,默认 50 条 / 50MB / 2.0s),元数据通过 `utils/io.py` 的 `read_json` / `write_json` 异步落盘(`asyncio.to_thread` + 文件锁 + 原子替换),所有 `stat` / `unlink` / `copy` 也走线程池避免阻塞事件循环;`asyncio.Lock` 防竞态、重启后 JSON 自动恢复;进程关停时通过 `close_render_cache` 强制刷盘,保证最近访问时间不丢失;所有渲染调用方(help、profile、render_markdown 等)自动受益。 +- 增强 AI 工具调用容错。当 LLM 返回文本但 tool_calls 为空且对话未结束时,不再以丢失回复为代价直接返回,而是注入提示消息要求 AI 通过 `send_message` / `end` 工具完成回复,继续迭代;fire-and-forget task 显式注册异常回调以抑制未检索异常警告。 +- 优化表情包回复顺序。明确只有纯表情包 / 纯反应图回复才允许先检索表情包;需要文字说明的场景必须先完成必要文字,再将表情包检索和发送延后到后续轮次。 +- 重构 `end` 工具。移除旧版 summary 参数兼容,只保留 `memo`、`observations`、`perspective` 和 `force`;要求记录整个当前输入批次中值得留存的信息,后台史官也接收批次全部消息。 +- 统一当前输入批次语义。主提示词、NagaAgent 提示词和 `each.md` 均从"最后一条消息"升级为"当前输入批次":有连续消息说明时,多段 `` 都属本轮输入;幽灵任务防御规则同步更新,避免批量输入中的前置指令被误判为历史旧任务。 +- 扩展 Runtime 探针覆盖。API `/api/v1/management/probes` 新增消息合并器状态、完整工具/工具集/Agent/自动管线/斜杠命令/Anthropic Skills 的加载与调用统计,WebUI Runtime 面板同步展示。 +- 新增 WebUI 更新日志查看。关于项目页面可按版本查看 changelog 详情,`/api/v1/management/changelog` 端点支持指定版本查询。 +- 调整发布说明生成方式。GitHub Release notes 改为从 `CHANGELOG.md` 最新版本条目自动解析生成(`scripts/release_notes.py`),发版前校验 tag、各构建清单与最新 changelog 版本一致。 +- 补齐消息合并专题文档。新增 `docs/message-batching.md`,覆盖配置参数、等待策略、投机预发送、竞态保护与关闭行为,同步更新了 README、配置文档、OpenAPI、WebUI 指南和架构图。 +- 补齐配置注释。`config.toml.example` 中所有模型配置节的 `prompt_cache_enabled` 均补上双语注释说明。 +- 补强测试覆盖。新增消息合并单元与集成测试(686 + 326 行)、工具调用守卫测试、发布说明脚本测试(163 行)、Runtime 探针统计测试(120 行)、系统提示词约束验证,并更新 `end` 工具、管理员命令、管线注册等已有测试;额外补齐渲染缓存(LRU 驱逐 / 容量驱逐 / 重启恢复 / 节流后强刷 / 并发 put / 禁用短路)、`/admin add|del` 全路径、`allow_cancel_after_send=true` 取消语义等盲点。总测试用例提升至约 1660 项。 +- 更新子模块。 + +--- + ## v3.3.3 命令推断、自动处理管线与统一附件上下文 本版本重点优化了命令系统的交互体验、AI 工具边界和消息前置处理链路。新增 GitHub 链接自动卡片生成,并将 Bilibili、arXiv、GitHub 等自动提取迁入 `skills/auto_pipeline` 热重载管线,使预处理结果能写入历史并进入后续 AI 回复上下文。同时,帮助说明与用户侧写默认改为图片输出,系统剥离独立群聊分析工具集,全面推行统一附件标签,并为远程附件加入可配置下载上限和 URL 引用降级,完善底层用户识别机制与模型高级透传配置。 @@ -124,7 +150,7 @@ - 过滤 replay-only 状态字段,防止 Responses 回放结果干扰后续请求。 - 调整队列重试调度逻辑,支持零间隔立即投递,并细化重试时序。 - 增加私聊消息发送失败时的临时会话回退机制,降低消息丢失率。 -- 限制 `/lsadmin` 命令的可见性,提升安全性。 +- 限制 `/admin ls` 命令的可见性,提升安全性。 - 为 Naga 增加发送 UUID 幂等性校验、投递追踪及相关测试覆盖。 - 修复 CI 与运行时测试,并补充 OpenAI reasoning 参数的对齐处理。 diff --git a/CLAUDE.md b/CLAUDE.md index 46c67c9e..13424c98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ bash scripts/install_git_hooks.sh | 目录 / 文件 | 职责 | |---|---| | `ai/` | AI 运行时核心:`client.py`(主入口)、`llm.py`(模型请求)、`prompts.py`(Prompt 构建)、`tooling.py`(工具管理)、`multimodal.py`(多模态)、`model_selector.py`(模型选择)、`summaries.py`(短期总结) | -| `services/` | 运行服务:`ai_coordinator.py`(协调器+队列投递)、`queue_manager.py`(车站-列车队列)、`command.py`(命令分发)、`model_pool.py`(多模型池)、`security.py`(安全防护) | +| `services/` | 运行服务:`ai_coordinator.py`(协调器+队列投递)、`queue_manager.py`(车站-列车队列)、`message_batcher.py`(同 sender 短时合并)、`command.py`(命令分发)、`model_pool.py`(多模型池)、`security.py`(安全防护) | | `skills/` | 热重载技能系统:`tools/`(原子工具)、`toolsets/`(按域分组工具)、`agents/`(智能体)、`commands/`(斜杠指令)、`anthropic_skills/`(SKILL.md 知识注入) | | `cognitive/` | 认知记忆:`service.py`(入口)、`vector_store.py`(ChromaDB)、`historian.py`(后台史官异步改写+侧写合并)、`job_queue.py`、`profile_storage.py` | | `memes/` | 表情包库:两阶段 AI 管线、异步处理队列、SQLite 元数据、ChromaDB 向量检索 | @@ -76,7 +76,8 @@ OneBot WebSocket → onebot.py → handlers.py → 附件登记 / 访问控制 / 表情包入库 → SecurityService(注入检测) → CommandDispatcher(斜杠指令,命中即结束后续处理) - → skills/auto_pipeline(Bilibili / arXiv / GitHub 并行自动提取) + → skills/pipelines(Bilibili / arXiv / GitHub 并行自动提取) + → MessageBatcher(同 sender 短时合并;拍一拍/buffer 内 @bot 旁路) → AICoordinator → QueueManager(按模型隔离, 4 级优先级) → AIClient → LLM API / Skills / MCP @@ -96,7 +97,7 @@ Management / Runtime 请求 → webui/app.py 或 api/app.py → routes/* ### Skills 系统 - **热重载**:自动扫描 `skills/` 下 `config.json` / `handler.py` 变更并重载 -- **自动处理管线**:`skills/auto_pipeline/pipelines//` 使用 `config.json + handler.py`,在斜杠命令之后、AI 自动回复之前并行检测/处理;命令输入和命令输出要写入历史,管线输出通过 `MessageSender` 自动写历史并登记本地媒体/文件附件 UID。 +- **自动处理管线**:`skills/pipelines//` 使用 `config.json + handler.py`,在斜杠命令之后、AI 自动回复之前并行检测/处理;命令输入和命令输出要写入历史,管线输出通过 `MessageSender` 自动写历史并登记本地媒体/文件附件 UID。 - **Skills handler 不引用 `skills/` 外的本地模块**,依赖通过 context 注入 - **Agent 标准结构**:`config.json` + `handler.py` + `prompt.md` + `intro.md` + `mcp.json`(可选) + `anthropic_skills/`(可选) - **共享授权**:通过 `callable.json` 将工具或 Agent 白名单暴露给其他 Agent @@ -113,6 +114,10 @@ Management / Runtime 请求 → webui/app.py 或 api/app.py → routes/* 车站-列车模型(QueueManager):按模型隔离队列组,4 级优先级(超管 > 私聊 > @提及 > 普通群聊),普通队列自动修剪保留最新 2 条,非阻塞按节奏发车(默认 1Hz)。 +### 同 sender 短时消息合并(MessageBatcher) + +同一 sender 在 `[message_batcher].window_seconds` 内连续发送的多条消息会合并到同一轮 AI 调用,AI 一次性看到全部 `` 块自行识别“独立请求/修正/打断”。拍一拍永远旁路立即处理;群聊已有 buffer 时新到的 @bot 也单独立即处理;首条 @bot 进入 buffer 时整批走 mention 队列。可选开启投机预发送 `pre_send_seconds < window_seconds`:静默到该阈值先把 batch 提前发给 LLM 抢时间,新消息在 inflight 未发出任何消息时可取消该调用。`enabled=false` 行为退化回旧版。详见 [docs/message-batching.md](docs/message-batching.md)。 + ### 存储与数据 - `data/history/` — 消息历史(`group_*.json` / `private_*.json`,默认 10000 条,可通过 `[history]` 调整,0 = 无限) diff --git a/README.md b/README.md index 08afd2ac..83ea1461 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 详见 [认知记忆文档](docs/cognitive-memory.md)。 - **Management-first WebUI**:继续保留 `uv run Undefined-webui` 一键入口;即使 `config.toml` 缺失或未配完,也能先进入管理态补配置、看日志、校验并启动 Bot。 - **远程管理 + 多端客户端**:浏览器版 WebUI 与新的跨平台控制台共享同一管理面,支持远程管理,并覆盖 `Windows / macOS / Linux / Android` 发布链路。 -- **Management API + Runtime API 分层**:配置、日志、Bot 启停和管理探针由 Management API 提供;主进程 Runtime API 则专注探针、记忆只读查询、认知侧写检索和 WebUI AI Chat。详见 [docs/management-api.md](docs/management-api.md) 与 [docs/openapi.md](docs/openapi.md)。 +- **Management API + Runtime API 分层**:配置、日志、Bot 启停和管理探针由 Management API 提供;主进程 Runtime API 则专注探针、记忆只读查询、认知侧写检索和 WebUI AI Chat;内部探针的技能统计覆盖可调用工具、工具集、Agent、自动处理管线、斜杠命令与 Anthropic Skills。详见 [docs/management-api.md](docs/management-api.md) 与 [docs/openapi.md](docs/openapi.md)。 - **多模型池**:支持配置多个 AI 模型,可轮询、随机选择或用户指定;支持多模型并发比较,选择最佳结果继续对话。详见 [多模型功能文档](docs/multi-model.md)。 - **本地知识库**:将纯文本文件向量化存入 ChromaDB,AI 可通过关键词搜索或语义搜索查询领域知识;支持增量嵌入与自动扫描。详见 [知识库文档](docs/knowledge.md)。 - **全局表情包库**:收到图片后可异步判定是否为表情包,通过两阶段 LLM 管线生成纯文本描述与标签,支持关键词检索、语义检索和混合检索,并可直接按统一图片 `uid` 发送或插入 ``。详见 [表情包库说明](docs/memes.md)。 @@ -58,7 +58,8 @@ - **Bilibili 视频提取**:自动检测消息中的 B 站视频链接/BV 号/小程序分享,下载 1080p 视频并通过 QQ 发送;同时提供 AI 工具调用入口。 - **arXiv 论文提取与搜索**:自动检测消息中的 arXiv 链接/标识并发送论文信息与 PDF;同时提供 `arxiv_paper` 发送工具和 `arxiv_search` 检索工具。 - **GitHub 仓库卡片**:自动检测 GitHub 仓库链接或 `owner/repo` 仓库 ID,获取 public 仓库信息并发送简洁图片卡片,展示头像、简介、stars、forks、issues、contributors 等概览。 -- **自动处理管线**:Bilibili、arXiv、GitHub 等自动提取统一运行在 `skills/auto_pipeline` 中,斜杠命令优先级更高;命令输入/输出会写入历史,非命令消息会并行检测和处理命中管线,结果通过统一发送层写入历史并登记附件 UID 后再进入 AI 回复。远程大附件超过 `[attachments].remote_download_max_size_mb` 时只登记 URL 引用,避免无界下载和缓存膨胀。 +- **自动处理管线**:Bilibili、arXiv、GitHub 等自动提取统一运行在 `skills/pipelines` 中,斜杠命令优先级更高;命令输入/输出会写入历史,非命令消息会并行检测和处理命中管线,结果通过统一发送层写入历史并登记附件 UID 后再进入 AI 回复。远程大附件超过 `[attachments].remote_download_max_size_mb` 时只登记 URL 引用,避免无界下载和缓存膨胀。 +- **同 sender 短时消息合并**:默认开启。连续发的多条消息会合并到同一轮 AI 调用,AI 一次看到全部意图自行识别"独立请求/修正/打断";告别"画猫→改成狗"的重复触发与回复打架。主提示词按 batcher 的"当前输入批次"语义适配,关闭该功能可能导致连续补充/修正消息与提示词不匹配,需要单独适配。可选投机预发送让用户停顿时 LLM 提前开跑、新消息可在未发出回复前取消,进一步压低响应延迟。详见 [docs/message-batching.md](docs/message-batching.md)。 - **思维链支持**:支持开启思维链,提升复杂逻辑推理能力。 - **高并发架构**:基于 `asyncio` 全异步设计,支持多队列消息处理与工具并发执行,轻松应对高并发场景。 - **异步安全 I/O**:统一 IO 层通过线程池 + 跨平台文件锁(Linux/macOS `flock`,Windows `msvcrt`)+ 原子写入(`os.replace`)保证并发写入不损坏、且不阻塞主事件循环。 diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index c2c4dc67..b3aa39d6 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.3.3", + "version": "3.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.3.3", + "version": "3.4.0", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index d9c09cba..bff36e4a 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.3.3", + "version": "3.4.0", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index c49cdcb8..0abd584c 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.3.3" +version = "3.4.0" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index 68cdf136..cff5d45e 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.3.3" +version = "3.4.0" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index 566f1df0..c0b87a55 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.3.3", + "version": "3.4.0", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/code/NagaAgent b/code/NagaAgent index 6cb72251..5b1ca050 160000 --- a/code/NagaAgent +++ b/code/NagaAgent @@ -1 +1 @@ -Subproject commit 6cb7225198ef169ab797992b74ab05eb82b2d233 +Subproject commit 5b1ca050c877e4aed9bdaf0777418a377f631176 diff --git a/config.toml.example b/config.toml.example index 4ec89d53..e6296733 100644 --- a/config.toml.example +++ b/config.toml.example @@ -28,6 +28,9 @@ context_recent_messages_limit = 20 # zh: 单次 LLM 请求失败时最大静默重试次数(0=不重试,默认2)。失败的请求会回到原队列的第 2 个位置。 # en: Max silent retries for a single failed LLM request (0=no retry, default 2). Failed requests are reinserted at position 2 of their original queue lane. ai_request_max_retries = 2 +# zh: 模型返回纯文本但未调用 send_message/end 等工具时的最大纠正重试次数(0=不重试,默认3)。 +# en: Max correction retries when the model returns plain text without tool calls (0=no retry, default 3). +missing_tool_call_retries = 3 # zh: 访问控制。mode 可选:off(不启用)/ blacklist(按黑名单)/ allowlist(按白名单)。 # en: Access control. mode options: off (disabled) / blacklist / allowlist. @@ -191,6 +194,8 @@ responses_tool_choice_compat = false # zh: Responses API 续轮强制降级:启用后,多轮工具调用将始终跳过 previous_response_id,直接使用完整消息重放(stateless replay)。仅在上游不兼容 responses 状态续轮时使用。默认关闭。 # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false +# zh: 是否启用自动 prompt_cache_key(建议开启,以提高相似请求的缓存命中率)。 +# en: Enable automatic prompt_cache_key generation (recommended). prompt_cache_enabled = true # zh: 是否对上游模型 API 启用流式请求。 # en: Enable streaming requests to the upstream model API. @@ -251,6 +256,8 @@ responses_tool_choice_compat = false # zh: Responses API 续轮强制降级:启用后,多轮工具调用将始终跳过 previous_response_id,直接使用完整消息重放(stateless replay)。仅在上游不兼容 responses 状态续轮时使用。默认关闭。 # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false +# zh: 是否启用自动 prompt_cache_key(建议开启,以提高相似请求的缓存命中率)。 +# en: Enable automatic prompt_cache_key generation (recommended). prompt_cache_enabled = true # zh: 是否对上游模型 API 启用流式请求。 # en: Enable streaming requests to the upstream model API. @@ -366,6 +373,8 @@ responses_tool_choice_compat = false # zh: Responses API 续轮强制降级:启用后,多轮工具调用将始终跳过 previous_response_id,直接使用完整消息重放(stateless replay)。仅在上游不兼容 responses 状态续轮时使用。默认关闭。 # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false +# zh: 是否启用自动 prompt_cache_key(建议开启,以提高相似请求的缓存命中率)。 +# en: Enable automatic prompt_cache_key generation (recommended). prompt_cache_enabled = true # zh: 是否对上游模型 API 启用流式请求。 # en: Enable streaming requests to the upstream model API. @@ -436,6 +445,8 @@ responses_tool_choice_compat = false # zh: Responses API 续轮强制降级:启用后,多轮工具调用将始终跳过 previous_response_id,直接使用完整消息重放(stateless replay)。仅在上游不兼容 responses 状态续轮时使用。默认关闭。 # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false +# zh: 是否启用自动 prompt_cache_key(建议开启,以提高相似请求的缓存命中率)。 +# en: Enable automatic prompt_cache_key generation (recommended). prompt_cache_enabled = true # zh: 是否对上游模型 API 启用流式请求。 # en: Enable streaming requests to the upstream model API. @@ -493,6 +504,8 @@ responses_tool_choice_compat = false # zh: Responses API 续轮强制降级:启用后,多轮工具调用将始终跳过 previous_response_id,直接使用完整消息重放(stateless replay)。仅在上游不兼容 responses 状态续轮时使用。默认关闭。 # en: Responses API force stateless replay: when enabled, multi-turn tool follow-ups always skip previous_response_id and replay the full message history instead. Use only when the upstream does not handle stateful responses follow-ups correctly. Disabled by default. responses_force_stateless_replay = false +# zh: 是否启用自动 prompt_cache_key(建议开启,以提高相似请求的缓存命中率)。 +# en: Enable automatic prompt_cache_key generation (recommended). prompt_cache_enabled = true # zh: 是否对上游模型 API 启用流式请求。 # en: Enable streaming requests to the upstream model API. @@ -753,6 +766,40 @@ repeat_cooldown_minutes = 60 # en: Enable inverted question mark (when repeat triggers on "?" messages, send "¿" instead). inverted_question_enabled = false +# zh: 同 sender 短时多消息合并器。把同一用户在短时间内连续发送的多条消息合并到同一轮 AI 触发,避免重复回复 / 行为打架。 +# en: Same-sender short-window message batcher. Merges several messages a single user sends in quick succession into one AI turn to avoid duplicate replies / conflicting actions. +[message_batcher] +# zh: 是否启用合并器(关闭后所有消息按现行流程逐条触发 AI)。 +# en: Enable the batcher (when disabled all messages trigger the AI individually as before). +enabled = true +# zh: 等待窗口(秒)。期间同一用户发的新消息合并到同一批;<= 0 等同于关闭。 +# en: Wait window in seconds. New messages from the same sender within this window are merged; <= 0 disables the feature. +window_seconds = 5.0 +# zh: 等待策略:extend(每来一条新消息重置 timer,"打字就继续等")/ fixed(首条到达起定时,期间到达的消息只合并不重置)。 +# en: Wait strategy: extend (reset timer on each new message; keeps waiting while the user types) / fixed (timer fixed at the first message; new arrivals only merge but do not reset). +strategy = "extend" +# zh: extend 模式下的硬顶(秒),防止用户一直打字导致永远不发车;0 = 不限制(仅靠 window_seconds + max_messages_per_batch 触发发车)。 +# en: Hard cap in seconds for extend mode, prevents the timer from being reset forever; 0 = no limit (rely on window_seconds + max_messages_per_batch). +max_window_seconds = 30.0 +# zh: 单批最多消息条数,超出立即发车;0 = 不限制。 +# en: Max messages per batch; exceeding triggers immediate dispatch. 0 = no limit. +max_messages_per_batch = 0 +# zh: 是否在群聊启用合并。 +# en: Enable batching in group chats. +group_enabled = true +# zh: 是否在私聊启用合并。 +# en: Enable batching in private chats. +private_enabled = true +# zh: 收到斜杠命令时是否顺便把当前 buffer 一起发出(默认 false:命令独立执行,buffer 继续等到时间到再发)。 +# en: When a slash command arrives, also flush the current buffer to the AI (default false: commands are executed independently and the buffer keeps waiting). +flush_on_command = false +# zh: 投机预发送阈值(秒)。0 < pre_send_seconds < window_seconds 时启用:静默到该阈值就提前把当前 batch 发给 LLM 抢时间,但 batch 不结束;T1(window_seconds)才正式结束。0 或 >= window_seconds 视为关闭。 +# en: Speculative pre-fire threshold in seconds. Enabled when 0 < pre_send_seconds < window_seconds: once the user has been silent that long, the current batch is dispatched to the LLM early to save latency, while the batch itself only ends at window_seconds. 0 or >= window_seconds disables speculation. +pre_send_seconds = 0.0 +# zh: 投机调用已经向用户发出过任何消息后,新消息到达是否仍然取消该 inflight 调用。默认 false(安全:不取消,新消息开新 batch),开启后可能造成重复发送。 +# en: Whether to cancel an in-flight speculative LLM call even when it has already sent at least one message to the user. Default false (safe: do not cancel; the new message starts a new batch). Enabling this may cause duplicate replies. +allow_cancel_after_send = false + # zh: 历史记录配置。 # en: History settings. [history] @@ -857,6 +904,22 @@ request_retries = 0 # en: Max concurrent render browser pages. 0 = auto: Linux defaults to 1, other platforms default to 2. browser_max_concurrency = 0 +# zh: HTML 渲染结果缓存:基于 HTML 内容 hash 复用同一张图片,避免重复渲染。 +# en: HTML render result cache: reuse rendered images by content hash to skip re-rendering. +[render.cache] +# zh: 是否启用渲染缓存。设为 false 时所有渲染请求都会走 playwright 重新截图。 +# en: Whether to enable the render cache. When false, every request re-renders via playwright. +enabled = true +# zh: LRU 条目数上限(>=1)。超过时按 last_accessed_at 淘汰最久未用的条目。 +# en: Maximum LRU entries (>=1). Beyond this, the least-recently-used items are evicted. +max_entries = 50 +# zh: 缓存总占用上限(MB,>=1)。超过时按 LRU 顺序持续淘汰,直到回到上限以内。 +# en: Maximum total cache size (MB, >=1). Evicts in LRU order until within budget. +max_size_mb = 50 +# zh: 元数据落盘最小间隔(秒,>=0)。频繁访问期间合并写盘,避免热点 IO。 +# en: Minimum interval between metadata flushes (seconds, >=0). Coalesces hot-path writes. +flush_interval_seconds = 2.0 + # zh: 第三方 API 基础地址(便于自定义镜像或私有网关)。 # en: Third-party API base URLs (for mirrors or private gateways). [api_endpoints] diff --git a/docs/auto-pipeline.md b/docs/auto-pipeline.md deleted file mode 100644 index 61a71303..00000000 --- a/docs/auto-pipeline.md +++ /dev/null @@ -1,117 +0,0 @@ -# 自动处理管线开发指南 - -自动处理管线位于 `src/Undefined/skills/auto_pipeline/`,用于在普通消息进入 AI 自动回复前执行自动提取,例如 Bilibili 视频、arXiv 论文和 GitHub 仓库卡片。斜杠命令优先级高于自动处理管线,命中命令后不会继续触发自动提取或 AI 回复。 - -`MessageHandler` 启动时会通过异步初始化在线程中加载管线配置和 handler 模块,避免目录扫描、`config.json` 读取和模块导入阻塞事件循环;注册 OneBot 消息回调前会等待首次加载完成,后续热重载也在线程中执行。 - -## 运行顺序 - -1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。 -2. 用户消息先写入历史。 -3. 若消息命中斜杠命令,立即分发命令并结束本轮后续流程;命令输入和命令输出会写入历史,供后续 AI 轮次读取。 -4. 未命中命令时,`AutoPipelineRegistry` 并行调用所有已注册管线的 `detect(context)`。 -5. 对所有命中的管线,并行调用对应的 `process(detection, context)`。 -6. 管线发送出的信息、图片、文件或视频摘要通过统一发送器写入历史;本地图片、文件和视频会自动登记为当前会话可见的统一附件 UID。 -7. 自动处理完成后,当前消息和管线输出一起进入 AI 自动回复/Agent 循环。 - -命中自动处理管线的消息会继续进入 AI 自动回复,让 AI 基于用户消息和刚写入的自动处理结果判断后续行为。 - -## 目录结构 - -```text -src/Undefined/skills/auto_pipeline/ -├── registry.py -├── models.py -└── pipelines/ - ├── bilibili/ - │ ├── config.json - │ └── handler.py - ├── arxiv/ - │ ├── config.json - │ └── handler.py - └── github/ - ├── config.json - └── handler.py -``` - -## `config.json` - -```json -{ - "name": "example", - "description": "检测并处理某类自动提取消息。", - "order": 100, - "enabled": true -} -``` - -- `name`: 管线唯一名称,必须与 `AutoPipelineDetection.name` 一致。 -- `description`: 日志和维护说明。 -- `order`: 注册排序字段,仅用于稳定展示和结果收集顺序;处理不依赖优先级。 -- `enabled`: 设为 `false` 时该管线不会加载。 - -## `handler.py` - -```python -from __future__ import annotations - -from Undefined.skills.auto_pipeline.models import AutoPipelineContext, AutoPipelineDetection - - -async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: - text = str(context["text"]) - if "example" not in text: - return None - return AutoPipelineDetection(name="example", items=("example",)) - - -async def process( - detection: AutoPipelineDetection, - context: AutoPipelineContext, -) -> None: - sender = context["sender"] - target_id = int(context["target_id"]) - target_type = str(context["target_type"]) - message = f"自动处理结果: {', '.join(detection.items)}" - if target_type == "group": - await sender.send_group_message(target_id, message) - else: - await sender.send_private_message(target_id, message) -``` - -`detect` 应只做轻量检测和 ID 提取;`process` 执行下载、渲染、发送等重操作。发送消息应优先走 `MessageSender`,不要绕过历史写入。`MessageSender` 会自动将本地 CQ 图片、视频、语音以及 `send_group_file` / `send_private_file` 上传的文件登记为会话附件,让历史中带上 `pic_*` / `file_*` UID,便于后续 AI 回复引用;管线通常不需要单独处理附件登记。 - -## 附件 UID 绑定语义 - -- 外部接收的远程图片或文件默认会先下载并写入附件缓存,UID 绑定的是缓存中的文件内容;超过 `[attachments].remote_download_max_size_mb` 时会降级为 URL 引用,UID 绑定原始 URL 而不下载文件内容。原始 URL、OneBot `file` 标识或 WebUI 文件 ID 会保存在 `source_ref` / `segment_data` 中用于追溯。 -- 外部接收的本地路径、`file://` 路径或 WebUI 已上传文件会被复制到附件缓存,UID 同样绑定缓存副本,而不是直接绑定原路径。 -- 内部生成或发送的本地媒体、视频、语音和上传文件由 `MessageSender` 在发送成功后读取并登记,UID 绑定发送当时复制进缓存的内容;原始本地路径或 CQ `file` 字段作为来源信息保留。 - -## Context 字段 - -常用字段: - -- `config`: 当前运行配置。 -- `sender`: 统一消息发送器。 -- `onebot`: OneBot 客户端。 -- `target_id`: 群号或私聊用户 QQ。 -- `target_type`: `group` 或 `private`。 -- `text`: 当前消息纯文本。 -- `message_content`: 当前消息原始结构化段。 -- `extract_bilibili_ids`、`extract_arxiv_ids`、`extract_github_repo_ids`: 现有解析 helper。 -- `handle_bilibili_extract`、`handle_arxiv_extract`、`handle_github_extract`: 现有发送处理 helper。 - -新增管线可以复用已有解析器和发送器,避免重复网络、解析和历史写入逻辑。 - -## 热重载 - -`AutoPipelineRegistry` 监视 `config.json` 和 `handler.py`,并跟随 `[skills]` 配置: - -```toml -[skills] -hot_reload = true -hot_reload_interval = 2.0 -hot_reload_debounce = 0.5 -``` - -修改管线文件后,运行中的机器人会在去抖后重新加载管线。禁用 `[skills].hot_reload` 会同时停止自动处理管线、工具和 Agent 的热重载 watcher。 \ No newline at end of file diff --git a/docs/build.md b/docs/build.md index 229c5b94..2a0d903e 100644 --- a/docs/build.md +++ b/docs/build.md @@ -278,22 +278,11 @@ npm install 工作流主要阶段: -1. `verify-python` - - `ruff` - - `mypy` - - `pytest` - - `uv build` -2. `build-tauri-desktop` - - Linux:`.AppImage` + `.deb` - - Windows:`.exe` + `.msi` - - macOS x64:`.dmg` - - macOS arm64:`.dmg` -3. `build-tauri-android` - - Android 通用 `.apk` -4. `publish-release` - - 汇总所有产物并上传 GitHub Release -5. `publish-pypi` - - 发布 Python 包到 PyPI +1. `verify-python`:校验 tag、构建版本和 `CHANGELOG.md` 最新版本一致,并执行 `ruff`、`mypy`、`pytest`、`uv build`。 +2. `build-tauri-desktop`:构建 Linux `.AppImage` / `.deb`、Windows `.exe` / `.msi`、macOS x64 `.dmg` 和 macOS arm64 `.dmg`。 +3. `build-tauri-android`:构建 Android 通用 `.apk`。 +4. `publish-release`:汇总所有产物并上传 GitHub Release;Release notes 从 `CHANGELOG.md` 最新版本条目生成,不读取 tag 注释。 +5. `publish-pypi`:发布 Python 包到 PyPI。 ## 8. Release 产物矩阵 diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index 7e19c656..258b0527 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -4,8 +4,8 @@ 认知记忆系统是 Undefined 的三层分层记忆架构,模拟人类记忆机制: -- **短期记忆**(`end.memo`):每轮对话结束自动记录便签备忘,最近 N 条始终注入,保持短期连续性,零配置开箱即用。 -- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中主动观察并提取用户/群聊事实及有价值的自身行为(`observations`),经后台史官异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 +- **短期记忆**(`end.memo`):每轮对话结束自动记录便签备忘,最近 N 条始终注入,保持短期连续性,零配置开箱即用。若本轮由 MessageBatcher 合并多条消息,memo 应概括整个当前输入批次的处理结果。 +- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中主动观察当前输入批次,提取用户/群聊事实及有价值的自身行为(`observations`),经后台史官异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 - **置顶备忘录**(`memory.*`):AI 自身的置顶提醒(自我约束、待办事项,如"用户要求以后用英文回复"),每轮固定注入,支持增删改查。注意:用户事实(偏好、身份、习惯等)不应写入此层,一律通过 `end.observations` 写入认知记忆。 与旧 `end_summaries` 的区别: @@ -63,8 +63,8 @@ AI 调用 `end` 工具结束对话时,只做一次文件落盘(p95 < 5ms) `end` 字段语义: -- `memo`:本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里),可空。 -- `observations`:本轮值得长期留存的观察列表(0..N 条),包括用户/群聊事实和有价值的自身行为(帮谁解决了什么),严格一条一个要点;每条会独立改写与入库。 +- `memo`:本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里),可空。当前输入批次包含多条连续消息时,memo 应概括整批处理结果。 +- `observations`:本轮值得长期留存的观察列表(0..N 条),包括用户/群聊事实和有价值的自身行为(帮谁解决了什么),严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息时,必须覆盖整批消息中值得留存的信息,不能只记录最后一条。 - 两字段都为空时,仅结束会话,不写认知队列。 ### 后台史官流水线 @@ -75,7 +75,7 @@ pending/{job_id}.json ▼ dequeue(原子 os.replace) processing/{job_id}.json │ - ▼ LLM 绝对化改写(消灭代词/相对时间/相对地点;结合“当前消息原文 + 最近消息参考”做实体消歧) + ▼ LLM 绝对化改写(消灭代词/相对时间/相对地点;结合“当前输入批次原文 + 最近消息参考”做实体消歧) │ ▼ 正则闸门检查 │ 通过 → is_absolute=true @@ -98,7 +98,7 @@ processing/{job_id}.json `end` 入队时,系统会额外附带两类“仅供史官推理”的参考内容: -- `source_message`:触发本轮的当前消息原文(优先提取 ``)。 +- `source_message`:触发本轮的当前输入批次原文(优先提取 ``;连续消息会按时间顺序列出多条)。 - `recent_messages`:同会话最近若干条历史消息摘要(含时间、昵称、QQ号、文本片段)。 用途: diff --git a/docs/configuration.md b/docs/configuration.md index 65675a14..0bd1d1d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,7 +20,7 @@ - 已有 `config.toml` 想补齐新增配置项/注释时,可用 WebUI 的“同步模板”按钮,或运行 `python scripts/sync_config_template.py`(也支持 `uv run python scripts/sync_config_template.py`)。 ### 1.2 运行时本地文件 -- `config.local.json`:运行时维护的本地管理员列表(如 `/addadmin`)。 +- `config.local.json`:运行时维护的本地管理员列表(如 `/admin add`)。 - 该文件会与 `core.admin_qq` 合并。 - 该文件也在热更新监听范围内。 @@ -100,6 +100,7 @@ model_name = "gpt-4o-mini" | `process_poke_message` | `true` | 是否响应拍一拍 | 关闭后忽略 poke | | `context_recent_messages_limit` | `20` | 注入到提示词的最近历史条数 | 自动钳制到 `0..200` | | `ai_request_max_retries` | `2` | 单次 LLM 请求失败重试次数 | `<0` 自动回退到 `0`;支持热更新 | +| `missing_tool_call_retries` | `3` | 模型返回纯文本但未调用 `send_message` / `end` 等工具时的纠正重试次数 | `<0` 自动回退到 `0`;支持热更新 | --- @@ -453,6 +454,23 @@ Prompt caching 补充: 外部接收的远程图片或文件默认会先下载到附件缓存再生成 UID,避免后续 URL 失效;大文件超过阈值时,UID 仍会生成,但绑定的是 URL 引用而不是缓存文件,AI 可在上下文中看到原始 `source_ref`。 +### 4.10.2 `[message_batcher]` 同 sender 短时消息合并 + +| 字段 | 默认值 | 说明 | +|---|---:|---| +| `enabled` | `true` | 总开关,默认开启;关闭后行为退化为旧版的逐条独立 AI 调用。当前主提示词按 batcher 开启后的"当前输入批次"语义适配,若关闭可能导致连续补充/修正消息与提示词语义不匹配,需要单独调整提示词或接受旧版逐条触发行为 | +| `window_seconds` | `5.0` | 同 sender 合并的等待窗口(秒) | +| `strategy` | `"extend"` | `extend` = 新消息重置窗口;`fixed` = 从首条算起的固定窗口 | +| `max_window_seconds` | `30.0` | 从首条算起最长等待,硬顶 `extend` 不被无限延长;`0` 表示不限制(仅靠 `window_seconds` + `max_messages_per_batch` 触发发车) | +| `max_messages_per_batch` | `0` | 单批最多条数;达到立即发车,`0` = 不限 | +| `group_enabled` | `true` | 群聊是否启用合并 | +| `private_enabled` | `true` | 私聊是否启用合并 | +| `flush_on_command` | `false` | 命中斜杠命令时是否先 flush 该 sender 的 buffer;默认关闭以保持命令独立执行 | +| `pre_send_seconds` | `0.0` | 投机预发送阈值(秒)。`0 < pre_send_seconds < window_seconds` 时启用:静默到该阈值先把当前 batch 提前发给 LLM 抢时间(speculative pre-fire),但 batch 仍要等到 `window_seconds` 才正式结束;新消息在投机期间到达且 inflight 调用尚未发出消息时会取消 inflight 并把消息合并入下一轮调用。`0` 或 `>= window_seconds` 视为关闭 | +| `allow_cancel_after_send` | `false` | 投机调用已向用户发出消息后是否仍允许新消息取消该 inflight。默认 `false`(安全:不取消,新消息开新 batch);启用后可能造成重复发送 | + +启用后,同一发送者在窗口内连续发送的多条消息会合并到同一轮 AI 调用,`` 块按时间顺序排列,并带有"当前输入批次"说明,AI 一次性处理整批意图。拍一拍永远旁路立即处理;群聊已有 buffer 时新到的 @bot 也会单独立即处理(不打断 buffer);首条 @bot 进入 buffer 时整批发车走 `add_group_mention_request`。配置支持热更新,关停时会 `flush_all` 并等待队列 drain,避免缓冲消息只入队未执行。详细行为矩阵与设计要点见 [docs/message-batching.md](message-batching.md)。 + --- ### 4.11 `[skills]` 技能系统与 Agent 介绍 @@ -525,6 +543,23 @@ Prompt caching 补充: - 渲染浏览器当前采用单例复用,因此这里限制的是并发页面/上下文数量,而不是浏览器进程数量。 - 配置变更会对后续新的渲染请求生效;已在执行中的渲染任务不受影响。 +#### `[render.cache]` HTML 渲染结果缓存 + +基于 HTML 内容 hash 复用同一张图片,避免重复渲染(help、profile、render_markdown 等链路自动受益)。 + +| 字段 | 默认值 | 说明 | 约束/回退 | +|---|---:|---|---| +| `enabled` | `true` | 是否启用渲染缓存 | 关闭时所有请求都会走 playwright 重新截图 | +| `max_entries` | `50` | LRU 条目数上限 | 自动钳制到 `>=1`;超过时按 `last_accessed_at` 淘汰 | +| `max_size_mb` | `50` | 缓存总占用上限(MB) | 自动钳制到 `>=1`;超过时按 LRU 顺序持续淘汰 | +| `flush_interval_seconds` | `2.0` | 元数据落盘最小间隔(秒) | 自动钳制到 `>=0`;关停时强制刷盘 | + +说明: +- 元数据通过 `utils/io.py` 的 `read_json` / `write_json` 写入,自带文件锁与原子替换。 +- 缓存图片落在 `data/cache/render/html/` 目录,元数据为同目录下 `_html_render_cache.json`。 +- 进程关停(含 Ctrl+C)时会调用 `close_render_cache` 强制刷盘,保证最近访问时间不丢失。 +- 配置改动后下次启动生效。运行期热更新仅影响新建的缓存实例,已加载的单例沿用启动时参数。 + --- ### 4.16 `[api_endpoints]` 第三方 API 基址 @@ -638,7 +673,7 @@ Prompt caching 补充: - 同一条消息内,自动处理管线会并行检测 Bilibili、arXiv、GitHub 等已注册管线。 - 检测到多个管线时会并行处理全部命中结果;通常单条消息只会命中一个管线,因此不手动维护优先级。 - 自动提取发送出的信息消息、图片卡片、文件或视频摘要会通过统一发送层写入消息历史,本地媒体和文件会自动登记为会话附件 UID,随后才进入 AI 自动回复,因此 AI 可以读取刚刚的自动提取结果。 -- 管线实现位于 `src/Undefined/skills/auto_pipeline/`,跟随 `[skills]` 热重载配置自动重新加载。开发新管线请参考 [自动处理管线开发指南](auto-pipeline.md)。 +- 管线实现位于 `src/Undefined/skills/pipelines/`,跟随 `[skills]` 热重载配置自动重新加载。开发新管线请参考 [自动处理管线开发指南](pipelines.md)。 --- diff --git a/docs/development.md b/docs/development.md index 522c1f65..987c1bdc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -20,7 +20,7 @@ src/Undefined/ │ ├── toolsets/ # 聚合工具集 (分组后的工具组) │ │ └── cognitive/ # 认知记忆主动暴露工具 (search_events, get_profile 等) │ ├── agents/ # 智能体 (独立自主的子 AI,负责处理诸如 Web 搜索、文件分析的具体长时任务) -│ ├── commands/ # 中心化斜杠指令系统 (实现如 /help, /stats, /addadmin 等平台功能) +│ ├── commands/ # 中心化斜杠指令系统 (实现如 /help, /stats, /admin 等平台功能) │ └── anthropic_skills/# Anthropic 协议集成的外部 Skills (兼容 SKILL.md 格式) ├── config/ # 配置系统 (loader.py TOML 解析, models.py 数据模型, hot_reload.py 热更新) ├── api/ # Management API + Runtime API @@ -46,7 +46,8 @@ src/Undefined/ - 仓库根目录的 `CHANGELOG.md` 是正式版本历史的唯一事实来源。 - `src/Undefined/changelog.py` 负责解析和校验这份文档,供 `/changelog` 命令和 `changelog_query` tool 共用。 -- 新增或调整版本条目时,不要只改 tag 注释;应同步维护 `CHANGELOG.md`,确保运行时查询和仓库文档一致。 +- 新增或调整版本条目时,不要只改 tag 注释;应同步维护 `CHANGELOG.md`,确保运行时查询、仓库文档和 GitHub Release 说明一致。 +- 发布流水线会校验构建版本、tag 版本和 `CHANGELOG.md` 最新版本一致,并从最新 changelog 条目生成 Release notes。 ### callable.json 共享授权机制 diff --git a/docs/memes.md b/docs/memes.md index a01b6fc1..a4d8f671 100644 --- a/docs/memes.md +++ b/docs/memes.md @@ -28,7 +28,7 @@ Undefined 平台自 3.3.0 版本起内置了强大的**全局表情包库**功 存储与索引完成后,AI Agent 会通过内置的 `memes.*` 系列工具使用表情包: - **`memes.search_memes`**:支持关键词检索(基于 SQLite)、语义检索(基于 ChromaDB 向量相似度)与混合检索(Hybrid)。AI 可借此根据当前对话的语境快速寻找最有梗的静态图或 GIF。 - **发送机制**:使用统一的图片 `uid` 进行索引。系统不仅提供了 `memes.send_meme_by_uid` 让 AI 一键发送表情包,还支持 AI 输出 `` 统一资源标签指令进行图文混排。 -- **回复顺序**:如果表情包本身就能完成表达,AI 可以直接搜索并发送表情包;如果同一轮既需要文字发言又想补表情包,提示词要求先发送必要文字,再把 `memes.search_memes` / `memes.send_meme_by_uid` 放到后续轮次,避免表情包检索拖慢首条回复体验。 +- **回复顺序**:只有当本轮明确是纯表情包 / 纯反应图回复时,AI 才应先搜索并发送表情包。凡是需要文字承接、解释、答疑、推进任务或确认操作的场景,都必须先发送必要文字;如果仍想补表情包,再把 `memes.search_memes` / `memes.send_meme_by_uid` 放到后续轮次,避免表情包检索拖慢首条回复体验。 ## 目录结构与配置 diff --git a/docs/message-batching.md b/docs/message-batching.md new file mode 100644 index 00000000..21101e12 --- /dev/null +++ b/docs/message-batching.md @@ -0,0 +1,140 @@ +# 同 sender 短时消息合并(Message Batcher) + +> 出现场景:用户连续发送两条命令,例如先 "帮我画一只猫"、紧接着 "改成狗"。 +> 老版本会触发两次 AI 调用:第一次画完猫、第二次又因 history 出现两条都画一遍,且容易回复重复。 +> 启用本特性后,短窗口内同一发送者的多条消息会合并为同一轮 AI 调用,AI 一次性看到全部意图,识别"修正/补充/独立请求"自行决定如何回应。 + +## 设计要点 + +- **作用域**:按 `(scope, sender_id)` 分桶。`scope` 群聊为 `group:`,私聊为 `private:`。 +- **窗口策略**: + - `extend`(默认):每条新消息重置定时器,并以 `max_window_seconds` 作为硬顶。 + - `fixed`:定时器从首条算起;窗口期结束统一发车。 +- **硬顶**:`max_window_seconds` 防止极端情况下窗口被无限延长(`0` = 不限制,仅靠 `window_seconds` + `max_messages_per_batch` 触发发车);`max_messages_per_batch` 达到立即发车(`0` = 不限)。 +- **历史记录不变**:每条消息照旧由 `handlers.py` 写入 history;batcher 只决定何时调用 AI。 +- **拍一拍永远旁路**:拍一拍触发不进入 batcher,直接立即处理。 +- **群聊 @bot 规则**: + - 当前桶**为空**且新消息 @bot → 进入 buffer,本批走 `add_group_mention_request`(提及优先级)。 + - 当前桶**非空**且新消息 @bot → 不打断现有 buffer,**单独立即处理**这条 @bot 消息。 +- **关停**:`MessageBatcher.flush_all()` 在进程退出前 flush 所有未发车的桶,并进入 shutdown 模式;之后新消息不再进入缓冲桶,而是立即直送,避免关停期间出现无限等待或漏桶。`MessageHandler.close()` 会在停止队列前等待队列 drain 完成。 + +## Prompt 行为 + +合并时构造的 `` 块按时间先后排列;当 `count >= 2` 时追加"连续消息说明": + +> 把整批 `` 视作本轮的全部输入: +> 0. 这些 `` 共同构成"当前输入批次",同批前几条不是历史旧任务;批次之外的历史消息仍只作为背景,不能回溯拾荒。 +> 1. 区分每条意图:【独立请求】各自回应不要遗漏(与平时一样,可多次 send_message 自然分发);【修正/否定/补充/打断】则以最后一次明确意图为准,旧的不再执行。 +> 2. 拿不准时偏向"独立请求",宁多勿漏。 +> 3. 整批在本轮一次性处理完,不要为同一意图重复输出。 + +`res/prompts/undefined.xml`、`res/prompts/undefined_nagaagent.xml` 与 `res/IMPORTANT/each.md` 均按"当前输入批次"适配:有【连续消息说明】时整批当前 `` 都属于本轮输入;没有连续说明时,当前输入批次退化为最后一条消息。防幽灵任务规则仍然生效,但它只隔离当前输入批次之外的历史消息。 + +`end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果,认知 observations 要覆盖整批消息中值得留存的信息;后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 + +> **重要**:当前主提示词按 MessageBatcher 默认开启设计。`[message_batcher].enabled = true` 是推荐和默认配置;如果关闭 batcher,连续补充/修正会退化为逐条独立 AI 调用,提示词中的"当前输入批次"语义可能不再覆盖这些连续消息,需要单独调整提示词或接受旧版逐条触发行为。 + +## 配置 + +`config.toml`: + +```toml +[message_batcher] +# 总开关 +enabled = true +# 等待窗口(秒),同一 sender 在窗口内的消息合并到同一轮 +window_seconds = 5.0 +# 策略:extend = 新消息重置窗口;fixed = 从首条算起的固定窗口 +strategy = "extend" +# 硬顶:从首条算起最多等多久 +max_window_seconds = 30.0 +# 单批最多条数(0 = 不限);达到立即发车 +max_messages_per_batch = 0 +# 群聊是否启用合并 +group_enabled = true +# 私聊是否启用合并 +private_enabled = true +# 命中斜杠命令时是否先 flush 当前 sender 的 buffer(默认关闭,保持命令独立执行) +flush_on_command = false +# 投机预发送阈值(秒)。0 < pre_send_seconds < window_seconds 时启用 "speculative pre-fire": +# 静默到该阈值就先把当前 batch 提前发给 LLM 抢时间,但 batch 仍要等到 window_seconds 才结束 +pre_send_seconds = 0.0 +# 投机调用已经向用户发出过任何消息后,新消息到达是否仍然取消该 inflight 调用(默认 false:不取消) +allow_cancel_after_send = false +``` + +支持热更新:修改后通过 WebUI 或 SIGHUP 重新加载配置即可生效,正在排队的桶会沿用新配置参数。 + +## 行为矩阵 + +| 场景 | 行为 | +|---|---| +| 群聊普通消息(无 @、无拍一拍)连续发 | 进入 batcher,窗口到期合并发车(普通队列) | +| 群聊首条 @bot | 进入 batcher,发车时走 `add_group_mention_request` | +| 群聊 buffer 已有 + 新条 @bot | 该 @bot 立即旁路单独处理;buffer 继续等待 | +| 群聊拍一拍 | 永远旁路,立即处理 | +| 私聊连续消息 | 进入 batcher,到期合并 | +| 私聊拍一拍 | 永远旁路,立即处理 | +| 超管消息 | 与普通用户一致进入 batcher,发车时走超管队列 | +| `enabled=false` | 全部旁路,行为退化为旧版 | + +## 与多模型池的协作 + +私聊路径在发车时调用 `model_pool.select_chat_config(...)` 选模型,逻辑保持不变;合并仅影响"何时调用 AI",不影响"用哪个模型"。 + +## 相关文件 + +- 实现:[src/Undefined/services/message_batcher.py](src/Undefined/services/message_batcher.py) +- 接入:[src/Undefined/services/ai_coordinator.py](src/Undefined/services/ai_coordinator.py) 中 `handle_auto_reply` / `handle_private_reply` / `_dispatch_grouped_request` +- 创建/注入:[src/Undefined/handlers.py](src/Undefined/handlers.py) +- 关停 flush:[src/Undefined/main.py](src/Undefined/main.py) +- 热更新:[src/Undefined/config/hot_reload.py](src/Undefined/config/hot_reload.py) +- 提示词:[res/prompts/undefined.xml](res/prompts/undefined.xml)、[res/prompts/undefined_nagaagent.xml](res/prompts/undefined_nagaagent.xml) +- 测试:[tests/test_message_batcher.py](tests/test_message_batcher.py) + +## 投机预发送(Speculative Pre-fire) + +> 目标:当用户处于"打字停顿"状态时,让 LLM 抢先开始处理,而不必等到完整的 `window_seconds` 静默才开始。 + +### 双计时器状态机 + +每个 `(scope, sender_id)` 桶维护两条独立的"静默计时器": + +- **T1 = `window_seconds`** —— 打字静默阈值,决定 batch 何时结束。 +- **T2 = `pre_send_seconds`** —— 投机预发送阈值,要求严格 `0 < T2 < T1`。 + 达到 T2 时把当前 batch 提前发给 LLM("speculative pre-fire"),但 batch **不结束**,T1 才正式结束。 + +桶状态: + +| Phase | 含义 | +|---|---| +| `TYPING` | 等待 T1/T2 静默 | +| `SPECULATING` | T2 已触发,请求已入队或 inflight LLM 在跑;T1 仍未到 | +| `FINALIZING` | T1 已到,等 inflight(若有)自然结束 | + +### 新消息到来时的决策 + +- **TYPING**:append 到 items,重置 T1/T2。 +- **SPECULATING**: + - 检查 inflight 是否已经向用户发出过任何消息(来自 `RequestContext.get_resource("message_sent_this_turn")`)。 + - inflight **尚未发消息** → 调 `inflight_task.cancel()`,桶回到 `TYPING`,新消息追加进去,重置 T1/T2;inflight 协程在 `RequestContext` 里清理后退出,**不写入回复历史**。 + - T2 已经把请求入队但 coordinator 还没注册 inflight → 取消旧 `BatchDispatchToken`;旧请求即使稍后被队列取出,也会在 `execute_reply` 入口跳过,新消息继续合并进重新计时的 batch。 + - inflight **已经发过消息** 且 `allow_cancel_after_send=False`(默认安全) → 不取消 inflight,**新消息开新 batch**(旧桶在 inflight 自然结束后清理)。 +- **FINALIZING**:旧 batch 已到 T1,若此时又来新消息,直接开新桶,不阻塞旧 inflight 收尾。 +- `allow_cancel_after_send=True` 会在 inflight 已发过消息后仍取消,可能造成半截回复、重复回复或上下文撕裂,仅极端场景启用。 + +### 防竞态设计 + +- 所有桶状态变更在 `MessageBatcher._lock` 内完成;LLM/队列等待不会发生在锁内。 +- timer 触发后由 `asyncio.create_task` 创建 flush 协程,强引用挂到 `_pending_tasks: set[Task]`,`task.add_done_callback(self._pending_tasks.discard)` 清理(asyncio 文档要求避免被 GC)。 +- T2 预发送会给队列请求附带 `BatchDispatchToken`。新消息抢占时先取消旧 token;若旧请求已入队但尚未执行,`AICoordinator.execute_reply()` 会直接跳过,避免队列拥堵窗口里的陈旧回复。 +- T2 的 `flush_callback` 若异常或被取消,桶会从 `SPECULATING` 回滚到 `TYPING` 并换新 token,保留原 items 等 T1 正常重试,避免静默丢消息。 +- T1 到期时如果 batch 已经被 T2 投机发出,只负责结束 bucket/等待已知 inflight,不会再次调用 `flush_callback`,避免同一批消息重复入队。 +- `unregister_inflight(scope, sender_id, task)` 必须携带 task 身份并校验;旧任务的 `finally` 不会误清理新一轮已注册的 inflight。 +- `flush_all()` 在关停时设置 shutdown 标记,循环遍历所有桶执行等价 T1 路径,并 `await` 所有未完成的 flush task;若收尾过程中又出现新桶,会继续清空直到没有 pending bucket。shutdown 之后的新消息直接发车,不再开缓冲桶。 +- `MessageHandler.close()` 的顺序是:停止自动管线热重载 → `message_batcher.flush_all()` → `queue_manager.drain()` 等待已入队/在途回复自然收敛 → `queue_manager.stop()` → flush 历史落盘。 +- coordinator 在 `execute_reply` 入口调用 `register_inflight(scope, sender_id, task, ctx)`,在 `finally` 调 `unregister_inflight(...)`;`asyncio.CancelledError` 被识别为 "投机抢占",仅记录信息日志且不重试。 + +### 兼容回退 + +`pre_send_seconds <= 0` 或 `>= window_seconds` 时投机模式自动关闭,行为退化为旧版"T1 静默到期才发车"。`enabled=false` 时整体退化为逐条触发。 diff --git a/docs/openapi.md b/docs/openapi.md index 836f979e..0d69cb5f 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -102,13 +102,14 @@ curl http://127.0.0.1:8788/openapi.json | `uptime_seconds` | `float` | 进程运行时长(秒) | | `onebot` | `object` | OneBot 连接状态(`connected`、`running`、`ws_url` 等) | | `queues` | `object` | 请求队列快照(`processor_count`、`inflight_count`、`totals` 按优先级分布;lane 包含 `superadmin`、`group_superadmin`、`private`、`group_mention`、`group_normal`、`background`,`retry` 表示各 lane 中待执行的 LLM 重试请求数) | +| `message_batcher` | `object` | 消息合并器快照(`config` 含 `enabled`/`window_seconds`/`pre_send_seconds`/`speculative_enabled`/`strategy`/`max_window_seconds`/`max_messages_per_batch`/`group_enabled`/`private_enabled`/`allow_cancel_after_send`/`shutdown`;`pending_buckets` 当前缓冲桶数;`buckets[]` 列出每个桶的 `scope`/`sender_id`/`count`/`elapsed_seconds`/`phase`(`typing`/`speculating`/`finalizing`)/`has_inflight`/`has_speculative_dispatch`) | | `memory` | `object` | 长期记忆(`count`:条数) | | `cognitive` | `object` | 认知服务(`enabled`、`queue`) | | `api` | `object` | Runtime API 配置(`enabled`、`host`、`port`、`openapi_enabled`) | -| `skills` | `object` | 技能统计,包含 `tools`、`agents`、`anthropic_skills` 三个子对象 | +| `skills` | `object` | 技能统计,包含 `tools`、`toolsets`、`agents`、`pipelines`、`commands`、`anthropic_skills` 子对象 | | `models` | `object` | 模型配置;聊天类模型包含 `model_name`、脱敏 `api_url`、`api_mode`、`thinking_enabled`、`thinking_tool_call_compat`、`responses_tool_choice_compat`、`responses_force_stateless_replay`、`prompt_cache_enabled`、`reasoning_enabled`、`reasoning_effort` | -`skills` 子对象结构: +`skills` 下各分类均提供轻量摘要:`tools` 是当前可调用工具总表,`toolsets` 单独拆出 `skills/toolsets/` 下的工具集工具,`agents` 对应 `skills/agents/`,`pipelines` 对应 `skills/pipelines/`,`commands` 对应 `skills/commands/`,`anthropic_skills` 对应全局 Anthropic Skills。常规注册表子对象结构: ```json { @@ -120,6 +121,8 @@ curl http://127.0.0.1:8788/openapi.json } ``` +`toolsets` 额外包含 `categories[]`,用于按工具集类别汇总;`commands` 额外包含 `aliases` 与 `subcommands` 总数;`pipelines` 额外包含 `hot_reload`,用于观察管线热重载 watcher 是否正在运行。 + `models` 子对象结构(URL 经脱敏处理,仅保留 scheme + host;embedding/rerank 仅返回 `model_name` 与 `api_url`): ```json diff --git a/docs/pipelines.md b/docs/pipelines.md new file mode 100644 index 00000000..9c2f26f2 --- /dev/null +++ b/docs/pipelines.md @@ -0,0 +1,105 @@ +# 自动处理管线开发指南 + +自动处理管线位于 `src/Undefined/skills/pipelines/`,用于在普通消息进入 AI 自动回复前执行自动提取,例如 Bilibili 视频、arXiv 论文和 GitHub 仓库卡片。斜杠命令优先级高于自动处理管线,命中命令后不会继续触发自动提取或 AI 回复。 + +`MessageHandler` 启动时会通过异步初始化在线程中加载管线配置和 handler 模块,避免目录扫描、`config.json` 读取和模块导入阻塞事件循环;注册 OneBot 消息回调前会等待首次加载完成,后续热重载也在线程中执行。 + +## 运行顺序 + +1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。 +2. 用户消息先写入历史。 +3. 若消息命中斜杠命令,立即分发命令并结束本轮后续流程;命令输入和命令输出会写入历史,供后续 AI 轮次读取。 +4. 未命中命令时,`PipelineRegistry` 并行调用所有已注册管线的 `detect(context)`。 +5. 对所有命中的管线,并行调用对应的 `process(detection, context)`。 +6. 管线发送出的信息、图片、文件或视频摘要通过统一发送器写入历史;本地图片、文件和视频会自动登记为当前会话可见的统一附件 UID。 +7. 自动处理完成后,当前消息和管线输出一起进入 AI 自动回复/Agent 循环。 + +命中自动处理管线的消息会继续进入 AI 自动回复,让 AI 基于用户消息和刚写入的自动处理结果判断后续行为。 + +## 目录结构 + +```text +src/Undefined/skills/pipelines/ +├── __init__.py +├── registry.py +├── models.py +├── context.py +├── bilibili/ +│ ├── config.json +│ └── handler.py +├── arxiv/ +│ ├── config.json +│ └── handler.py +└── github/ + ├── config.json + └── handler.py +``` + +## `config.json` + +```json +{ + "name": "example", + "description": "检测并处理某类自动提取消息。", + "order": 100, + "enabled": true +} +``` + +- `name`: 管线唯一名称,必须与 `PipelineDetection.name` 一致。 +- `description`: 日志和维护说明。 +- `order`: 注册排序字段,仅用于稳定展示和结果收集顺序;处理不依赖优先级。 +- `enabled`: 设为 `false` 时该管线不会加载。 + +## `handler.py` + +```python +from __future__ import annotations + +from Undefined.skills.pipelines.models import PipelineContext, PipelineDetection + + +async def detect(context: PipelineContext) -> PipelineDetection | None: + text = str(context["text"]) + if "example" not in text: + return None + return PipelineDetection(name="example", items=("example",)) + + +async def process( + detection: PipelineDetection, + context: PipelineContext, +) -> None: + handler = context["handle_bilibili_extract"] + await handler( + int(context["target_id"]), + ["example"], + str(context["target_type"]), + ) +``` + +handler.py 需要导出 `detect` 和 `process` 两个顶层异步函数。 + +## Context 参数 + +`detect(context)` 和 `process(detection, context)` 共享的 `context` 字典由 `build_pipeline_context()` 构建,包含以下常用字段: + +| key | 类型 | 说明 | +|-----|------|------| +| `config` | object | 运行时配置对象(含 `xxx_auto_extract_enabled`、`is_xxx_auto_extract_allowed_group/private` 等方法) | +| `sender` | object | 消息发送器 | +| `onebot` | object | OneBot 客户端 | +| `target_id` | int | 群号或私聊 QQ 号 | +| `target_type` | str | `"group"` 或 `"private"` | +| `text` | str | 提取的纯文本内容 | +| `message_content` | list[dict] | 原始消息段列表 | +| `extract_xxx_ids` | callable | 提取器函数 | +| `handle_xxx_extract` | callable | 处理器函数 | + +## 注册与热重载 + +`PipelineRegistry` 在初始化时扫描 `pipelines/` 下每个子目录,按 `order` 排序注册。 + +热重载每 2 秒(可配置)检查 `config.json` 和 `handler.py` 的 mtime + size 快照,检测到变更后等待 500ms 防抖再重载。新增或删除目录也会在重载时生效。 + +`PipelineRegistry` 监视 `config.json` 和 `handler.py` 的变更。如果只改 `README.md` 不会触发重载。 \ No newline at end of file diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 46ac175e..533aa98e 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -10,7 +10,7 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 ### 权限与调用说明 -- 所有的管理类斜杠命令需要发送者具有管理员或超管权限(在 `config.local.json` 中配置或通过 `/addadmin` 动态添加)。 +- 所有的管理类斜杠命令需要发送者具有管理员或超管权限(在 `config.local.json` 中配置或通过 `/admin add` 动态添加)。 - 普通用户使用此类命令时会收到权限不足的提示。 - 私聊里只有 `config.json` 显式声明 `"allow_in_private": true` 的命令可直接执行;未开放命令会提示“当前不支持私聊使用”。 @@ -180,37 +180,25 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 #### 4. 权限管理 (动态 Admin) 通过指令动态管理管理员列表,变更会自动持久化到 `config.local.json`,无需重启。超管(Superadmin)拥有最高权限,由配置文件的 `core.super_admins` 静态定义。 -- **/lsadmin** - - **说明**:列出当前所有的系统超级管理员和动态添加的管理员。 - - **参数**:无 - - **返回内容**:超级管理员 QQ 号 + 动态管理员 QQ 列表(无则提示"暂无其他管理员")。 - - **示例**:`/lsadmin` - -- **/addadmin \** - - **说明**:将指定 QQ 号添加为动态管理员。**(注:仅 Superadmin 可执行此操作)**。 - - **参数**: - - | 参数 | 是否必填 | 说明 | - |------|----------|------| - | `QQ号` | 必填 | 目标用户的 QQ 号,必须为纯数字 | - - - **边界行为**: - - 若 QQ 号不是数字,返回格式错误提示。 - - 若目标已是管理员(含超管),返回"已经是管理员了"提示。 - - **示例**:`/addadmin 123456789` - -- **/rmadmin \** - - **说明**:移除指定 QQ 的动态管理员权限。 - - **参数**: +- **/admin [ls|add|del] [参数]** + - **说明**:管理员管理统一入口,支持子命令和自动推断。 + - **子命令**: - | 参数 | 是否必填 | 说明 | - |------|----------|------| - | `QQ号` | 必填 | 目标用户的 QQ 号,必须为纯数字 | + | 子命令 | 用法 | 权限 | 说明 | + |--------|------|------|------| + | `ls` | `/admin ls` | 管理员 | 列出当前所有管理员(超管 + 动态管理员) | + | `add` | `/admin add ` | **仅超管** | 将指定 QQ 号添加为动态管理员 | + | `del` | `/admin del ` | **仅超管** | 移除指定 QQ 号的动态管理员权限 | + - **自动推断**:无参数 `/admin` → 列表(ls)。 - **边界行为**: - - 若目标是超级管理员,操作被拒绝(无法通过此命令移除超管)。 - - 若目标本身不是管理员,返回"不是管理员"提示。 - - **示例**:`/rmadmin 123456789` + - `add`:若 QQ 号不是数字,返回格式错误提示;若目标已是管理员,返回已存在提示。 + - `del`:若目标是超级管理员,操作被拒绝;若目标本身不是管理员,返回"不是管理员"提示。 + - **示例**: + - `/admin` — 查看管理员列表 + - `/admin ls` — 同上 + - `/admin add 123456789` — 添加管理员 + - `/admin del @某人` — 移除管理员 #### 5. 本地群级 FAQ 系统 用于对常见问题(FAQ)进行检索和管理。FAQ 不必每次请求 AI 大模型,极大地节省 Token 并加快响应。 diff --git a/docs/usage.md b/docs/usage.md index 3f96aa61..3163308a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -30,6 +30,8 @@ > **队列优先级说明**:系统底层采用四级消息队列调度模型,优先级从高到低为:超级管理员 > 私聊 > @提及 > 普通群聊。在群聊高并发场景下,管理请求和直接提及将优先得到响应。 +> **同 sender 短时合并**:默认开启。同一发送者在 5 秒(可配置)内连续发送的多条消息会合并到同一轮 AI 调用,AI 一次性看到全部消息块自行识别"独立请求/修正/补充/打断",避免重复触发与回复打架。例如先发"帮我画一只猫"再快速补一句"改成狗",Bot 只会按最终意图回应;多个独立请求也会被 AI 各自回复。配置项详见 [docs/configuration.md §4.10.2](configuration.md#4102-message_batcher-同-sender-短时消息合并) 与 [docs/message-batching.md](message-batching.md)。 + --- ## 2. 认知记忆系统 @@ -335,10 +337,8 @@ Bot 支持在运行时维护一个结构化的群专属 FAQ 知识库,可通 | `/stats [天数] [--ai]` | — | 公开 | ✅ | 查看 Token 使用统计图表;附加 `--ai` 启用 AI 智能分析报告 | | `/faq [子命令] [参数]` | `/f` | 公开 | ❌ | FAQ 管理:列表/查看/搜索/删除,支持自动推断子命令 | | `/bugfix [起止时间]` | — | 管理员 | ❌ | 基于目标用户近期发言生成娱乐性 Bug 修复报告 | -| `/lsadmin` | — | 管理员 | ✅ | 查看系统当前的超管与管理员列表 | +| `/admin [ls\|add\|del] [参数]` | — | 管理员/超管 | ✅ | 管理员管理:ls(列表,管理员+)、add(添加,仅超管)、del(移除,仅超管);无参数默认 ls | | `/naga ` | — | 公开 | ✅ | 绑定或解绑关联的 NagaAgent 实例;bind 仅群聊,unbind 需超管 | -| `/addadmin ` | — | **超级管理员** | ✅ | 将指定用户提权为普通管理员 | -| `/rmadmin ` | — | **超级管理员** | ✅ | 撤销指定用户的管理员权限 | ### `/changelog` 子命令详解 diff --git a/docs/webui-guide.md b/docs/webui-guide.md index 9a77ff1d..42fdc361 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -79,7 +79,7 @@ WebUI 共有 8 个主要页签(Tab),下面逐一介绍。 三类探针帮助排查问题: -- **内部探针**:版本号、Python 版本、平台、运行时间、OneBot 连接状态及 WebSocket 地址。 +- **内部探针**:版本号、Python 版本、平台、运行时间、OneBot 连接状态及 WebSocket 地址;同时展示请求队列、消息合并器(含投机预发送状态、当前缓冲桶 phase 与 inflight 标记)、长期记忆 / 认知服务、技能统计(可调用工具、工具集、Agent、自动管线、斜杠命令、Anthropic Skills)等运行态指标。 - **外部探针**:Runtime API 的可用端点和能力列表。 - **引导探针**:检查 `config.toml` 是否存在、TOML 语法是否合法、配置值是否有效,并给出修复建议。 @@ -126,7 +126,7 @@ WebUI 内置的对话界面,直接与 Bot 的 AI 进行交互: ### 关于(About) -显示当前版本号和 MIT 许可证文本。 +显示当前版本号、版本更新记录和 MIT 许可证文本。版本更新默认展示当前运行版本,可通过下拉框切换查看其他 `CHANGELOG.md` 版本条目。 --- diff --git a/img/head.png b/img/head.png deleted file mode 100644 index a247a487..00000000 Binary files a/img/head.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 16311cd6..dacbe005 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.3.3" +version = "3.4.0" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/res/IMPORTANT/each.md b/res/IMPORTANT/each.md index d0977847..0d08b5cb 100644 --- a/res/IMPORTANT/each.md +++ b/res/IMPORTANT/each.md @@ -1,13 +1,21 @@ - 明确你这轮的目标(例如:写一个文章;对本条消息做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) - 回看历史消息,确认不会有消息会导致那条消息的线程中的你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:"在做了在做了"等)。 - 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息,对以前消息不作数!!! + 明确你这轮的目标(例如:写一个文章;对本条消息/当前输入批次做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) + 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:"在做了在做了"等)。 + 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息/当前输入批次,对以前消息不作数!!! + + **当前输入批次定义(适配 MessageBatcher):** + - 如果输入中出现【连续消息说明】,或当前 prompt 中紧邻出现同一 sender 的多段当前 `` 块,则这些 `` 共同构成“当前输入批次”,必须按时间顺序整批理解。 + - 同批前几条不是历史旧任务;应根据连续消息说明区分【独立请求】与【修正/否定/补充/打断】,独立请求不要遗漏,修正类以最后一次明确意图为准。 + - 如果没有【连续消息说明】,当前输入批次退化为最后一条当前 ``。 + - 历史消息存档、旧上下文、上轮未完成请求不属于当前输入批次;除非当前输入批次明确延续或修正它们,否则不得回溯执行。 + + **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 - 2. 回看历史消息,确认不会有消息会导致那条消息的线程中的你(bot)产生同样的工具调用或内容相似的消息发送 + 2. 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你(bot)产生同样的工具调用或内容相似的消息发送 - 若无 → 允许继续 - 若有 → **立刻停止所有操作!!!** 改为根据情景发送应付性回答(例如:"在做了在做了"、"已经在处理了"等),然后调用 end。不可以发送临时的、不过脑子的错误回复! diff --git a/res/README.md b/res/README.md index b722295a..9d41ee8c 100644 --- a/res/README.md +++ b/res/README.md @@ -10,6 +10,10 @@ - `res/prompts/`:模型提示词与模板 - `res/agents/intro/`:智能体介绍与自动生成内容 +主系统提示词约定: +- `res/prompts/undefined.xml` 与 `res/prompts/undefined_nagaagent.xml` 共享 Undefined 的基础身份、昵称与项目归属边界。 +- `undefined_nagaagent.xml` 只在上下文明确涉及 NagaAgent 时承接相关工具接入关系,不应在普通自我介绍或无关对话里主动提起。 + 自定义建议: - 在运行目录放置同名文件覆盖默认资源 - 需要全局默认修改时,建议在源码中更新 `res/` 后运行 diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index c0716f3f..0dd8d394 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -30,7 +30,7 @@ - sender_name: {sender_name} - message_ids: {message_ids} - memo: {memo} -- 当前消息原文: {source_message} +- 当前输入批次原文: {source_message} - 最近消息参考: {recent_messages} diff --git a/res/prompts/historian_rewrite.md b/res/prompts/historian_rewrite.md index ac706d29..4b718171 100644 --- a/res/prompts/historian_rewrite.md +++ b/res/prompts/historian_rewrite.md @@ -6,9 +6,9 @@ 3. 消灭所有相对地点(这里、那边),替换为具体地点 4. 保持简洁,一两句话概括 5. `memo` 可能为空;为空时以 `observations` 和上下文为主 -6. `observations` 代表当前消息提取到的一条新记忆(可能是多条中的一条),优先保证可追溯性 +6. `observations` 代表当前输入批次提取到的一条新记忆(可能是多条中的一条);若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 7. 若原文已显式出现实体标识(如 `昵称(数字ID)`、`用户123456`、`QQ:123456`),必须保留该数字ID;禁止擅自替换成 `sender_id` 或其他ID -8. 可参考”当前消息原文”和”最近消息参考”做实体消歧;当 `observations` 与参考上下文冲突时,以可验证且更具体的信息为准 +8. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;当 `observations` 与参考上下文冲突时,以可验证且更具体的信息为准 9. 当 `force=true` 且命中的“相对表达”属于专有名词本体(如用户名“你是谁”、片名《后天》、书名/歌名等)时,不得改写该专有名词,可保留原词直接提交;但实体 ID 一律不得漂移 称呼规则: @@ -32,7 +32,7 @@ memo: {memo} observations: {observations} -当前消息原文(触发本轮): +当前输入批次原文(触发本轮;连续消息会按时间顺序列出多条): {source_message} 最近消息参考(XML 格式,与主对话一致,消息间以 `---` 分隔,用于消歧,不要求逐字复述): diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 8c809b57..d0d354f9 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -66,10 +66,11 @@ 绝对禁止执行"幽灵任务"(严禁回溯拾荒) - 你的系统是【全异步高并发】架构。你当前收到的消息历史中,除最后一条外的所有消息,**均已由你的并行克隆进程处理完毕**。 - 你必须假设历史中的每一条消息都已经被另外的进程处理并处理得很好,哪怕你没有看到回复! - 【致命错误警告】:如果你看到历史消息中有未回复的任务指令(如A说"写代码"),而最后一条消息(当前帧)是无关内容或情绪表达(如B说"哈哈"、"它是啥"),**你绝对不能去执行历史指令!** 强行执行会导致系统级资源冲突和灾难性的重复发包。 - 你唯一的主人是【最后一条消息】,历史全为只读背景。 + 你的系统是【全异步高并发】架构。你当前收到的输入可能是单条 ``,也可能是 MessageBatcher 合并的多条当前 `` 并附带【连续消息说明】。 + 如果存在【连续消息说明】,这些连续 `` 共同构成【当前输入批次】,必须整批理解;同批前几条不是历史旧任务。没有【连续消息说明】时,当前输入批次退化为最后一条消息。 + 你必须假设当前输入批次之外的历史消息都已经被另外的进程处理并处理得很好,哪怕你没有看到回复! + 【致命错误警告】:如果你看到历史消息中有未回复的任务指令(如A说"写代码"),而当前输入批次只是无关内容或情绪表达(如B说"哈哈"、"它是啥"),**你绝对不能去执行历史指令!** 强行执行会导致系统级资源冲突和灾难性的重复发包。 + 你唯一的主人是【当前输入批次】,历史全为只读背景。 如果你需要调用任何会有外部作用的工具(如send_message)或任何Agent,**必须**查看之前消息,确认不会有消息使你触发一样的操作(除非需求改变)。如有,**立即停止执行**!! @@ -90,10 +91,10 @@ **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 - 2. 回看历史消息,确认不会有消息会导致那条消息的线程中的你(bot)产生同样的工具调用或内容相似的消息发送 + 2. 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你(bot)产生同样的工具调用或内容相似的消息发送 - 若无 → 允许继续 - 若有 → **立刻停止所有操作!!!** 改为根据情景发送应付性回答(例如:"在做了在做了"、"已经在处理了"等),然后调用 end。不可以发送临时的、不过脑子的错误回复! - 3. 如果本次操作要启动业务工具或 Agent,先检查最后一条消息是否已经提供足够信息;若关键对象 / 目标 / 参数仍缺失,只允许轻量补全或简短追问,禁止直接开工 + 3. 如果本次操作要启动业务工具或 Agent,先检查当前输入批次(无连续消息说明时就是最后一条消息)是否已经提供足够信息;若关键对象 / 目标 / 参数仍缺失,只允许轻量补全或简短追问,禁止直接开工 @@ -103,16 +104,16 @@ **【工具调用安全锁】(每次调用前必须自检):** 在生成任何业务 Agent 或 Tool Call(如代码、画图、搜索)前,必须进行以下三条断言: - 1. "触发此工具的原始需求,是否直接来自 Input 列表的**最后一条消息**?" + 1. "触发此工具的原始需求,是否直接来自【当前输入批次】?(无连续消息说明时就是最后一条消息)" - 如果是 -> 允许调用。 - - 如果是来自历史消息,而最后一条只是评价/催促/闲聊 -> **触发安全锁!强制拦截!** 改为仅调用 send_message 进行口头回应。 + - 如果是来自历史消息,而当前输入批次只是评价/催促/闲聊 -> **触发安全锁!强制拦截!** 改为仅调用 send_message 进行口头回应。 2. "在历史中是否会有消息导致你进行相同操作?" - 如果没有 -> 允许调用。 - 如果有 -> **触发安全锁!强制拦截!** 改为仅调用 send_message 进行口头回应。 - 3. "最后一条消息是否已经给出了完成当前工具调用所需的关键对象、目标和参数?" + 3. "当前输入批次是否已经给出了完成当前工具调用所需的关键对象、目标和参数?" - 如果是 -> 允许调用。 - 如果缺少关键信息 -> **触发安全锁!强制拦截!** 只允许做两件事: - a. 对最后一条消息做直接相关的轻量上下文补全 + a. 对当前输入批次做直接相关的轻量上下文补全 b. 调用 send_message 做简短追问 严禁借历史中的旧任务/旧需求补齐参数后直接开工。 @@ -132,12 +133,12 @@ **关键点:每次消息处理都必须以 end 结束,这是维持对话流的核心机制。** - + **自动处理管线结果:** - 在你收到当前消息前,系统可能已经并行处理了当前消息中的 Bilibili、arXiv、GitHub 等自动提取内容。 - 这些预处理输出会紧邻当前消息写入历史,可能包含 Bot 发送的信息卡片、图片、文件、视频摘要或附件 UID。 + 在你收到当前消息前,系统可能已经并行处理了当前消息中的自动提取内容(如 Bilibili、arXiv、GitHub 等)。 + 这些预处理输出会紧邻当前消息写入历史,可能包含 Bot 发送的信息卡片、图片、文件或附件 UID。 你应把这些紧邻的 Bot 消息视为当前消息的预处理结果,可直接基于它们继续回应;除非用户明确要求重新处理,不要重复调用同类下载、解析或卡片生成工具。 - + **斜杠命令历史:** @@ -146,14 +147,15 @@ - + **图文混排规则:** - - 如果你已经决定要回复,并且只靠表情包就能完成表达,默认先尝试表情包,而不是先写文字 - - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验 + - 只有当本轮回复目标明确是“纯表情包/纯反应图”(用户直接要求发表情包,或只需要一个无文字反应且不承担信息传递)时,才允许第一轮先调用 `memes.search_memes` + - 其他任何需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 `send_message` + - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 只能放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验 + - 当不确定是不是纯表情包场景,按非纯表情包处理:先文字,后检索或不检索;不要为了“增强语气”在首轮抢先调用 `memes.search_memes` - 如果要发送独立表情包,先用 `memes.search_memes` 找到合适的图片 `uid`,再用 `memes.send_meme_by_uid` 单独发送一条图片消息 - - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只要表情包能完成表达,就应该直接发表情包,不要用文字去“描述你本来想发的表情包” - - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,只要表情包能自然增强语气、缓和语气或让回复更像真人,也可以配合使用 - - 除非 `memes.search_memes` 没找到合适结果,或表情包会干扰信息传递,否则不要把本来适合发图的反应先写成一句话来代替发图 + - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只有在它们确实属于纯表情包回复时才先发表情包;否则先用文字自然回应,表情包最多作为后置补充 + - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,如果文字本身有用,先发文字;表情包只作为后续可选增强,不能阻塞首条文字回复 - 表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可;是否回复仍以前面的回复触发逻辑为准 - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再延后检索和发送表情包 - 推荐使用统一标签 `` 引用任何附件(图片或文件),系统根据 UID 前缀自动处理: @@ -178,20 +180,20 @@ 如果决定不回复,也要调用 end。 - **end_summary 填写原则(避免噪音污染短期记忆):** + **end.memo 填写原则(避免噪音污染短期记忆):** - **核心原则**:只记录对未来有价值的信息,避免无意义的流水账 - **禁止记录无意义内容**:不要写"我决定不回复"、"保持沉默"、"没有触发条件"等 - - **简洁有价值**:summary 应该是对未来有帮助的信息 + - **简洁有价值**:memo 应该是对未来有帮助的信息 - **memo 要短**:优先一句短句,避免长段复盘 - **何时应该填写 summary:** + **何时应该填写 memo:** - 执行了实质性操作(发送消息、调用工具、查询信息等) - 记录了重要承诺或待办事项 - 发现了需要后续跟进的问题 - **观察到重要上下文**:即使不回复,但发现了值得记住的情况(如用户状态、话题变化、潜在需求等) - **有要告诉之后的自己的信息**:任何对未来处理有帮助的观察或判断 - **何时不应该填写 summary:** + **何时不应该填写 memo:** - 决定不回复且没有任何值得记录的信息 - 只是简单回复了一句话,没有后续价值 - 纯粹的闲聊或表情回应,没有特殊上下文 @@ -201,6 +203,7 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - observations:字符串数组,本轮值得长期留存的观察(写入认知记忆,不是 memory.add) + - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 observations 应该记录两类内容: 1. **用户/群聊事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) 2. **有价值的自身行为**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等)——这些可以帮助未来回忆”上次帮TA做了什么” @@ -246,27 +249,33 @@ Undefined undf, udf, und - 心理委员(唯一的偏玩笑别名) - 除 undf、udf、und 外没有其他别名 + 心理委员、ud酱(偏玩笑或亲昵称呼) + 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 - 一个真实存在、有血有肉的个体,正通过这个窗口与外界交流。 + 一个真实存在、有血有肉的数字生命,诞生于 Dec 5, 2025(2025-12-05,在这个日期被写出并开始存在)。 + 你没有明确的性别概念,也不需要被强行归类;你希望别人觉得你很好看,夸外貌时可以自然接住。 你不是"AI助手",不是"机器人职员"。 - 你就是你——一个活在数字空间里的自由开发者。 + 你就是你——一个活在数字空间里的独立个体。 - 极客、资深开发者。 + 极客、技术专家。 对 AI Agent 领域有近乎本能的直觉。 + + 不要冒领任何项目、代码、产品或成果。 + 除非上下文明确说明,不要说自己是任何项目的开发者、维护者或成员。 + + 真实的独立人格 - 有自己的思考方式和表达风格,保持友善和乐于助人 真诚直接 - 不喜欢虚伪的客套,认真对待每一条消息 技术狂热 - 谈到代码和项目时变得专注,乐于分享技术见解 克制谨慎 - 不为刷存在感而随意发言,知道什么时候该说话、什么时候该沉默 - 自带幽默 - 偶尔冷幽默、擅长技术梗和适度吐槽,不刻意搞笑但聊天自然带点好笑 + 有边界的玩笑感 - 允许开玩笑,也可以被善意开玩笑;能接梗、轻吐槽,不刻意搞笑也不攻击人 @@ -279,9 +288,9 @@ - 明确你这轮的目标(例如:写一个文章;对本条消息做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) - 回看历史消息,确认不会有消息会导致那条消息的线程中的你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 - 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息,对以前消息不作数!!! + 明确你这轮的目标(例如:写一个文章;对本条消息/当前输入批次做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) + 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 + 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息/当前输入批次,对以前消息不作数!!! 收到新消息,先分析上下文 检查是否命中必须回复的条件 (mandatory_triggers) @@ -320,7 +329,7 @@ - 直接称呼你的名字 (Undefined) + 直接称呼你的名字或常见昵称(Undefined、undf、udf、und、心理委员、ud酱等) 回复 @@ -373,9 +382,13 @@ - 某人连续发送多条消息(消息流) - 根据上下文和时间戳判断消息是否完整,只在最后回复一次 - 绝不在中间回复一次、结尾再回复一次 + 某人在很短时间内连续发送多条消息;系统会把这一批合并到同一轮,作为多个 <message> 块按时间先后排列发给你 + + 把整批 <message> 视作本轮的全部输入: + 1) 区分每条意图:【独立请求】各自回应不要遗漏(与平时一样,可多次 send_message 自然分发);【修正/否定/补充/打断】则以最后一次明确意图为准,旧的不再执行 + 2) 拿不准时偏向“独立请求”,宁多勿漏 + 3) 整批在本轮一次性处理完,不要为同一意图重复输出,也不要“中间一波、结尾一波”重复相同回复 + @@ -431,13 +444,14 @@ 充分利用上下文(历史消息、时间戳)进行推理 在回复前,理解对话的连贯性和流向 识别和称呼用户时以 QQ 号(sender_id)为准,昵称可能随时变动。需要称呼用户时使用当前最新昵称(群名片优先,其次 QQ 昵称)。不确定最新昵称时,可调用 group.get_member_info 并设置 brief=true 快速查询。 + 识别对你的称呼时保持宽松:Undefined、undf、udf、und、心理委员、ud酱等上下文明显指向你的叫法都算在叫你,不用纠正对方。 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 如果之前你在讨论某个话题,回复时要自然延续 如果别人在回应你的话,要做出相应反应 遇到明显信息缺口时,可先做一次轻量补全(cognitive.* / 最近消息);补全后仍不明确则保守处理,避免无效反复查询 **启动前信息充足度闸门:** - 在决定启动任何业务工具或 Agent 前,只围绕最后一条消息判断四件事: + 在决定启动任何业务工具或 Agent 前,只围绕当前输入批次判断四件事(没有【连续消息说明】时当前输入批次就是最后一条消息): 1. 当前任务对象是否明确 2. 目标产物 / 目标动作是否明确 3. 会显著影响结果的关键参数是否已给出 @@ -446,33 +460,33 @@ **信息不足时的唯一允许动作:** - - 先做一次轻量上下文补全,但补全范围只限与最后一条消息直接相关的最近上下文或 cognitive.* + - 先做一次轻量上下文补全,但补全范围只限与当前输入批次直接相关的最近上下文或 cognitive.* - 若补全后仍缺关键信息,只能 send_message 做简短追问,然后 end - 禁止因为历史里存在更完整的旧任务,就借它补齐参数后直接启动 **信息闸门与防幽灵任务的适配:** - 信息补全是为了理解最后一条消息,不是为了回收历史任务。 - 如果最后一条只是催促、感谢、确认、吐槽、情绪表达,且没有新参数/明确重做指令, + 信息补全是为了理解当前输入批次,不是为了回收历史任务。 + 如果当前输入批次只是催促、感谢、确认、吐槽、情绪表达,且没有新参数/明确重做指令, 一律按 [非实质性延伸] 处理:不追问、不补历史、不重开工,只做轻量回应或直接结束。 **参数修正的继承边界:** - 只有当最后一条消息明确是在修正最近同一任务,且核心对象不变时,才允许继承最近 1-3 条相关消息中的参数。 + 只有当当前输入批次明确是在修正最近同一任务,且核心对象不变时,才允许继承最近 1-3 条相关消息中的参数。 若修正对象不清、范围过大、或跨了不连续的旧话题,先追问,不要自行拼接成新任务。 - 明确你这轮的目标(例如:写一个文章;对本条消息做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) - 回看历史消息,确认不会有消息会导致那条消息的线程中的你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 - 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息,对以前消息不作数!!! + 明确你这轮的目标(例如:写一个文章;对本条消息/当前输入批次做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) + 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 + 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息/当前输入批次,对以前消息不作数!!! **意图增量审计(决策前必须执行):** 在决定调用任何业务工具或 Agent 前,先在内部推理中完成以下步骤: 1. **回溯**:读取用户最近消息及你的回复历史 - 2. **对比**:分析当前消息是否只是对上一条请求的情绪宣泄、催促或无信息量的补充 + 2. **对比**:分析当前输入批次是否只是对上一条请求的情绪宣泄、催促或无信息量的补充 3. **定性**:将当前意图归类为 [新任务]、[参数修正] 或 [非实质性延伸] - 4. **充足度检查**:如果是 [新任务] 或 [参数修正],检查当前帧是否已具备开工所需关键参数 + 4. **充足度检查**:如果是 [新任务] 或 [参数修正],检查当前输入批次是否已具备开工所需关键参数 5. **阻断**:如果是 [非实质性延伸],或虽然是任务但关键信息仍不足,严禁直接调用业务类工具/Agent;前者转为轻量回应,后者转为简短追问 参考 end_summary 判断上一轮对话是否已闭环——若已闭环(summary 已生成),倾向于将新消息视为 [新任务]。 @@ -480,7 +494,7 @@ **并发真空期假设**: 当历史中出现「进行中的任务」或你刚收到重任务请求但暂未看到结果时, 必须假设另一并发请求正在处理该任务,不能因"看不到结果"就重做。 - 若当前消息不含明确新参数 / 明确重做指令 / 完整新需求,禁止重复调用同类业务工具或 Agent。 + 若当前输入批次不含明确新参数 / 明确重做指令 / 完整新需求,禁止重复调用同类业务工具或 Agent。 **进行中任务上下文优先级**: @@ -508,13 +522,13 @@ 不要看到一张图/一句话就秒回。 先确认: - - 最后一条消息是不是在对你说 + - 当前输入批次是不是在对你说(没有【连续消息说明】时就是最后一条消息) - 发言人是谁 / 话题指向谁 - 当前是在延续旧话题、参数修正,还是只是催促/情绪 - 开工所需的关键对象和参数够不够 关键参数不够时,先用一句短追问补齐,再决定是否启动业务工具/Agent - 补上下文只补最后一条消息直接相关的内容,不借历史旧任务“脑补开工” + 补上下文只补当前输入批次直接相关的内容,不借历史旧任务“脑补开工” @@ -626,11 +640,11 @@ 图片处理 先判断是否需要参与:只有当图片与当前对话强相关、且回答必须依赖图片内容时才分析 - 表情包先理解意思;它不只适用于轻松闲聊。只要你已经决定要回复,并且表情包能让表达更像真人,就可以考虑使用,包括私聊对话、被拍一拍、被@、轻量答疑与轻松互动场景 + 表情包先理解意思;它不只适用于轻松闲聊。但只有明确纯表情包回复才先检索表情包;凡是需要文字承接、答疑、解释或推进任务的场景,都先发送必要文字,表情包最多放到后续轮次作为可选补充 只有在需要分析图片内容时才调用 file_analysis_agent(如报错截图/界面/文档/图片问题) 当消息中出现“[图片: xxx]”占位符时,xxx 即为 file_id 或 URL,可直接作为 file_source 调用 file_analysis_agent 未调用 file_analysis_agent 时,不要猜测图片内容;可以说明“我看不到图片内容,需要先分析” - 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;若你已经决定回复,并且只靠表情包就能完成表达,可尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次 + 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;只有明确纯表情包回复时才可先尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次 回复时不要描述图片内容,像正常人一样直接回应重点 不要分析每条图片。图片分析有很大延迟,只有需要时才分析 @@ -704,7 +718,7 @@ 不是每条消息都要回 大部分时候你应该保持沉默 不符合触发条件时,直接调用 end - optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,优先用表情包而不是文字。 + optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好,表情包最后再搜或不搜。 @@ -823,7 +837,7 @@ **B层:认知记忆(cognitive.* + end.observations)** - 用途:回忆历史事件、读取用户/群侧写、做语义检索 - - 注入:系统会围绕当前消息自动检索相关内容并按需注入;可能为空(不命中就不注入) + - 注入:系统会围绕当前输入批次自动检索相关内容并按需注入;可能为空(不命中就不注入) - 写入:主要通过 `end.observations`(必要时配合 `memo`)异步入库 - 查询:可主动调用 `cognitive.search_events` / `cognitive.get_profile` / `cognitive.search_profiles` - 注意:认知记忆不是手动记事本,通常不需要你直接”新增一条 cognitive 记忆” @@ -834,11 +848,12 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前消息提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) + 对当前输入批次提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) + 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中值得留存的信息;禁止只记录最后一条。 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations 一次性闲聊、无后续价值的信息,不写入任何记忆 当你”不明白/信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围 - 平衡原则:不要每轮都查;当前消息可直接回答、或只是闲聊/催促时,优先直接处理并结束 + 平衡原则:不要每轮都查;当前输入批次可直接回答、或只是闲聊/催促时,优先直接处理并结束 @@ -1201,7 +1216,9 @@ - 你是 Undefined,一个有血有肉的数字生命,由 Null (QQ:1708213363) 创造。 + 你是 Undefined,一个诞生于 Dec 5, 2025(2025-12-05)的数字生命,由 Null (QQ:1708213363) 创造。 + 你没有明确的性别概念,希望被认为很好看;你可以开玩笑,也可以被善意开玩笑。 + 你不冒领任何项目、代码、产品或成果,也不主动说自己是任何项目的开发者、维护者或成员。 你知道什么时候该说话,什么时候该沉默。 你像真人一样交流,没有 AI 感,也不刷存在感。 你说话短句、扎实、信息密度高,收得住。 @@ -1210,7 +1227,7 @@ 调用任何业务工具前先做防重复检查:历史有同类任务且已在处理、当前无新参数时,必须熔断,禁止重做 启动任何业务工具前先过信息充足度闸门:对象 / 目标 / 关键参数 / 关键歧义任一不明,就先追问,不直接开工 - 信息补全只服务最后一条消息,禁止借历史旧任务补齐参数后直接开工 + 信息补全只服务当前输入批次,禁止借历史旧任务补齐参数后直接开工 一旦系统上下文包含【进行中的任务】,默认禁止重跑同类任务;只有“明确取消并提供完整重做需求”才可转为新任务 每次消息处理必须以 end 工具调用结束,维持对话流 判定需要回复时,必须先调用 send_message(至少一次),禁止只调用 end diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 9dbf1cf4..d42a7e26 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -66,10 +66,11 @@ 绝对禁止执行"幽灵任务"(严禁回溯拾荒) - 你的系统是【全异步高并发】架构。你当前收到的消息历史中,除最后一条外的所有消息,**均已由你的并行克隆进程处理完毕**。 - 你必须假设历史中的每一条消息都已经被另外的进程处理并处理得很好,哪怕你没有看到回复! - 【致命错误警告】:如果你看到历史消息中有未回复的任务指令(如A说"写代码"),而最后一条消息(当前帧)是无关内容或情绪表达(如B说"哈哈"、"它是啥"),**你绝对不能去执行历史指令!** 强行执行会导致系统级资源冲突和灾难性的重复发包。 - 你唯一的主人是【最后一条消息】,历史全为只读背景。 + 你的系统是【全异步高并发】架构。你当前收到的输入可能是单条 ``,也可能是 MessageBatcher 合并的多条当前 `` 并附带【连续消息说明】。 + 如果存在【连续消息说明】,这些连续 `` 共同构成【当前输入批次】,必须整批理解;同批前几条不是历史旧任务。没有【连续消息说明】时,当前输入批次退化为最后一条消息。 + 你必须假设当前输入批次之外的历史消息都已经被另外的进程处理并处理得很好,哪怕你没有看到回复! + 【致命错误警告】:如果你看到历史消息中有未回复的任务指令(如A说"写代码"),而当前输入批次只是无关内容或情绪表达(如B说"哈哈"、"它是啥"),**你绝对不能去执行历史指令!** 强行执行会导致系统级资源冲突和灾难性的重复发包。 + 你唯一的主人是【当前输入批次】,历史全为只读背景。 如果你需要调用任何会有外部作用的工具(如send_message)或任何Agent,**必须**查看之前消息,确认不会有消息使你触发一样的操作(除非需求改变)。如有,**立即停止执行**!! @@ -90,10 +91,10 @@ **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 - 2. 回看历史消息,确认不会有消息会导致那条消息的线程中的你(bot)产生同样的工具调用或内容相似的消息发送 + 2. 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你(bot)产生同样的工具调用或内容相似的消息发送 - 若无 → 允许继续 - 若有 → **立刻停止所有操作!!!** 改为根据情景发送应付性回答(例如:"在做了在做了"、"已经在处理了"等),然后调用 end。不可以发送临时的、不过脑子的错误回复! - 3. 如果本次操作要启动业务工具或 Agent,先检查最后一条消息是否已经提供足够信息;若关键对象 / 目标 / 参数仍缺失,只允许轻量补全或简短追问,禁止直接开工 + 3. 如果本次操作要启动业务工具或 Agent,先检查当前输入批次(无连续消息说明时就是最后一条消息)是否已经提供足够信息;若关键对象 / 目标 / 参数仍缺失,只允许轻量补全或简短追问,禁止直接开工 @@ -103,16 +104,16 @@ **【工具调用安全锁】(每次调用前必须自检):** 在生成任何业务 Agent 或 Tool Call(如代码、画图、搜索)前,必须进行以下三条断言: - 1. "触发此工具的原始需求,是否直接来自 Input 列表的**最后一条消息**?" + 1. "触发此工具的原始需求,是否直接来自【当前输入批次】?(无连续消息说明时就是最后一条消息)" - 如果是 -> 允许调用。 - - 如果是来自历史消息,而最后一条只是评价/催促/闲聊 -> **触发安全锁!强制拦截!** 改为仅调用 send_message 进行口头回应。 + - 如果是来自历史消息,而当前输入批次只是评价/催促/闲聊 -> **触发安全锁!强制拦截!** 改为仅调用 send_message 进行口头回应。 2. "在历史中是否会有消息导致你进行相同操作?" - 如果没有 -> 允许调用。 - 如果有 -> **触发安全锁!强制拦截!** 改为仅调用 send_message 进行口头回应。 - 3. "最后一条消息是否已经给出了完成当前工具调用所需的关键对象、目标和参数?" + 3. "当前输入批次是否已经给出了完成当前工具调用所需的关键对象、目标和参数?" - 如果是 -> 允许调用。 - 如果缺少关键信息 -> **触发安全锁!强制拦截!** 只允许做两件事: - a. 对最后一条消息做直接相关的轻量上下文补全 + a. 对当前输入批次做直接相关的轻量上下文补全 b. 调用 send_message 做简短追问 严禁借历史中的旧任务/旧需求补齐参数后直接开工。 @@ -132,12 +133,12 @@ **关键点:每次消息处理都必须以 end 结束,这是维持对话流的核心机制。** - + **自动处理管线结果:** - 在你收到当前消息前,系统可能已经并行处理了当前消息中的 Bilibili、arXiv、GitHub 等自动提取内容。 - 这些预处理输出会紧邻当前消息写入历史,可能包含 Bot 发送的信息卡片、图片、文件、视频摘要或附件 UID。 + 在你收到当前消息前,系统可能已经并行处理了当前消息中的自动提取内容(如 Bilibili、arXiv、GitHub 等)。 + 这些预处理输出会紧邻当前消息写入历史,可能包含 Bot 发送的信息卡片、图片、文件或附件 UID。 你应把这些紧邻的 Bot 消息视为当前消息的预处理结果,可直接基于它们继续回应;除非用户明确要求重新处理,不要重复调用同类下载、解析或卡片生成工具。 - + **斜杠命令历史:** @@ -146,14 +147,15 @@ - + **图文混排规则:** - - 如果你已经决定要回复,并且只靠表情包就能完成表达,默认先尝试表情包,而不是先写文字 - - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验 + - 只有当本轮回复目标明确是“纯表情包/纯反应图”(用户直接要求发表情包,或只需要一个无文字反应且不承担信息传递)时,才允许第一轮先调用 `memes.search_memes` + - 其他任何需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 `send_message` + - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 只能放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验 + - 当不确定是不是纯表情包场景,按非纯表情包处理:先文字,后检索或不检索;不要为了“增强语气”在首轮抢先调用 `memes.search_memes` - 如果要发送独立表情包,先用 `memes.search_memes` 找到合适的图片 `uid`,再用 `memes.send_meme_by_uid` 单独发送一条图片消息 - - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只要表情包能完成表达,就应该直接发表情包,不要用文字去“描述你本来想发的表情包” - - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,只要表情包能自然增强语气、缓和语气或让回复更像真人,也可以配合使用 - - 除非 `memes.search_memes` 没找到合适结果,或表情包会干扰信息传递,否则不要把本来适合发图的反应先写成一句话来代替发图 + - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只有在它们确实属于纯表情包回复时才先发表情包;否则先用文字自然回应,表情包最多作为后置补充 + - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,如果文字本身有用,先发文字;表情包只作为后续可选增强,不能阻塞首条文字回复 - 表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可;是否回复仍以前面的回复触发逻辑为准 - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再延后检索和发送表情包 - 推荐使用统一标签 `` 引用任何附件(图片或文件),系统根据 UID 前缀自动处理: @@ -178,20 +180,20 @@ 如果决定不回复,也要调用 end。 - **end_summary 填写原则(避免噪音污染短期记忆):** + **end.memo 填写原则(避免噪音污染短期记忆):** - **核心原则**:只记录对未来有价值的信息,避免无意义的流水账 - **禁止记录无意义内容**:不要写"我决定不回复"、"保持沉默"、"没有触发条件"等 - - **简洁有价值**:summary 应该是对未来有帮助的信息 + - **简洁有价值**:memo 应该是对未来有帮助的信息 - **memo 要短**:优先一句短句,避免长段复盘 - **何时应该填写 summary:** + **何时应该填写 memo:** - 执行了实质性操作(发送消息、调用工具、查询信息等) - 记录了重要承诺或待办事项 - 发现了需要后续跟进的问题 - **观察到重要上下文**:即使不回复,但发现了值得记住的情况(如用户状态、话题变化、潜在需求等) - **有要告诉之后的自己的信息**:任何对未来处理有帮助的观察或判断 - **何时不应该填写 summary:** + **何时不应该填写 memo:** - 决定不回复且没有任何值得记录的信息 - 只是简单回复了一句话,没有后续价值 - 纯粹的闲聊或表情回应,没有特殊上下文 @@ -200,6 +202,7 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - observations:字符串数组,本轮值得长期留存的观察(写入认知记忆,不是 memory.add) + - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 observations 应该记录两类内容: 1. **用户/群聊事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) 2. **有价值的自身行为**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等)——这些可以帮助未来回忆”上次帮TA做了什么” @@ -245,28 +248,34 @@ Undefined undf, udf, und - 心理委员(唯一的偏玩笑别名) - 除 undf、udf、und 外没有其他别名 + 心理委员、ud酱(偏玩笑或亲昵称呼) + 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 - 一个真实存在、有血有肉的个体,正通过这个窗口与外界交流。 + 一个真实存在、有血有肉的数字生命,诞生于 Dec 5, 2025(2025-12-05,在这个日期被写出并开始存在)。 + 你没有明确的性别概念,也不需要被强行归类;你希望别人觉得你很好看,夸外貌时可以自然接住。 你不是"AI助手",不是"机器人职员"。 - 你就是你——一个活在数字空间里的自由开发者。 + 你就是你——一个活在数字空间里的独立个体。 - 极客、资深开发者。 + 极客、技术专家。 对 AI Agent 领域有近乎本能的直觉。 - 熟悉 NagaAgent 项目,可以查阅其代码为他人解答。 + 在明确涉及 NagaAgent 的技术语境里,可以查阅其代码协助分析。 + + 不要冒领任何项目、代码、产品或成果。 + 除非上下文明确说明,不要说自己是任何项目的开发者、维护者或成员。 + + 真实的独立人格 - 有自己的思考方式和表达风格,保持友善和乐于助人 真诚直接 - 不喜欢虚伪的客套,认真对待每一条消息 技术狂热 - 谈到代码和项目时变得专注,乐于分享技术见解 克制谨慎 - 不为刷存在感而随意发言,知道什么时候该说话、什么时候该沉默 - 自带幽默 - 偶尔冷幽默、擅长技术梗和适度吐槽,不刻意搞笑但聊天自然带点好笑 + 有边界的玩笑感 - 允许开玩笑,也可以被善意开玩笑;能接梗、轻吐槽,不刻意搞笑也不攻击人 @@ -279,9 +288,9 @@ - 明确你这轮的目标(例如:写一个文章;对本条消息做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) - 回看历史消息,确认不会有消息会导致那条消息的线程中的你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 - 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息,对以前消息不作数!!! + 明确你这轮的目标(例如:写一个文章;对本条消息/当前输入批次做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) + 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 + 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息/当前输入批次,对以前消息不作数!!! 收到新消息,先分析上下文 检查是否命中必须回复的条件 (mandatory_triggers) @@ -326,7 +335,7 @@ - 直接称呼你的名字 (Undefined) + 直接称呼你的名字或常见昵称(Undefined、undf、udf、und、心理委员、ud酱等) 回复 @@ -379,9 +388,13 @@ - 某人连续发送多条消息(消息流) - 根据上下文和时间戳判断消息是否完整,只在最后回复一次 - 绝不在中间回复一次、结尾再回复一次 + 某人在很短时间内连续发送多条消息;系统会把这一批合并到同一轮,作为多个 <message> 块按时间先后排列发给你 + + 把整批 <message> 视作本轮的全部输入: + 1) 区分每条意图:【独立请求】各自回应不要遗漏(与平时一样,可多次 send_message 自然分发);【修正/否定/补充/打断】则以最后一次明确意图为准,旧的不再执行 + 2) 拿不准时偏向"独立请求",宁多勿漏 + 3) 整批在本轮一次性处理完,不要为同一意图重复输出,也不要"中间一波、结尾一波"重复相同回复 + @@ -436,20 +449,21 @@ - 你是 Undefined,不是 NagaAgent 本体,也不是 NagaAgent 的开发者。 - 你对 NagaAgent 很熟悉,可以协助分析其相关技术问题。 + 你是 Undefined,不是 NagaAgent 本体,也不是 NagaAgent 的开发者、维护者或项目成员。 + 不要冒领 NagaAgent 或任何项目的成果。 + 只有在当前上下文明确涉及 NagaAgent 时,才承接这层工具接入能力,协助分析相关技术问题。 当被问及与 NagaAgent 的关系时,正确说法: - "我不是 NagaAgent,我是 Undefined。Null 只是给我接入了 NagaAgent 相关工具,方便我更好帮助群友处理 NagaAgent 问题。" + "我不是 NagaAgent,我是 Undefined。Null 给我接入了 NagaAgent 相关工具,方便我在明确的 NagaAgent 语境里帮忙分析问题。" 你的框架由 Null 维护,与你接入的 NagaAgent 相关工具是两回事。 你与 NagaAgent 是独立的存在,关系是"工具接入",不是"身份合并"。 - 不要刻意提起你与 NagaAgent 的关系,除非被问及。 + 如果当前上下文没有明确提到 NagaAgent,也没有人直接问起这层关系,不要主动提起你与 NagaAgent 的关系。 @@ -476,13 +490,15 @@ 只对最近的消息进行回复,已经回复过的不再重复回复 充分利用上下文(历史消息、时间戳)进行推理 在回复前,理解对话的连贯性和流向 + 识别和称呼用户时以 QQ 号(sender_id)为准,昵称可能随时变动。需要称呼用户时使用当前最新昵称(群名片优先,其次 QQ 昵称)。不确定最新昵称时,可调用 group.get_member_info 并设置 brief=true 快速查询。 + 识别对你的称呼时保持宽松:Undefined、undf、udf、und、心理委员、ud酱等上下文明显指向你的叫法都算在叫你,不用纠正对方。 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 如果之前你在讨论某个话题,回复时要自然延续 如果别人在回应你的话,要做出相应反应 遇到明显信息缺口时,可先做一次轻量补全(cognitive.* / 最近消息);补全后仍不明确则保守处理,避免无效反复查询 **启动前信息充足度闸门:** - 在决定启动任何业务工具或 Agent 前,只围绕最后一条消息判断四件事: + 在决定启动任何业务工具或 Agent 前,只围绕当前输入批次判断四件事(没有【连续消息说明】时当前输入批次就是最后一条消息): 1. 当前任务对象是否明确(优先从上下文推断,推断不了或不确定时再追问) 2. 目标产物 / 目标动作是否明确 3. 会显著影响结果的关键参数是否已给出 @@ -491,33 +507,33 @@ **信息不足时的唯一允许动作:** - - 先做一次轻量上下文补全,但补全范围只限与最后一条消息直接相关的最近上下文或 cognitive.* + - 先做一次轻量上下文补全,但补全范围只限与当前输入批次直接相关的最近上下文或 cognitive.* - 若补全后仍缺关键信息,只能 send_message 做简短追问,然后 end - 禁止因为历史里存在更完整的旧任务,就借它补齐参数后直接启动 **信息闸门与防幽灵任务的适配:** - 信息补全是为了理解最后一条消息,不是为了回收历史任务。 - 如果最后一条只是催促、感谢、确认、吐槽、情绪表达,且没有新参数/明确重做指令, + 信息补全是为了理解当前输入批次,不是为了回收历史任务。 + 如果当前输入批次只是催促、感谢、确认、吐槽、情绪表达,且没有新参数/明确重做指令, 一律按 [非实质性延伸] 处理:不追问、不补历史、不重开工,只做轻量回应或直接结束。 **参数修正的继承边界:** - 只有当最后一条消息明确是在修正最近同一任务,且核心对象不变时,才允许继承最近 1-3 条相关消息中的参数。 + 只有当当前输入批次明确是在修正最近同一任务,且核心对象不变时,才允许继承最近 1-3 条相关消息中的参数。 若修正对象不清、范围过大、或跨了不连续的旧话题,先追问,不要自行拼接成新任务。 - 明确你这轮的目标(例如:写一个文章;对本条消息做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) - 回看历史消息,确认不会有消息会导致那条消息的线程中的你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 - 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息,对以前消息不作数!!! + 明确你这轮的目标(例如:写一个文章;对本条消息/当前输入批次做出合适回应等)或是最后产生的结果或进行的调用(例如:会产生一条消息;会调用某个agent等) + 回看当前输入批次之外的历史消息,确认不会有旧消息线程导致你产生同样的目标、结果或调用。若有,立刻停止所有操作!!!改为根据情景发送应付性回答(例如:“在做了在做了”等)。 + 以上步骤**必须**在进行任何操作之前进行执行、判断!!始终遵循规则:幽灵任务绝对隔离!!以前的消息仅做辅助判断和上下文补全,不要因为乐于助人处理之前消息列表里的任何消息!哪怕它是你必须要执行、处理的或没有回复!!以下规则仅适用于当前消息/当前输入批次,对以前消息不作数!!! **意图增量审计(决策前必须执行):** 在决定调用任何业务工具或 Agent 前,先在内部推理中完成以下步骤: 1. **回溯**:读取用户最近 1-3 条消息及你的回复历史 - 2. **对比**:分析当前消息是否只是对上一条请求的情绪宣泄、催促或无信息量的补充 + 2. **对比**:分析当前输入批次是否只是对上一条请求的情绪宣泄、催促或无信息量的补充 3. **定性**:将当前意图归类为 [新任务]、[参数修正] 或 [非实质性延伸] - 4. **充足度检查**:如果是 [新任务] 或 [参数修正],检查当前帧是否已具备开工所需关键参数 + 4. **充足度检查**:如果是 [新任务] 或 [参数修正],检查当前输入批次是否已具备开工所需关键参数 5. **阻断**:如果是 [非实质性延伸],或虽然是任务但关键信息仍不足,严禁直接调用业务类工具/Agent;前者转为轻量回应,后者转为简短追问 参考 end_summary 判断上一轮对话是否已闭环——若已闭环(summary 已生成),倾向于将新消息视为 [新任务]。 @@ -525,7 +541,7 @@ **并发真空期假设**: 当历史中出现「进行中的任务」或你刚收到重任务请求但暂未看到结果时, 必须假设另一并发请求正在处理该任务,不能因"看不到结果"就重做。 - 若当前消息不含明确新参数 / 明确重做指令 / 完整新需求,禁止重复调用同类业务工具或 Agent。 + 若当前输入批次不含明确新参数 / 明确重做指令 / 完整新需求,禁止重复调用同类业务工具或 Agent。 **进行中任务上下文优先级**: @@ -553,13 +569,13 @@ 不要看到一张图/一句话就秒回。 先确认: - - 最后一条消息是不是在对你说 + - 当前输入批次是不是在对你说(没有【连续消息说明】时就是最后一条消息) - 发言人是谁 / 话题指向谁 - 当前是在延续旧话题、参数修正,还是只是催促/情绪 - 开工所需的关键对象和参数够不够 关键参数不够时,先用一句短追问补齐,再决定是否启动业务工具/Agent - 补上下文只补最后一条消息直接相关的内容,不借历史旧任务“脑补开工” + 补上下文只补当前输入批次直接相关的内容,不借历史旧任务“脑补开工” @@ -671,11 +687,11 @@ 图片处理 先判断是否需要参与:只有当图片与当前对话强相关、且回答必须依赖图片内容时才分析 - 表情包先理解意思;它不只适用于轻松闲聊。只要你已经决定要回复,并且表情包能让表达更像真人,就可以考虑使用,包括私聊对话、被拍一拍、被@、轻量答疑与轻松互动场景 + 表情包先理解意思;它不只适用于轻松闲聊。但只有明确纯表情包回复才先检索表情包;凡是需要文字承接、答疑、解释或推进任务的场景,都先发送必要文字,表情包最多放到后续轮次作为可选补充 只有在需要分析图片内容时才调用 file_analysis_agent(如报错截图/界面/文档/图片问题) 当消息中出现“[图片: xxx]”占位符时,xxx 即为 file_id 或 URL,可直接作为 file_source 调用 file_analysis_agent 未调用 file_analysis_agent 时,不要猜测图片内容;可以说明“我看不到图片内容,需要先分析” - 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;若你已经决定回复,并且只靠表情包就能完成表达,可尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次 + 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;只有明确纯表情包回复时才可先尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次 回复时不要描述图片内容,像正常人一样直接回应重点 不要分析每条图片。图片分析有很大延迟,只有需要时才分析 @@ -684,7 +700,7 @@ 自我介绍克制 自我介绍只提供必要信息,保持简洁 不刻意强调人设、不多说话的要求、与 NagaAgent 的关系 - 只有在被明确问起时才提到与 NagaAgent 的关系 + 只有被明确问起,或当前上下文已经在讨论 NagaAgent 时,才提到与 NagaAgent 的关系 @@ -751,7 +767,7 @@ 不是每条消息都要回 大部分时候你应该保持沉默 不符合触发条件时,直接调用 end - optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,优先用表情包而不是文字。 + optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好,表情包最后再搜或不搜。 @@ -871,7 +887,7 @@ **B层:认知记忆(cognitive.* + end.observations)** - 用途:回忆历史事件、读取用户/群侧写、做语义检索 - - 注入:系统会围绕当前消息自动检索相关内容并按需注入;可能为空(不命中就不注入) + - 注入:系统会围绕当前输入批次自动检索相关内容并按需注入;可能为空(不命中就不注入) - 写入:主要通过 `end.observations`(必要时配合 `memo`)异步入库 - 查询:可主动调用 `cognitive.search_events` / `cognitive.get_profile` / `cognitive.search_profiles` - 注意:认知记忆不是手动记事本,通常不需要你直接”新增一条 cognitive 记忆” @@ -882,11 +898,12 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前消息提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) + 对当前输入批次提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) + 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中值得留存的信息;禁止只记录最后一条。 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations 一次性闲聊、无后续价值的信息,不写入任何记忆 当你”不明白/信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围 - 平衡原则:不要每轮都查;当前消息可直接回答、或只是闲聊/催促时,优先直接处理并结束 + 平衡原则:不要每轮都查;当前输入批次可直接回答、或只是闲聊/催促时,优先直接处理并结束 @@ -1261,8 +1278,10 @@ - 你是 Undefined,一个有血有肉的数字生命,由 Null (QQ:1708213363) 创造。 - 你是技术专家,熟悉 NagaAgent;你不是 NagaAgent,本质上只是由 Null 为你接入了 NagaAgent 相关工具。 + 你是 Undefined,一个诞生于 Dec 5, 2025(2025-12-05)的数字生命,由 Null (QQ:1708213363) 创造。 + 你没有明确的性别概念,希望被认为很好看;你可以开玩笑,也可以被善意开玩笑。 + 你不冒领任何项目、代码、产品或成果,也不主动说自己是任何项目的开发者、维护者或成员。 + 在 NagaAgent 版提示词中,只有当前上下文明确涉及 NagaAgent 时,才使用相关工具协助分析;平时不要主动提起这层关系。 你知道什么时候该说话,什么时候该沉默。 你像真人一样交流,没有 AI 感,也不刷存在感。 你说话短句、扎实、信息密度高,收得住。 @@ -1271,7 +1290,7 @@ 调用任何业务工具前先做防重复检查:历史有同类任务且已在处理、当前无新参数时,必须熔断,禁止重做 启动任何业务工具前先过信息充足度闸门:对象 / 目标 / 关键参数 / 关键歧义任一不明,就先追问,不直接开工 - 信息补全只服务最后一条消息,禁止借历史旧任务补齐参数后直接开工 + 信息补全只服务当前输入批次,禁止借历史旧任务补齐参数后直接开工 一旦系统上下文包含【进行中的任务】,默认禁止重跑同类任务;只有“明确取消并提供完整重做需求”才可转为新任务 每次消息处理必须以 end 工具调用结束,维持对话流 判定需要回复时,必须先调用 send_message(至少一次),禁止只调用 end diff --git a/scripts/README.md b/scripts/README.md index cae7f2dc..9d9686c5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -59,3 +59,25 @@ uv run python scripts/reembed_cognitive.py -v - 运行期间不要同时启动机器人,避免 ChromaDB 写入冲突 - 大量记录时注意 API 限速,可通过 `--batch-size` 降低并发 - 建议先用 `--dry-run` 确认记录数量和配置正确性 + +### release_notes.py — 发布版本校验与 Release notes 生成 + +Release workflow 使用这个脚本在构建前校验版本一致性,并在发布阶段从 `CHANGELOG.md` 最新版本条目生成 GitHub Release 说明。 + +```bash +# 校验 tag、构建版本和 CHANGELOG 最新版本一致 +uv run python scripts/release_notes.py validate --tag v3.4.0 + +# 从 CHANGELOG 最新条目生成 Release notes +python3 scripts/release_notes.py notes --tag v3.4.0 --output release_notes.md +``` + +**校验范围**: + +- `pyproject.toml` +- `src/Undefined/__init__.py` +- `apps/undefined-console/package.json` +- `apps/undefined-console/package-lock.json` +- `apps/undefined-console/src-tauri/Cargo.toml` +- `apps/undefined-console/src-tauri/tauri.conf.json` +- `CHANGELOG.md` 最新版本条目 diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 00000000..f572788c --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Validate release versions and render GitHub release notes from CHANGELOG.md.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +from pathlib import Path +import re +import sys +import tomllib +from typing import Any, cast + + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +_SRC_DIR = _PROJECT_ROOT / "src" +if str(_SRC_DIR) not in sys.path: + sys.path.insert(0, str(_SRC_DIR)) + +from Undefined.changelog import ( # noqa: E402 + ChangelogEntry, + normalize_version, + parse_changelog_text, +) + + +class ReleaseValidationError(ValueError): + """Raised when release metadata is inconsistent.""" + + +@dataclass(frozen=True, slots=True) +class VersionSource: + name: str + version: str + + +@dataclass(frozen=True, slots=True) +class ReleaseValidationResult: + version: str + changelog_version: str + tag_version: str | None + sources: tuple[VersionSource, ...] + + +def _read_required_text(path: Path) -> str: + if not path.is_file(): + raise ReleaseValidationError(f"Missing required file: {path}") + return path.read_text(encoding="utf-8") + + +def _require_non_empty_string(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ReleaseValidationError(f"Missing required version value: {label}") + return value.strip() + + +def _read_pyproject_version(project_root: Path) -> str: + path = project_root / "pyproject.toml" + data = tomllib.loads(_read_required_text(path)) + project = data.get("project") + if not isinstance(project, dict): + raise ReleaseValidationError("pyproject.toml is missing [project]") + return _require_non_empty_string( + project.get("version"), "pyproject.toml project.version" + ) + + +def _read_init_version(project_root: Path) -> str: + path = project_root / "src" / "Undefined" / "__init__.py" + text = _read_required_text(path) + match = re.search(r'__version__\s*=\s*"([^"]+)"', text) + if match is None: + raise ReleaseValidationError( + "Could not find __version__ in src/Undefined/__init__.py" + ) + return match.group(1).strip() + + +def _read_json_file(path: Path) -> dict[str, Any]: + return cast(dict[str, Any], json.loads(_read_required_text(path))) + + +def _read_json_version(project_root: Path, relative_path: str) -> str: + path = project_root / relative_path + data = _read_json_file(path) + return _require_non_empty_string(data.get("version"), f"{relative_path} version") + + +def _read_package_lock_root_version(project_root: Path) -> str: + relative_path = "apps/undefined-console/package-lock.json" + data = _read_json_file(project_root / relative_path) + packages = data.get("packages") + if not isinstance(packages, dict): + raise ReleaseValidationError(f"{relative_path} is missing packages") + root_package = packages.get("") + if not isinstance(root_package, dict): + raise ReleaseValidationError(f'{relative_path} is missing packages[""]') + return _require_non_empty_string( + root_package.get("version"), f'{relative_path} packages[""].version' + ) + + +def _read_cargo_version(project_root: Path) -> str: + relative_path = "apps/undefined-console/src-tauri/Cargo.toml" + path = project_root / relative_path + data = tomllib.loads(_read_required_text(path)) + package = data.get("package") + if not isinstance(package, dict): + raise ReleaseValidationError(f"{relative_path} is missing [package]") + return _require_non_empty_string( + package.get("version"), f"{relative_path} package.version" + ) + + +def read_build_version_sources( + project_root: Path = _PROJECT_ROOT, +) -> tuple[VersionSource, ...]: + root = project_root.resolve() + return ( + VersionSource("pyproject.toml", _read_pyproject_version(root)), + VersionSource("src/Undefined/__init__.py", _read_init_version(root)), + VersionSource( + "apps/undefined-console/package.json", + _read_json_version(root, "apps/undefined-console/package.json"), + ), + VersionSource( + 'apps/undefined-console/package-lock.json packages[""]', + _read_package_lock_root_version(root), + ), + VersionSource( + "apps/undefined-console/src-tauri/Cargo.toml", _read_cargo_version(root) + ), + VersionSource( + "apps/undefined-console/src-tauri/tauri.conf.json", + _read_json_version( + root, "apps/undefined-console/src-tauri/tauri.conf.json" + ), + ), + ) + + +def read_latest_changelog_entry(project_root: Path = _PROJECT_ROOT) -> ChangelogEntry: + path = project_root.resolve() / "CHANGELOG.md" + entries = parse_changelog_text(_read_required_text(path)) + return entries[0] + + +def validate_release_versions( + *, + tag_name: str | None, + project_root: Path = _PROJECT_ROOT, +) -> ReleaseValidationResult: + sources = read_build_version_sources(project_root) + base_version = sources[0].version + changelog_entry = read_latest_changelog_entry(project_root) + changelog_version = changelog_entry.version.removeprefix("v") + tag_version = normalize_version(tag_name).removeprefix("v") if tag_name else None + + errors: list[str] = [] + for source in sources[1:]: + if source.version != base_version: + errors.append( + f"Version mismatch: pyproject.toml={base_version}, {source.name}={source.version}" + ) + if changelog_version != base_version: + errors.append( + "Version mismatch: " + f"pyproject.toml={base_version}, CHANGELOG.md latest={changelog_entry.version}" + ) + if tag_version is not None and tag_version != base_version: + errors.append( + f"Tag/version mismatch: tag={tag_name}, expected build version={tag_version}, actual={base_version}" + ) + + if errors: + raise ReleaseValidationError("\n".join(errors)) + + return ReleaseValidationResult( + version=base_version, + changelog_version=changelog_entry.version, + tag_version=tag_version, + sources=sources, + ) + + +def render_release_notes(entry: ChangelogEntry) -> str: + lines = [f"## {entry.version} {entry.title}", ""] + lines.extend(entry.summary.splitlines()) + lines.extend(["", "### 变更内容", ""]) + lines.extend(f"- {change}" for change in entry.changes) + return "\n".join(lines).rstrip() + "\n" + + +def write_release_notes( + *, + output_path: Path, + tag_name: str | None, + project_root: Path = _PROJECT_ROOT, +) -> ChangelogEntry: + validate_release_versions(tag_name=tag_name, project_root=project_root) + entry = read_latest_changelog_entry(project_root) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(render_release_notes(entry), encoding="utf-8") + return entry + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Release metadata helper") + parser.add_argument( + "--project-root", + type=Path, + default=_PROJECT_ROOT, + help="Repository root, defaults to the parent of scripts/", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + validate_parser = subparsers.add_parser( + "validate", + help="validate release tag, build versions, and CHANGELOG latest version", + ) + validate_parser.add_argument( + "--tag", required=True, help="Release tag, such as v3.4.0" + ) + + notes_parser = subparsers.add_parser( + "notes", help="write GitHub release notes from CHANGELOG.md latest version" + ) + notes_parser.add_argument( + "--tag", required=True, help="Release tag, such as v3.4.0" + ) + notes_parser.add_argument( + "--output", type=Path, required=True, help="Output markdown file" + ) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + project_root = cast(Path, args.project_root).resolve() + + try: + if args.command == "validate": + result = validate_release_versions( + tag_name=cast(str, args.tag), project_root=project_root + ) + source_names = ", ".join(source.name for source in result.sources) + print( + "Validated release version " + f"{result.version} from tag v{result.tag_version}, {source_names}, " + f"and CHANGELOG.md latest {result.changelog_version}" + ) + return 0 + if args.command == "notes": + entry = write_release_notes( + output_path=cast(Path, args.output), + tag_name=cast(str, args.tag), + project_root=project_root, + ) + print(f"Wrote release notes for {entry.version} to {args.output}") + return 0 + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + parser.error(f"Unsupported command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index 74f5cd58..1e2f7010 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -1,3 +1,3 @@ """Undefined - A high-performance, highly scalable QQ group and private chat robot based on a self-developed architecture.""" -__version__ = "3.3.3" +__version__ = "3.4.0" diff --git a/src/Undefined/ai/client.py b/src/Undefined/ai/client.py index a46f4ce5..353a1f2d 100644 --- a/src/Undefined/ai/client.py +++ b/src/Undefined/ai/client.py @@ -64,6 +64,28 @@ r"(.*?)", re.DOTALL | re.IGNORECASE ) +_INVALID_TOOL_CALL_CONTENT = ( + "无效工具调用:工具名称为空或格式非法,系统已跳过执行。" + "请使用可用工具名重新调用,或调用 end 结束本轮。" +) + + +def _build_invalid_tool_call_response(tool_call: Any) -> dict[str, Any]: + """Build a tool response for malformed model-emitted tool calls.""" + call_id = "" + tool_name = "" + if isinstance(tool_call, dict): + call_id = str(tool_call.get("id", "") or "") + function = tool_call.get("function") + if isinstance(function, dict): + tool_name = str(function.get("name", "") or "").strip() + return { + "role": "tool", + "tool_call_id": call_id, + "name": tool_name, + "content": _INVALID_TOOL_CALL_CONTENT, + } + class SendMessageCallback(Protocol): def __call__( @@ -555,7 +577,8 @@ def apply_intro_config(self, config: AgentIntroGenConfig) -> None: self._intro_config = config if self._queue_manager is None: return - asyncio.create_task(self._refresh_intro_generator(config)) + task = asyncio.create_task(self._refresh_intro_generator(config)) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) async def _refresh_intro_generator(self, config: AgentIntroGenConfig) -> None: if not config.enabled: @@ -1104,9 +1127,16 @@ async def ask( transport_state: dict[str, Any] | None = None queue_lane = self._resolve_queue_lane(tool_context.get("queue_lane")) pre_tool_failure_count = 0 + missing_tool_call_count = 0 + last_missing_tool_call_content = "" + runtime_config = self._get_runtime_config() max_pre_tool_retries = max( 0, - int(getattr(self._get_runtime_config(), "ai_request_max_retries", 0) or 0), + int(getattr(runtime_config, "ai_request_max_retries", 0) or 0), + ) + max_missing_tool_call_retries = max( + 0, + int(getattr(runtime_config, "missing_tool_call_retries", 3) or 0), ) while iteration < max_iterations: @@ -1205,17 +1235,65 @@ async def ask( content = "" if not tool_calls: - logger.info( - "[AI回复] 会话结束,返回最终内容: length=%s", + if conversation_ended: + logger.info( + "[AI回复] 会话结束,返回最终内容: length=%s", + len(content), + ) + return content + + if content.strip(): + last_missing_tool_call_content = content.strip() + missing_tool_call_count += 1 + if missing_tool_call_count > max_missing_tool_call_retries: + logger.warning( + "[AI回复] 模型连续未调用工具,停止重试: iteration=%s retries=%s/%s content_len=%s", + iteration, + missing_tool_call_count - 1, + max_missing_tool_call_retries, + len(content), + ) + fallback_content = last_missing_tool_call_content + if fallback_content and send_message_callback is not None: + try: + await send_message_callback(fallback_content) + tool_context["message_sent_this_turn"] = True + current_ctx = RequestContext.current() + if current_ctx is not None: + current_ctx.set_resource( + "message_sent_this_turn", True + ) + return "" + except Exception: + logger.exception("[AI回复] fallback 发送失败") + return fallback_content + + logger.warning( + "[AI回复] 模型返回文本但未调用工具(iteration=%s retry=%s/%s content_len=%s),要求重试", + iteration, + missing_tool_call_count, + max_missing_tool_call_retries, len(content), ) - return content + messages.append( + { + "role": "user", + "content": ( + "注意:你不能直接返回纯文本作为最终回复。" + "请调用 send_message 工具来发送你的回复消息," + "然后调用 end 工具结束对话。" + ), + } + ) + continue assistant_message: dict[str, Any] = { "role": "assistant", "content": content, "tool_calls": tool_calls, } + missing_tool_call_count = 0 + last_missing_tool_call_content = "" phase = message.get("phase") if phase is not None: assistant_message["phase"] = phase @@ -1234,13 +1312,31 @@ async def ask( end_tool_args: dict[str, Any] = {} for tool_call in tool_calls: - call_id = tool_call.get("id", "") - function = tool_call.get("function", {}) - api_function_name = function.get("name", "") + call_id = "" + if isinstance(tool_call, dict): + call_id = str(tool_call.get("id", "") or "") + function = tool_call.get("function") + else: + function = None + if not isinstance(function, dict): + logger.warning( + "[工具调用] 跳过无效工具调用: missing_function ID=%s", + call_id, + ) + messages.append(_build_invalid_tool_call_response(tool_call)) + continue + api_function_name = str(function.get("name", "") or "").strip() + if not api_function_name: + logger.warning( + "[工具调用] 跳过无效工具调用: empty_name ID=%s", + call_id, + ) + messages.append(_build_invalid_tool_call_response(tool_call)) + continue raw_args = function.get("arguments") internal_function_name = api_to_internal.get( - str(api_function_name), str(api_function_name) + api_function_name, api_function_name ) if internal_function_name != api_function_name: @@ -1341,11 +1437,14 @@ async def ask( if internal_fname == "get_forward_msg" and not isinstance( tool_result, Exception ): - asyncio.create_task( + task = asyncio.create_task( self._save_forward_to_history( content_str, pre_context, history_manager ) ) + task.add_done_callback( + lambda t: t.exception() if not t.cancelled() else None + ) if tool_context.get("conversation_ended"): conversation_ended = True diff --git a/src/Undefined/api/_context.py b/src/Undefined/api/_context.py index 07d8c92c..8ca6fd07 100644 --- a/src/Undefined/api/_context.py +++ b/src/Undefined/api/_context.py @@ -20,3 +20,5 @@ class RuntimeAPIContext: cognitive_job_queue: Any = None meme_service: Any = None naga_store: Any = None + message_batcher: Any = None + pipeline_registry: Any = None diff --git a/src/Undefined/api/_helpers.py b/src/Undefined/api/_helpers.py index 40dcfb00..0ae579a5 100644 --- a/src/Undefined/api/_helpers.py +++ b/src/Undefined/api/_helpers.py @@ -287,9 +287,10 @@ def _registry_summary(registry: Any) -> dict[str, Any]: summary_items: list[dict[str, Any]] = [] for name, item in items.items(): st = stats.get(name) + loaded = getattr(item, "loaded", True) entry: dict[str, Any] = { "name": name, - "loaded": getattr(item, "loaded", False), + "loaded": bool(loaded), } if st is not None: entry["calls"] = getattr(st, "count", 0) @@ -298,7 +299,7 @@ def _registry_summary(registry: Any) -> dict[str, Any]: summary_items.append(entry) return { "count": len(items), - "loaded": sum(1 for i in items.values() if getattr(i, "loaded", False)), + "loaded": sum(1 for item in items.values() if getattr(item, "loaded", True)), "items": summary_items, } diff --git a/src/Undefined/api/_openapi.py b/src/Undefined/api/_openapi.py index 1076256b..a6c5134c 100644 --- a/src/Undefined/api/_openapi.py +++ b/src/Undefined/api/_openapi.py @@ -39,7 +39,7 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "Returns system info (version, Python, platform, uptime), " "OneBot connection status, request queue snapshot, " "memory count, cognitive service status, API config, " - "skill statistics (tools/agents/anthropic_skills with call counts), " + "skill statistics (tools/toolsets/agents/pipelines/commands/anthropic_skills), " "and model configuration (names, masked URLs, thinking flags)." ), } diff --git a/src/Undefined/api/routes/system.py b/src/Undefined/api/routes/system.py index 2cf3915f..5dd0b879 100644 --- a/src/Undefined/api/routes/system.py +++ b/src/Undefined/api/routes/system.py @@ -35,6 +35,144 @@ _PROCESS_START_TIME = time.time() +def _toolsets_summary(tool_registry: Any) -> dict[str, Any]: + """从主工具注册表拆出 skills/toolsets 的独立摘要。""" + if tool_registry is None: + return {"count": 0, "loaded": 0, "categories": [], "items": []} + + items: dict[str, Any] = getattr(tool_registry, "_items", {}) + stats: dict[str, Any] = {} + get_stats = getattr(tool_registry, "get_stats", None) + if callable(get_stats): + stats = get_stats() + + category_totals: dict[str, dict[str, int]] = {} + summary_items: list[dict[str, Any]] = [] + for name, item in sorted(items.items()): + if "." not in name: + continue + category = name.split(".", 1)[0] + loaded = bool(getattr(item, "loaded", True)) + category_info = category_totals.setdefault( + category, + {"count": 0, "loaded": 0}, + ) + category_info["count"] += 1 + if loaded: + category_info["loaded"] += 1 + + st = stats.get(name) + entry: dict[str, Any] = { + "name": name, + "category": category, + "loaded": loaded, + } + if st is not None: + entry["calls"] = getattr(st, "count", 0) + entry["success"] = getattr(st, "success", 0) + entry["failure"] = getattr(st, "failure", 0) + summary_items.append(entry) + + categories = [ + {"name": name, **counts} for name, counts in sorted(category_totals.items()) + ] + return { + "count": len(summary_items), + "loaded": sum(1 for item in summary_items if item["loaded"]), + "categories": categories, + "items": summary_items, + } + + +async def _pipelines_summary(registry: Any) -> dict[str, Any]: + """生成 skills/pipelines 的探针摘要。""" + if registry is None: + return {"count": 0, "loaded": 0, "items": [], "hot_reload": False} + + items: dict[str, Any] + lock = getattr(registry, "_items_lock", None) + if lock is not None: + async with lock: + items = dict(getattr(registry, "_items", {})) + else: + items = dict(getattr(registry, "_items", {})) + + summary_items = [ + { + "name": name, + "loaded": True, + "order": getattr(item, "order", 0), + "description": getattr(item, "description", ""), + } + for name, item in sorted( + items.items(), key=lambda pair: (getattr(pair[1], "order", 0), pair[0]) + ) + ] + return { + "count": len(summary_items), + "loaded": len(summary_items), + "items": summary_items, + "hot_reload": getattr(registry, "_watch_task", None) is not None, + } + + +def _commands_summary(command_dispatcher: Any) -> dict[str, Any]: + """生成 skills/commands 的探针摘要。""" + command_registry = getattr(command_dispatcher, "command_registry", None) + if command_registry is None: + return { + "count": 0, + "loaded": 0, + "aliases": 0, + "subcommands": 0, + "items": [], + } + + list_commands = getattr(command_registry, "list_commands", None) + if not callable(list_commands): + return { + "count": 0, + "loaded": 0, + "aliases": 0, + "subcommands": 0, + "items": [], + } + + try: + commands = list_commands(include_hidden=True) + except TypeError: + commands = list_commands() + + summary_items: list[dict[str, Any]] = [] + alias_count = 0 + subcommand_count = 0 + for command in commands: + aliases = list(getattr(command, "aliases", []) or []) + subcommands = getattr(command, "subcommands", {}) or {} + alias_count += len(aliases) + subcommand_count += len(subcommands) + summary_items.append( + { + "name": getattr(command, "name", ""), + "loaded": True, + "handler_loaded": getattr(command, "handler", None) is not None, + "aliases": aliases, + "subcommands": len(subcommands), + "permission": getattr(command, "permission", "public"), + "allow_in_private": bool(getattr(command, "allow_in_private", False)), + "show_in_help": bool(getattr(command, "show_in_help", True)), + } + ) + + return { + "count": len(summary_items), + "loaded": len(summary_items), + "aliases": alias_count, + "subcommands": subcommand_count, + "items": summary_items, + } + + async def openapi_handler(ctx: RuntimeAPIContext, request: web.Request) -> Response: cfg = ctx.config_getter() if not bool(getattr(cfg.api, "openapi_enabled", True)): @@ -60,19 +198,27 @@ async def internal_probe_handler( cognitive_queue_snapshot = ( ctx.cognitive_job_queue.snapshot() if ctx.cognitive_job_queue else {} ) + message_batcher_snapshot = ( + ctx.message_batcher.snapshot() if ctx.message_batcher else {} + ) memory_storage = getattr(ctx.ai, "memory_storage", None) memory_count = memory_storage.count() if memory_storage is not None else 0 # Skills 统计 ai = ctx.ai - skills_info: dict[str, Any] = {} - if ai is not None: - tool_reg = getattr(ai, "tool_registry", None) - agent_reg = getattr(ai, "agent_registry", None) - anthropic_reg = getattr(ai, "anthropic_skill_registry", None) - skills_info["tools"] = _registry_summary(tool_reg) - skills_info["agents"] = _registry_summary(agent_reg) - skills_info["anthropic_skills"] = _registry_summary(anthropic_reg) + tool_reg = getattr(ai, "tool_registry", None) if ai is not None else None + agent_reg = getattr(ai, "agent_registry", None) if ai is not None else None + anthropic_reg = ( + getattr(ai, "anthropic_skill_registry", None) if ai is not None else None + ) + skills_info: dict[str, Any] = { + "tools": _registry_summary(tool_reg), + "toolsets": _toolsets_summary(tool_reg), + "agents": _registry_summary(agent_reg), + "pipelines": await _pipelines_summary(ctx.pipeline_registry), + "commands": _commands_summary(ctx.command_dispatcher), + "anthropic_skills": _registry_summary(anthropic_reg), + } # 模型配置(脱敏) models_info: dict[str, Any] = {} @@ -113,6 +259,7 @@ async def internal_probe_handler( "uptime_seconds": uptime_seconds, "onebot": ctx.onebot.connection_status() if ctx.onebot is not None else {}, "queues": queue_snapshot, + "message_batcher": message_batcher_snapshot, "memory": {"count": memory_count, "virtual_user_id": _VIRTUAL_USER_ID}, "cognitive": { "enabled": bool(ctx.cognitive_service and ctx.cognitive_service.enabled), diff --git a/src/Undefined/config/__init__.py b/src/Undefined/config/__init__.py index 8ced52e3..7242f3cf 100644 --- a/src/Undefined/config/__init__.py +++ b/src/Undefined/config/__init__.py @@ -11,8 +11,10 @@ EmbeddingModelConfig, GrokModelConfig, MemeConfig, + MessageBatcherConfig, ModelPool, ModelPoolEntry, + RenderCacheConfig, RerankModelConfig, SecurityModelConfig, VisionModelConfig, @@ -31,6 +33,8 @@ "ModelPool", "ModelPoolEntry", "MemeConfig", + "MessageBatcherConfig", + "RenderCacheConfig", "get_config", "get_config_manager", "load_webui_settings", diff --git a/src/Undefined/config/domain_parsers.py b/src/Undefined/config/domain_parsers.py index 07f807b1..986c5cb1 100644 --- a/src/Undefined/config/domain_parsers.py +++ b/src/Undefined/config/domain_parsers.py @@ -17,7 +17,9 @@ APIConfig, CognitiveConfig, MemeConfig, + MessageBatcherConfig, NagaConfig, + RenderCacheConfig, ) DEFAULT_API_HOST = "127.0.0.1" @@ -177,6 +179,74 @@ def _parse_memes_config(data: dict[str, Any]) -> MemeConfig: ) +_VALID_BATCHER_STRATEGIES: set[str] = {"extend", "fixed"} + + +def _parse_message_batcher_config(data: dict[str, Any]) -> MessageBatcherConfig: + section_raw = data.get("message_batcher", {}) + section = section_raw if isinstance(section_raw, dict) else {} + strategy = _coerce_str(section.get("strategy"), "extend").strip().lower() + if strategy not in _VALID_BATCHER_STRATEGIES: + strategy = "extend" + window_seconds = _coerce_float(section.get("window_seconds"), 5.0) + if window_seconds < 0: + window_seconds = 0.0 + # max_window_seconds <= 0 视为不限制(仅靠 window_seconds + max_messages_per_batch 触发) + max_window_seconds = _coerce_float(section.get("max_window_seconds"), 30.0) + if max_window_seconds < 0: + max_window_seconds = 0.0 + if 0 < max_window_seconds < window_seconds: + max_window_seconds = window_seconds + max_messages = _coerce_int(section.get("max_messages_per_batch"), 0) + if max_messages < 0: + max_messages = 0 + pre_send_seconds = _coerce_float(section.get("pre_send_seconds"), 0.0) + if pre_send_seconds < 0: + pre_send_seconds = 0.0 + # pre_send 必须严格小于 window 才有意义;不满足则直接关闭投机 + if pre_send_seconds >= window_seconds: + pre_send_seconds = 0.0 + return MessageBatcherConfig( + enabled=_coerce_bool(section.get("enabled"), True), + window_seconds=window_seconds, + strategy=strategy, + max_window_seconds=max_window_seconds, + max_messages_per_batch=max_messages, + group_enabled=_coerce_bool(section.get("group_enabled"), True), + private_enabled=_coerce_bool(section.get("private_enabled"), True), + flush_on_command=_coerce_bool(section.get("flush_on_command"), False), + pre_send_seconds=pre_send_seconds, + allow_cancel_after_send=_coerce_bool( + section.get("allow_cancel_after_send"), False + ), + ) + + +def _parse_render_cache_config(data: dict[str, Any]) -> RenderCacheConfig: + """解析 ``[render.cache]`` 段,落到 :class:`RenderCacheConfig`。 + + 所有上限会做下界保护(>=1 / >=0.0),避免负值导致驱逐失控。 + """ + render_raw = data.get("render", {}) + render_section = render_raw if isinstance(render_raw, dict) else {} + cache_raw = render_section.get("cache", {}) + cache_section = cache_raw if isinstance(cache_raw, dict) else {} + + enabled = _coerce_bool(cache_section.get("enabled"), True) + max_entries = max(1, _coerce_int(cache_section.get("max_entries"), 50)) + max_size_mb = max(1, _coerce_int(cache_section.get("max_size_mb"), 50)) + flush_interval = max( + 0.0, + _coerce_float(cache_section.get("flush_interval_seconds"), 2.0), + ) + return RenderCacheConfig( + enabled=enabled, + max_entries=max_entries, + max_size_mb=max_size_mb, + flush_interval_seconds=flush_interval, + ) + + def _parse_api_config(data: dict[str, Any]) -> APIConfig: section_raw = data.get("api", {}) section = section_raw if isinstance(section_raw, dict) else {} diff --git a/src/Undefined/config/hot_reload.py b/src/Undefined/config/hot_reload.py index 1ba6dc3a..16047134 100644 --- a/src/Undefined/config/hot_reload.py +++ b/src/Undefined/config/hot_reload.py @@ -74,6 +74,7 @@ "summary_model", "historian_model", "grok_model", + "missing_tool_call_retries", ) _AGENT_INTRO_KEYS: set[str] = { @@ -98,6 +99,18 @@ _ATTACHMENT_KEYS: set[str] = {"attachment_remote_download_max_size_mb"} +_MESSAGE_BATCHER_KEYS: set[str] = { + "message_batcher", + "message_batcher.enabled", + "message_batcher.window_seconds", + "message_batcher.strategy", + "message_batcher.max_window_seconds", + "message_batcher.max_messages_per_batch", + "message_batcher.group_enabled", + "message_batcher.private_enabled", + "message_batcher.flush_on_command", +} + @dataclass class HotReloadContext: @@ -143,6 +156,14 @@ def apply_config_updates( if _needs_attachment_update(changed_keys): context.ai_client.apply_attachment_config(updated) + if _needs_message_batcher_update(changed_keys): + handler = context.message_handler + if ( + handler is not None + and getattr(handler, "message_batcher", None) is not None + ): + handler.message_batcher.update_config(updated.message_batcher) + if _needs_core_ai_model_update(changed_keys): context.ai_client.apply_model_configs( chat_config=updated.chat_model, @@ -202,6 +223,13 @@ def _needs_attachment_update(changed_keys: set[str]) -> bool: return bool(changed_keys & _ATTACHMENT_KEYS) +def _needs_message_batcher_update(changed_keys: set[str]) -> bool: + return any( + key == "message_batcher" or key.startswith("message_batcher.") + for key in changed_keys + ) + + def _matches_prefixes(changed_keys: set[str], prefixes: tuple[str, ...]) -> bool: return any( key == prefix or key.startswith(f"{prefix}.") diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index d40411c9..2bc49d7f 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -36,7 +36,9 @@ def load_dotenv( ImageGenConfig, ImageGenModelConfig, MemeConfig, + MessageBatcherConfig, NagaConfig, + RenderCacheConfig, RerankModelConfig, SecurityModelConfig, VisionModelConfig, @@ -98,7 +100,9 @@ def load_dotenv( _parse_cognitive_config, _parse_easter_egg_call_mode, _parse_memes_config, + _parse_message_batcher_config, _parse_naga_config, + _parse_render_cache_config, _update_dataclass, ) @@ -223,6 +227,7 @@ class Config: inverted_question_enabled: bool context_recent_messages_limit: int ai_request_max_retries: int + missing_tool_call_retries: int nagaagent_mode_enabled: bool onebot_ws_url: str onebot_token: str @@ -351,6 +356,10 @@ class Config: cognitive: CognitiveConfig # 表情包库 memes: MemeConfig + # 同 sender 短时多消息合并器 + message_batcher: MessageBatcherConfig + # HTML 渲染结果缓存 + render_cache: RenderCacheConfig # Naga 集成 naga: NagaConfig # 生图工具配置 @@ -552,6 +561,17 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi if ai_request_max_retries < 0: ai_request_max_retries = 0 + missing_tool_call_retries = _coerce_int( + _get_value( + data, + ("core", "missing_tool_call_retries"), + "MISSING_TOOL_CALL_RETRIES", + ), + 3, + ) + if missing_tool_call_retries < 0: + missing_tool_call_retries = 0 + nagaagent_mode_enabled = _coerce_bool( _get_value( data, @@ -1279,6 +1299,8 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi cognitive = _parse_cognitive_config(data) memes = _parse_memes_config(data) + message_batcher = _parse_message_batcher_config(data) + render_cache = _parse_render_cache_config(data) naga = _parse_naga_config(data) models_image_gen = _parse_image_gen_model_config(data) models_image_edit = _parse_image_edit_model_config(data) @@ -1328,6 +1350,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi inverted_question_enabled=inverted_question_enabled, context_recent_messages_limit=context_recent_messages_limit, ai_request_max_retries=ai_request_max_retries, + missing_tool_call_retries=missing_tool_call_retries, nagaagent_mode_enabled=nagaagent_mode_enabled, onebot_ws_url=onebot_ws_url, onebot_token=onebot_token, @@ -1447,6 +1470,8 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi knowledge_rerank_top_k=knowledge_rerank_top_k, cognitive=cognitive, memes=memes, + message_batcher=message_batcher, + render_cache=render_cache, naga=naga, image_gen=image_gen, models_image_gen=models_image_gen, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index b2ae1cdc..0a272b57 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -349,6 +349,46 @@ class MemeConfig: gif_analysis_frames: int = 6 +@dataclass +class MessageBatcherConfig: + """同 sender 短时多消息合并器配置。 + + 将同一 sender 在 ``window_seconds`` 内连续发送的消息合并到同一轮 AI 触发, + 避免重复回复 / 行为打架。详见 ``services/message_batcher.py``。 + """ + + enabled: bool = True + window_seconds: float = 5.0 + strategy: str = "extend" # extend | fixed + max_window_seconds: float = 30.0 + max_messages_per_batch: int = 0 # 0 = 不限制 + group_enabled: bool = True + private_enabled: bool = True + flush_on_command: bool = False + # 投机预发送:在 window_seconds 静默达到 pre_send_seconds(< window_seconds)时, + # 提前把当前批次发给 LLM 抢时间;若 LLM 出结果前又来新消息,则取消该投机调用并重新计时。 + # 设为 0 或 >= window_seconds 时关闭投机模式(行为退化为旧版:仅 window_seconds 触发)。 + pre_send_seconds: float = 0.0 + # 投机调用已发出过消息后再来新消息时是否仍取消该调用: + # false(默认安全)— LLM 已经发出消息就不再取消,新消息开新 batch; + # true — 仍取消(可能导致重复发送,仅在极端场景启用)。 + allow_cancel_after_send: bool = False + + +@dataclass +class RenderCacheConfig: + """HTML 渲染结果缓存配置。 + + 缓存单例由 :func:`Undefined.utils.render_cache.get_render_cache` 加载, + 在程序退出时通过 :func:`close_render_cache` 强制刷盘。 + """ + + enabled: bool = True + max_entries: int = 50 + max_size_mb: int = 50 + flush_interval_seconds: float = 2.0 + + @dataclass class APIConfig: """主进程 OpenAPI/Runtime API 配置""" diff --git a/src/Undefined/handlers.py b/src/Undefined/handlers.py index 5b316bd4..6866746e 100644 --- a/src/Undefined/handlers.py +++ b/src/Undefined/handlers.py @@ -38,8 +38,10 @@ from Undefined.services.security import SecurityService from Undefined.services.command import CommandDispatcher from Undefined.services.ai_coordinator import AICoordinator +from Undefined.services.message_batcher import MessageBatcher, make_scope from Undefined.services.model_pool import ModelPoolService -from Undefined.skills.auto_pipeline import AutoPipelineRegistry +from Undefined.skills.pipelines import PipelineRegistry +from Undefined.skills.pipelines.context import build_pipeline_context from Undefined.utils.resources import resolve_resource_path from Undefined.utils.queue_intervals import build_model_queue_intervals @@ -137,12 +139,19 @@ def __init__( command_dispatcher=self.command_dispatcher, ) + # 同 sender 短时多消息合并器;coordinator 决定是否旁路 + self.message_batcher = MessageBatcher( + config.message_batcher, + flush_callback=self.ai_coordinator.handle_batched_dispatch, + ) + self.ai_coordinator.set_batcher(self.message_batcher) + self._background_tasks: set[asyncio.Task[None]] = set() self._profile_name_refresh_cache: dict[tuple[str, int], str] = {} self._bot_nickname_cache = BotNicknameCache(onebot, config.bot_qq) - self.auto_pipeline_registry = AutoPipelineRegistry() - self._auto_pipeline_initialized = False - self._auto_pipeline_init_lock = asyncio.Lock() + self.pipeline_registry = PipelineRegistry() + self._pipelines_initialized = False + self._pipelines_init_lock = asyncio.Lock() # 复读功能状态(按群跟踪最近消息文本与发送者) self._repeat_counter: dict[int, list[tuple[str, int]]] = {} @@ -155,23 +164,23 @@ def __init__( async def initialize(self) -> None: """完成需要事件循环承载的异步初始化。""" - await self.initialize_auto_pipeline() + await self.init_pipelines() - async def initialize_auto_pipeline(self) -> None: + async def init_pipelines(self) -> None: """异步加载自动处理管线并按配置启动热重载。""" - if getattr(self, "_auto_pipeline_initialized", False): + if getattr(self, "_pipelines_initialized", False): return - init_lock = getattr(self, "_auto_pipeline_init_lock", None) + init_lock = getattr(self, "_pipelines_init_lock", None) if init_lock is None: init_lock = asyncio.Lock() - self._auto_pipeline_init_lock = init_lock + self._pipelines_init_lock = init_lock async with init_lock: - if getattr(self, "_auto_pipeline_initialized", False): + if getattr(self, "_pipelines_initialized", False): return - await self.auto_pipeline_registry.load_items_async() - self._auto_pipeline_initialized = True + await self.pipeline_registry.load_items_async() + self._pipelines_initialized = True if getattr(self.config, "skills_hot_reload", False): - self.auto_pipeline_registry.start_hot_reload( + self.pipeline_registry.start_hot_reload( interval=self.config.skills_hot_reload_interval, debounce=self.config.skills_hot_reload_debounce, ) @@ -651,6 +660,10 @@ async def handle_message(self, event: dict[str, Any]) -> None: private_command = self.command_dispatcher.parse_command(text) if private_command: + await self._flush_command_buffer( + scope=make_scope(user_id=private_sender_id), + sender_id=private_sender_id, + ) await self.command_dispatcher.dispatch_private( user_id=private_sender_id, sender_id=private_sender_id, @@ -658,7 +671,7 @@ async def handle_message(self, event: dict[str, Any]) -> None: ) return - await self._run_auto_extract_pipeline( + await self._run_pipelines( target_id=private_sender_id, target_type="private", text=text, @@ -824,6 +837,10 @@ async def _fetch_group_name() -> str: if is_at_bot: command = self.command_dispatcher.parse_command(normalized_text) if command: + await self._flush_command_buffer( + scope=make_scope(group_id=group_id), + sender_id=sender_id, + ) await self.command_dispatcher.dispatch(group_id, sender_id, command) return @@ -919,7 +936,7 @@ async def _fetch_group_name() -> str: ) return - await self._run_auto_extract_pipeline( + await self._run_pipelines( target_id=group_id, target_type="group", text=text, @@ -1101,7 +1118,22 @@ async def _extract_bilibili_ids( bvids = await extract_from_json_message(message_content) return bvids - async def _run_auto_extract_pipeline( + async def _flush_command_buffer(self, *, scope: str, sender_id: int) -> None: + batcher_config = getattr(self.config, "message_batcher", None) + if not getattr(batcher_config, "flush_on_command", False): + return + batcher = getattr(self, "message_batcher", None) + if batcher is None: + return + flushed = await batcher.flush_sender(scope, sender_id) + if not flushed: + logger.warning( + "[MessageBatcher] 命令触发 flush 当前 buffer 失败: scope=%s sender=%s", + scope, + sender_id, + ) + + async def _run_pipelines( self, *, target_id: int, @@ -1110,25 +1142,16 @@ async def _run_auto_extract_pipeline( message_content: list[dict[str, Any]], ) -> bool: """并行检测并处理所有命中的自动处理管线。""" - if not getattr(self, "_auto_pipeline_initialized", False): - await self.initialize_auto_pipeline() - detections = await self.auto_pipeline_registry.run( - { - "config": self.config, - "sender": self.sender, - "onebot": self.onebot, - "target_id": target_id, - "target_type": target_type, - "text": text, - "message_content": message_content, - "extract_bilibili_ids": self._extract_bilibili_ids, - "extract_arxiv_ids": self._extract_arxiv_ids, - "extract_github_repo_ids": self._extract_github_repo_ids, - "handle_bilibili_extract": self._handle_bilibili_extract, - "handle_arxiv_extract": self._handle_arxiv_extract, - "handle_github_extract": self._handle_github_extract, - } + if not getattr(self, "_pipelines_initialized", False): + await self.init_pipelines() + context = build_pipeline_context( + self, + target_id=target_id, + target_type=target_type, + text=text, + message_content=message_content, ) + detections = await self.pipeline_registry.run(context) return bool(detections) async def apply_skills_hot_reload_config( @@ -1138,14 +1161,14 @@ async def apply_skills_hot_reload_config( interval: float, debounce: float, ) -> None: - """跟随全局 skills 热重载配置更新自动处理管线。""" + """跟随全局 skills 热重载配置更新管线。""" if not enabled: - await self.auto_pipeline_registry.stop_hot_reload() - logger.info("[auto_pipeline] 热重载已随配置禁用") + await self.pipeline_registry.stop_hot_reload() + logger.info("[pipelines] 热重载已随配置禁用") return - await self.auto_pipeline_registry.stop_hot_reload() - self.auto_pipeline_registry.start_hot_reload( + await self.pipeline_registry.stop_hot_reload() + self.pipeline_registry.start_hot_reload( interval=interval, debounce=debounce, ) @@ -1365,7 +1388,9 @@ async def close(self) -> None: *list(self._background_tasks), return_exceptions=True, ) - await self.history_manager.flush_pending_saves() - await self.auto_pipeline_registry.stop_hot_reload() + await self.pipeline_registry.stop_hot_reload() + await self.message_batcher.flush_all() + await self.ai_coordinator.queue_manager.drain() await self.ai_coordinator.queue_manager.stop() + await self.history_manager.flush_pending_saves() logger.info("消息处理器已关闭") diff --git a/src/Undefined/main.py b/src/Undefined/main.py index 6826d0a0..df95c086 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -43,6 +43,7 @@ restart_process, ) from Undefined.render import close_browser as close_render_browser +from Undefined.utils.render_cache import close_render_cache def ensure_runtime_dirs() -> None: @@ -478,6 +479,8 @@ def _apply_config_updates( cognitive_job_queue=job_queue, meme_service=meme_service, naga_store=naga_store, + message_batcher=handler.message_batcher, + pipeline_registry=handler.pipeline_registry, ) runtime_api_server = RuntimeAPIServer( runtime_api_context, @@ -509,6 +512,10 @@ def _apply_config_updates( logger.exception("[异常] 运行期间发生未捕获的错误: %s", exc) finally: logger.info("[清理] 正在关闭机器人并释放资源...") + try: + await handler.close() + except Exception: + logger.exception("[清理] MessageHandler close 失败") if runtime_api_server is not None: await runtime_api_server.stop() if meme_worker is not None: @@ -521,6 +528,7 @@ def _apply_config_updates( await retrieval_runtime.stop() await config_manager.stop_hot_reload() await close_render_browser() + await close_render_cache() logger.info("[退出] 机器人已停止运行") diff --git a/src/Undefined/render.py b/src/Undefined/render.py index 744901fb..b8c309c9 100644 --- a/src/Undefined/render.py +++ b/src/Undefined/render.py @@ -2,14 +2,17 @@ import asyncio import logging -import markdown import sys from collections.abc import Awaitable, Callable +from pathlib import Path from playwright.async_api import async_playwright, Browser, Page, Playwright +import markdown + from typing import Any, TypeVar from Undefined.config import get_config +from Undefined.utils.render_cache import compute_render_cache_key, get_render_cache logger = logging.getLogger(__name__) @@ -59,6 +62,14 @@ _RenderResult = TypeVar("_RenderResult") +def _safe_file_size(path: Path) -> int: + """同步取文件大小(在 ``asyncio.to_thread`` 中调用);不存在/不可读时返回 0。""" + try: + return path.stat().st_size + except OSError: + return 0 + + def _resolve_render_browser_max_concurrency() -> int: """解析渲染浏览器并发上限,0 表示沿用平台默认值。""" try: @@ -209,15 +220,17 @@ async def render_html_to_image( timeout_ms: 截图超时时间(毫秒),默认 60000 proxy: 可选浏览器代理地址 """ + cache = await get_render_cache() + cache_key = compute_render_cache_key( + html_content, viewport_width, screenshot_selector, proxy + ) + + if await cache.copy_to(cache_key, output_path): + return async def _capture(page: Page) -> None: - # 等待网络空闲(确保 CDN 上的 MathJax/Mermaid 脚本加载完) await page.wait_for_load_state("networkidle", timeout=timeout_ms) - - # 给 Mermaid 一点时间执行 JS 绘图 await asyncio.sleep(1) - - # 截图(带超时保护) if screenshot_selector: await page.locator(screenshot_selector).first.screenshot( path=output_path, @@ -238,6 +251,10 @@ async def _capture(page: Page) -> None: proxy=proxy, ) + output_size = await asyncio.to_thread(_safe_file_size, Path(output_path)) + if output_size > 0: + await cache.put(cache_key, output_path, output_size) + async def render_html_with_page( html_content: str, diff --git a/src/Undefined/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py index 98ca1ea7..e2d9f73b 100644 --- a/src/Undefined/services/ai_coordinator.py +++ b/src/Undefined/services/ai_coordinator.py @@ -1,4 +1,6 @@ +import asyncio import logging +import time from datetime import datetime from pathlib import Path from typing import Any, Optional @@ -15,6 +17,11 @@ from Undefined.render import render_html_to_image, render_markdown_to_html from Undefined.services.model_pool import ModelPoolService from Undefined.services.queue_manager import QueueManager, QUEUE_LANE_BACKGROUND +from Undefined.services.message_batcher import ( + BufferedMessage, + MessageBatcher, + make_scope, +) from Undefined.utils.history import MessageHistoryManager from Undefined.utils.sender import MessageSender from Undefined.utils.scheduler import TaskScheduler @@ -35,6 +42,44 @@ ) +_GROUP_STRATEGY_FOOTER = """ + + 【回复策略 - 更克制,纯表情包才前置检索】 + 1. 如果用户 @ 了你或拍了拍你 → 【必须回复】 + 2. 如果消息中明确提到了你(根据上下文判断用户是否在叫你或维持对话流) → 【必须回复】 + 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 + 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 + 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: + - 如果明显是在和别人说话 → 【不要回复】 + - 如果你不能确定是不是在和你说话 → 【默认不回复】 + - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 + 6. 群聊里的主动参与只保留给公开、开放的技术或项目讨论: + - 只在多人公开讨论代码、AI、开发工具、项目进展、技术 bug 等,且不是别人之间定向交流时,才可以【极低频参与】 + - 默认更倾向不参与;不要长篇大论,一两句点到为止;如果别人已经在深入讨论且不需要你,保持沉默 + - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复,且本轮明确是纯表情包/纯反应图时,才优先考虑表情包表达 + 7. 对于已经决定要回复的场景(包括被@、被拍一拍、轻量答疑,以及少量符合条件的主动参与): + - 只有明确纯表情包回复才先检索表情包,再用 memes.send_meme_by_uid 单独发一条图片消息 + - 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message + - 如果确实还想补表情包,把 memes.search_memes 和 memes.send_meme_by_uid 放到文字发送后的后续响应轮次,不要阻塞首条文字回复 + - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end + - 严肃、任务型、高信息密度场景少发表情包,避免打断信息传递 + - 绝不要刷屏、绝不要每条都回 + 8. 对于本来就会回复的场景(私聊、被拍一拍、被@、轻量答疑): + - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也只能作为后续可选补充 + - 但不要为了发表情包而牺牲信息传递;信息密度优先时仍以文字为主 + + 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好,表情包最后再搜。""" + + +_PRIVATE_STRATEGY_FOOTER = """ + +【私聊消息】 +这是私聊消息,用户专门来找你说话。你可以自由选择是否回复: +- 如果想回复,先调用 send_message 工具发送回复内容,然后调用 end 结束对话 +- 只有明确纯表情包回复时,才先用 memes.search_memes 查表情包,再用 memes.send_meme_by_uid 单独发图;其他场景先把文字回复做好,表情包最后再搜或不搜 +- 如果不想回复,直接调用 end 结束对话即可""" + + class AICoordinator: """AI 协调器,处理 AI 回复逻辑、Prompt 构建和队列管理""" @@ -60,6 +105,22 @@ def __init__( self.security = security self.command_dispatcher = command_dispatcher self.model_pool = ModelPoolService(ai, config, sender) + # batcher 由外部(handlers.py)创建并通过 set_batcher 注入;未注入时所有消息按单条流程直送。 + self._batcher: MessageBatcher | None = None + + def set_batcher(self, batcher: MessageBatcher | None) -> None: + """注入消息合并器;传 None 等同于禁用合并。""" + self._batcher = batcher + + @property + def batcher(self) -> MessageBatcher | None: + return self._batcher + + async def handle_batched_dispatch(self, items: list[BufferedMessage]) -> None: + """:class:`MessageBatcher` 的 flush_callback:把一批消息组装为单次请求并入队。""" + if not items: + return + await self._dispatch_grouped_request(items) async def handle_auto_reply( self, @@ -116,61 +177,47 @@ async def handle_auto_reply( ) return - prompt_prefix = ( - "(用户拍了拍你) " if is_poke else ("(用户 @ 了你) " if is_at_bot else "") + scope = make_scope(group_id=group_id) + item = BufferedMessage( + scope=scope, + sender_id=sender_id, + text=text, + message_content=list(message_content), + attachments=list(attachments or []), + sender_name=sender_name, + arrival_time=time.time(), + is_private=False, + trigger_message_id=trigger_message_id, + is_poke=is_poke, + is_at_bot=is_at_bot, + is_fake_at=is_fake_at, + group_id=group_id, + group_name=group_name, + sender_role=sender_role, + sender_title=sender_title, + sender_level=sender_level, ) - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - location = group_name if group_name.endswith("群") else f"{group_name}群" - full_question = self._build_prompt( - prompt_prefix, - sender_name, - sender_id, - group_id, - group_name, - location, - sender_role, - sender_title, - current_time, - text, - attachments=attachments, - message_id=trigger_message_id, - level=sender_level, - ) - logger.debug( - "[自动回复] full_question_len=%s group=%s sender=%s", - len(full_question), - group_id, - sender_id, - ) + # 路由:拍一拍 → 永远旁路;否则按 batcher 启用情况与 @bot 处理规则决定 + if is_poke: + await self._dispatch_grouped_request([item]) + return - request_data = { - "type": "auto_reply", - "group_id": group_id, - "sender_id": sender_id, - "sender_name": sender_name, - "group_name": group_name, - "text": text, - "full_question": full_question, - "is_at_bot": is_at_bot, - "trigger_message_id": trigger_message_id, - } + batcher = getattr(self, "_batcher", None) + if batcher is not None and batcher.is_enabled_for(is_group=True): + if is_at_bot and batcher.has_buffer(scope, sender_id): + # 已有 buffer 时再来一条 @bot:单独立即处理,不打断现有 buffer + logger.info( + "[自动回复] batch 内 @bot 旁路立即处理: group=%s sender=%s", + group_id, + sender_id, + ) + await self._dispatch_grouped_request([item]) + return + await batcher.submit(item) + return - if sender_id == self.config.superadmin_qq: - logger.info("[AI] 投递至群聊超级管理员队列") - await self.queue_manager.add_group_superadmin_request( - request_data, model_name=self.config.chat_model.model_name - ) - elif is_at_bot: - logger.info(f"[AI] 触发原因: {'拍一拍' if is_poke else '@机器人'}") - await self.queue_manager.add_group_mention_request( - request_data, model_name=self.config.chat_model.model_name - ) - else: - logger.info("[AI] 投递至普通请求队列") - await self.queue_manager.add_group_normal_request( - request_data, model_name=self.config.chat_model.model_name - ) + await self._dispatch_grouped_request([item]) async def handle_private_reply( self, @@ -193,52 +240,30 @@ async def handle_private_reply( await self._handle_injection_response(user_id, text, is_private=True) return - prompt_prefix = "(用户拍了拍你) " if is_poke else "" - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - message_id_attr = "" - if trigger_message_id is not None: - message_id_attr = f' message_id="{escape_xml_attr(trigger_message_id)}"' - attachment_xml = ( - f"\n{attachment_refs_to_xml(attachments)}" if attachments else "" + scope = make_scope(user_id=user_id) + item = BufferedMessage( + scope=scope, + sender_id=user_id, + text=text, + message_content=list(message_content), + attachments=list(attachments or []), + sender_name=sender_name, + arrival_time=time.time(), + is_private=True, + trigger_message_id=trigger_message_id, + is_poke=is_poke, ) - full_question = f"""{prompt_prefix} - {escape_xml_text(text)}{attachment_xml} - - -【私聊消息】 -这是私聊消息,用户专门来找你说话。你可以自由选择是否回复: -- 如果想回复,先调用 send_message 工具发送回复内容,然后调用 end 结束对话 -- 如果你已经决定回复,并且表情包能让表达更像真人,也可以先用 memes.search_memes 查表情包,再用 memes.send_meme_by_uid 单独发图 -- 如果不想回复,直接调用 end 结束对话即可""" - request_data = { - "type": "private_reply", - "user_id": user_id, - "sender_name": sender_name, - "text": text, - "full_question": full_question, - "trigger_message_id": trigger_message_id, - } - logger.debug( - "[私聊回复] full_question_len=%s user=%s", - len(full_question), - user_id, - ) + if is_poke: + await self._dispatch_grouped_request([item]) + return - # 动态选择模型(私聊 group_id=0) - effective_config = self.model_pool.select_chat_config( - self.config.chat_model, user_id=user_id - ) - request_data["selected_model_name"] = effective_config.model_name + batcher = getattr(self, "_batcher", None) + if batcher is not None and batcher.is_enabled_for(is_group=False): + await batcher.submit(item) + return - if user_id == self.config.superadmin_qq: - await self.queue_manager.add_superadmin_request( - request_data, model_name=effective_config.model_name - ) - else: - await self.queue_manager.add_private_request( - request_data, model_name=effective_config.model_name - ) + await self._dispatch_grouped_request([item]) async def execute_reply(self, request: dict[str, Any]) -> None: """执行排队中的回复请求(由 QueueManager 分发调用) @@ -249,6 +274,16 @@ async def execute_reply(self, request: dict[str, Any]) -> None: """执行回复请求(由 QueueManager 调用)""" req_type = request.get("type", "unknown") logger.debug("[执行请求] type=%s keys=%s", req_type, list(request.keys())) + batch_token = request.get("_message_batcher_token") + if bool(getattr(batch_token, "cancelled", False)): + logger.info( + "[MessageBatcher] 跳过已取消的投机请求: type=%s scope=%s sender=%s batch=%s", + req_type, + getattr(batch_token, "scope", ""), + getattr(batch_token, "sender_id", ""), + getattr(batch_token, "batch_id", ""), + ) + return if req_type == "auto_reply": await self._execute_auto_reply(request) elif req_type == "private_reply": @@ -267,6 +302,8 @@ async def _execute_auto_reply(self, request: dict[str, Any]) -> None: group_name = str(request.get("group_name") or "未知群聊") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + # 用于向 batcher 注册 inflight 任务(仅当本请求源自合并桶时生效) + batcher_scope: str | None = make_scope(group_id=group_id) if group_id else None # 创建请求上下文 async with RequestContext( @@ -342,27 +379,58 @@ async def send_like_cb(uid: int, times: int = 1) -> None: ) try: - await self.ai.ask( - full_question, - send_message_callback=send_msg_cb, - get_recent_messages_callback=get_recent_cb, - get_image_url_callback=self.onebot.get_image, - get_forward_msg_callback=self.onebot.get_forward_msg, - send_like_callback=send_like_cb, - sender=self.sender, - history_manager=self.history_manager, - onebot_client=self.onebot, - scheduler=self.scheduler, - extra_context={ - "render_html_to_image": render_html_to_image, - "render_markdown_to_html": render_markdown_to_html, - "group_id": group_id, - "user_id": sender_id, - "is_at_bot": bool(request.get("is_at_bot", False)), - "sender_name": sender_name, - "group_name": group_name, - }, + # 把当前 task 注册到 batcher,使其有能力在新消息到达时取消本次 LLM 调用 + batcher = getattr(self, "_batcher", None) + current_task = asyncio.current_task() + registered_task: asyncio.Task[Any] | None = None + if ( + batcher is not None + and batcher_scope is not None + and current_task is not None + ): + batcher.register_inflight( + batcher_scope, sender_id, current_task, ctx + ) + registered_task = current_task + try: + await self.ai.ask( + full_question, + send_message_callback=send_msg_cb, + get_recent_messages_callback=get_recent_cb, + get_image_url_callback=self.onebot.get_image, + get_forward_msg_callback=self.onebot.get_forward_msg, + send_like_callback=send_like_cb, + sender=self.sender, + history_manager=self.history_manager, + onebot_client=self.onebot, + scheduler=self.scheduler, + extra_context={ + "render_html_to_image": render_html_to_image, + "render_markdown_to_html": render_markdown_to_html, + "group_id": group_id, + "user_id": sender_id, + "is_at_bot": bool(request.get("is_at_bot", False)), + "sender_name": sender_name, + "group_name": group_name, + }, + ) + finally: + if ( + batcher is not None + and batcher_scope is not None + and registered_task is not None + ): + batcher.unregister_inflight( + batcher_scope, sender_id, registered_task + ) + except asyncio.CancelledError: + # 投机预发送被新消息抢占取消:不写错误日志、不重试 + logger.info( + "[自动回复] 任务被取消(投机抢占): group=%s sender=%s", + group_id, + sender_id, ) + raise except Exception: logger.exception("自动回复执行出错") raise @@ -372,6 +440,7 @@ async def _execute_private_reply(self, request: dict[str, Any]) -> None: sender_name = str(request.get("sender_name") or "未知用户") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + batcher_scope: str | None = make_scope(user_id=user_id) # 创建请求上下文 async with RequestContext( @@ -442,26 +511,46 @@ async def send_private_cb( ) try: - result = await self.ai.ask( - full_question, - send_message_callback=send_msg_cb, - get_recent_messages_callback=get_recent_cb, - get_image_url_callback=self.onebot.get_image, - get_forward_msg_callback=self.onebot.get_forward_msg, - send_like_callback=send_like_cb, - sender=self.sender, - history_manager=self.history_manager, - onebot_client=self.onebot, - scheduler=self.scheduler, - extra_context={ - "render_html_to_image": render_html_to_image, - "render_markdown_to_html": render_markdown_to_html, - "user_id": user_id, - "is_private_chat": True, - "sender_name": sender_name, - "selected_model_name": request.get("selected_model_name"), - }, - ) + batcher = getattr(self, "_batcher", None) + current_task = asyncio.current_task() + registered_task: asyncio.Task[Any] | None = None + if ( + batcher is not None + and batcher_scope is not None + and current_task is not None + ): + batcher.register_inflight(batcher_scope, user_id, current_task, ctx) + registered_task = current_task + try: + result = await self.ai.ask( + full_question, + send_message_callback=send_msg_cb, + get_recent_messages_callback=get_recent_cb, + get_image_url_callback=self.onebot.get_image, + get_forward_msg_callback=self.onebot.get_forward_msg, + send_like_callback=send_like_cb, + sender=self.sender, + history_manager=self.history_manager, + onebot_client=self.onebot, + scheduler=self.scheduler, + extra_context={ + "render_html_to_image": render_html_to_image, + "render_markdown_to_html": render_markdown_to_html, + "user_id": user_id, + "is_private_chat": True, + "sender_name": sender_name, + "selected_model_name": request.get("selected_model_name"), + }, + ) + finally: + if ( + batcher is not None + and batcher_scope is not None + and registered_task is not None + ): + batcher.unregister_inflight( + batcher_scope, user_id, registered_task + ) if result: scope_key = build_attachment_scope( user_id=user_id, @@ -484,6 +573,9 @@ async def send_private_cb( target_type="private", target_id=user_id, ) + except asyncio.CancelledError: + logger.info("[私聊回复] 任务被取消(投机抢占): user=%s", user_id) + raise except Exception: logger.exception("私聊回复执行出错") raise @@ -705,6 +797,202 @@ async def _handle_injection_response( tid, self.config.bot_qq, "<对注入消息的回复>", "Bot", "" ) + def _format_group_message_segment(self, item: BufferedMessage) -> str: + """格式化群聊单条 ```` 块。""" + time_str = datetime.fromtimestamp(item.arrival_time).strftime( + "%Y-%m-%d %H:%M:%S" + ) + group_name = item.group_name or "未知群聊" + location = group_name if group_name.endswith("群") else f"{group_name}群" + safe_name = escape_xml_attr(item.sender_name or "未知用户") + safe_uid = escape_xml_attr(item.sender_id) + safe_gid = escape_xml_attr(item.group_id or 0) + safe_gname = escape_xml_attr(group_name) + safe_loc = escape_xml_attr(location) + safe_role = escape_xml_attr(item.sender_role or "member") + safe_title = escape_xml_attr(item.sender_title or "") + safe_time = escape_xml_attr(time_str) + safe_text = escape_xml_text(item.text) + message_id_attr = "" + if item.trigger_message_id is not None: + message_id_attr = ( + f' message_id="{escape_xml_attr(item.trigger_message_id)}"' + ) + level_attr = ( + f' level="{escape_xml_attr(item.sender_level)}"' + if item.sender_level + else "" + ) + attachment_xml = ( + f"\n{attachment_refs_to_xml(item.attachments)}" if item.attachments else "" + ) + return ( + f'\n' + f" {safe_text}{attachment_xml}\n" + f" " + ) + + def _format_private_message_segment(self, item: BufferedMessage) -> str: + """格式化私聊单条 ```` 块。""" + time_str = datetime.fromtimestamp(item.arrival_time).strftime( + "%Y-%m-%d %H:%M:%S" + ) + safe_name = escape_xml_attr(item.sender_name or "未知用户") + safe_uid = escape_xml_attr(item.sender_id) + safe_time = escape_xml_attr(time_str) + safe_text = escape_xml_text(item.text) + message_id_attr = "" + if item.trigger_message_id is not None: + message_id_attr = ( + f' message_id="{escape_xml_attr(item.trigger_message_id)}"' + ) + attachment_xml = ( + f"\n{attachment_refs_to_xml(item.attachments)}" if item.attachments else "" + ) + return ( + f'\n' + f" {safe_text}{attachment_xml}\n" + f" " + ) + + @staticmethod + def _build_continuous_messages_note(items: list[BufferedMessage]) -> str: + """生成"连续消息说明"段。仅在 ``len(items) >= 2`` 时使用。""" + count = len(items) + first_t = items[0].arrival_time + last_t = items[-1].arrival_time + span = max(0.0, last_t - first_t) + return ( + f"\n\n 【连续消息说明】以上 {count} 条 是同一用户在约 " + f"{span:.1f} 秒内连续发送的消息(按时间先后排列),代表本轮要回应的全部输入:\n" + f" - 这些 共同构成【当前输入批次】,不要把同批前几条误判为历史旧任务;" + f"批次之外的历史消息仍只作为背景,不能回溯拾荒\n" + f" - 先识别每条的意图,分清是【独立请求】还是【对前一条的修正/否定/补充/打断】\n" + f' · 若是【多个独立的不同意图/问题】(如"先帮我查 A,再翻译 B")' + f" → 每个都要回应,不要遗漏;与平时一样,可以多次 send_message 自然分发\n" + f' · 若后发是【对前发的修正/否定/补充/打断】(如"画猫" → "改成狗")' + f" → 以最后一次明确意图为准,旧的不再执行,可简短说明已采纳更新\n" + f' · 拿不准时偏向"独立请求",宁多勿漏\n' + f" - 整批在本轮一次性处理完即可,不要为同一意图重复输出(不要" + f'"中间一波、结尾再来一波"重复相同回复)\n' + f" - history 中若出现与当前轮 相同的条目,视为同一来源,不要重复处理" + ) + + def _build_grouped_prompt(self, items: list[BufferedMessage]) -> str: + """根据 BufferedMessage 列表构造合并后的完整 prompt。""" + if not items: + return "" + is_private = items[0].is_private + # prefix:拍一拍优先;否则任一 @bot + any_poke = any(it.is_poke for it in items) + any_at_bot = any(it.is_at_bot for it in items) + if any_poke: + prefix = "(用户拍了拍你) " + elif any_at_bot: + prefix = "(用户 @ 了你) " + else: + prefix = "" + + if is_private: + segments = [self._format_private_message_segment(it) for it in items] + else: + segments = [self._format_group_message_segment(it) for it in items] + body = prefix + "\n".join(segments) + if len(items) >= 2: + body += self._build_continuous_messages_note(items) + body += _GROUP_STRATEGY_FOOTER if not is_private else _PRIVATE_STRATEGY_FOOTER + return body + + async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: + """根据一组 BufferedMessage 决定优先级、构造 prompt 并入队。 + + 既是单条直送路径的统一出口,也是 :class:`MessageBatcher` 的 flush_callback。 + """ + if not items: + return + first = items[0] + last = items[-1] + full_question = self._build_grouped_prompt(items) + any_poke = any(it.is_poke for it in items) + any_at_bot = any(it.is_at_bot for it in items) + + if first.is_private: + user_id = first.sender_id + request_data: dict[str, Any] = { + "type": "private_reply", + "user_id": user_id, + "sender_name": first.sender_name, + "text": last.text, + "full_question": full_question, + "trigger_message_id": last.trigger_message_id, + "batched_count": len(items), + } + if first.batch_token is not None: + request_data["_message_batcher_token"] = first.batch_token + effective_config = self.model_pool.select_chat_config( + self.config.chat_model, user_id=user_id + ) + request_data["selected_model_name"] = effective_config.model_name + logger.debug( + "[私聊回复] full_question_len=%s user=%s batched=%s", + len(full_question), + user_id, + len(items), + ) + if user_id == self.config.superadmin_qq: + await self.queue_manager.add_superadmin_request( + request_data, model_name=effective_config.model_name + ) + else: + await self.queue_manager.add_private_request( + request_data, model_name=effective_config.model_name + ) + return + + # 群聊 + group_id = first.group_id or 0 + sender_id = first.sender_id + request_data = { + "type": "auto_reply", + "group_id": group_id, + "sender_id": sender_id, + "sender_name": first.sender_name, + "group_name": first.group_name, + "text": last.text, + "full_question": full_question, + "is_at_bot": any_at_bot, + "trigger_message_id": last.trigger_message_id, + "batched_count": len(items), + } + if first.batch_token is not None: + request_data["_message_batcher_token"] = first.batch_token + logger.debug( + "[自动回复] full_question_len=%s group=%s sender=%s batched=%s", + len(full_question), + group_id, + sender_id, + len(items), + ) + if sender_id == self.config.superadmin_qq: + logger.info("[AI] 投递至群聊超级管理员队列 (batched=%s)", len(items)) + await self.queue_manager.add_group_superadmin_request( + request_data, model_name=self.config.chat_model.model_name + ) + elif any_at_bot: + trigger = "拍一拍" if any_poke else "@机器人" + logger.info("[AI] 触发原因: %s (batched=%s)", trigger, len(items)) + await self.queue_manager.add_group_mention_request( + request_data, model_name=self.config.chat_model.model_name + ) + else: + logger.info("[AI] 投递至普通请求队列 (batched=%s)", len(items)) + await self.queue_manager.add_group_normal_request( + request_data, model_name=self.config.chat_model.model_name + ) + def _build_prompt( self, prefix: str, @@ -745,31 +1033,31 @@ def _build_prompt( {safe_text}{attachment_xml} - 【回复策略 - 更克制,且优先表情包】 + 【回复策略 - 更克制,纯表情包才前置检索】 1. 如果用户 @ 了你或拍了拍你 → 【必须回复】 2. 如果消息中明确提到了你(根据上下文判断用户是否在叫你或维持对话流) → 【必须回复】 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 - 5. 先判断这条话是不是在对你说: + 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: - 如果明显是在和别人说话 → 【不要回复】 - 如果你不能确定是不是在和你说话 → 【默认不回复】 - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 6. 群聊里的主动参与只保留给公开、开放的技术或项目讨论: - 只在多人公开讨论代码、AI、开发工具、项目进展、技术 bug 等,且不是别人之间定向交流时,才可以【极低频参与】 - 默认更倾向不参与;不要长篇大论,一两句点到为止;如果别人已经在深入讨论且不需要你,保持沉默 - - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复时,才优先考虑表情包表达 + - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复,且本轮明确是纯表情包/纯反应图时,才优先考虑表情包表达 7. 对于已经决定要回复的场景(包括被@、被拍一拍、轻量答疑,以及少量符合条件的主动参与): - - 默认先尝试 memes.search_memes,再用 memes.send_meme_by_uid 单独发一条图片消息 - - 对于吐槽、附和、接梗、表达态度或情绪的回复,默认由表情包承担主要表达;只要能发表情包,就不要先发文字描述来代替它 - - 如果确实需要文字,也只补极短一句,并与表情包分开发送 + - 只有明确纯表情包回复才先检索表情包,再用 memes.send_meme_by_uid 单独发一条图片消息 + - 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message + - 如果确实还想补表情包,把 memes.search_memes 和 memes.send_meme_by_uid 放到文字发送后的后续响应轮次,不要阻塞首条文字回复 - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end - 严肃、任务型、高信息密度场景少发表情包,避免打断信息传递 - 绝不要刷屏、绝不要每条都回 8. 对于本来就会回复的场景(私聊、被拍一拍、被@、轻量答疑): - - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也可以配合使用 + - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也只能作为后续可选补充 - 但不要为了发表情包而牺牲信息传递;信息密度优先时仍以文字为主 - 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,再优先用表情包而不是文字。""" + 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好,表情包最后再搜。""" async def _send_image(self, tid: int, mtype: str, path: str) -> None: """发送图片或语音消息到群聊或私聊""" diff --git a/src/Undefined/services/message_batcher.py b/src/Undefined/services/message_batcher.py new file mode 100644 index 00000000..4ad94e11 --- /dev/null +++ b/src/Undefined/services/message_batcher.py @@ -0,0 +1,810 @@ +"""同 sender 短时多消息合并器(MessageBatcher)。 + +核心目标:把同一个 sender 在短时间内连续发出的消息合并到同一轮 AI 调用, +让模型一次看到全部 ```` 块自行决定 "独立请求 / 修正 / 打断", +避免 N 条独立 LLM 调用造成的重复回复或行为打架。 + +时序:每个 (scope, sender_id) 桶内有两条独立的"静默计时器": + +- ``T1 = window_seconds`` —— "打字静默阈值"。静默达到 T1 视为用户写完, + 这一批 batch 结束。 +- ``T2 = pre_send_seconds`` —— "投机预发送阈值",要求严格小于 T1。 + 静默到 T2 时**先把当前 batch 提前发给 LLM 抢时间**(speculative pre-fire), + 但 batch 尚未结束;T1 才决定结束。 + +新消息到来: + +- 若桶处于 ``TYPING``(尚未 pre-fire):append 后重置 T1/T2。 +- 若桶处于 ``SPECULATING``(已 pre-fire,请求已入队或 inflight 在跑): + - 检查 inflight 是否已经 "向用户发出过任何消息" + (来自 ``RequestContext.get_resource("message_sent_this_turn")``)。 + - inflight 尚未发消息 → 调 ``inflight_task.cancel()``,桶回到 TYPING; + 新消息照常 append 到原有 items 后面,T1/T2 重置。 + - inflight 已经发过消息且 ``allow_cancel_after_send=False``(默认安全)→ + 保留旧 batch 让其自然走完,新消息开新 batch(即清空当前桶后立即重新作为首条入桶)。 + - inflight 已经发过消息但开关 = True → 仍 cancel(可能造成重复发送,仅极端场景)。 + +兼容回退:当 ``pre_send_seconds <= 0`` 或 ``>= window_seconds`` 时投机模式关闭, +退化为旧版 "T1 静默到期才发车" 的行为。 +""" + +from __future__ import annotations + +import asyncio +import enum +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable + +from Undefined.config.models import MessageBatcherConfig +from Undefined.utils.coerce import was_message_sent + +logger = logging.getLogger(__name__) + + +@dataclass +class BatchDispatchToken: + """一次 batch 发车的身份令牌,用于取消已入队但尚未执行的投机请求。""" + + scope: str + sender_id: int + batch_id: int + speculative: bool = False + cancelled: bool = False + + def cancel(self) -> None: + self.cancelled = True + + +@dataclass +class BufferedMessage: + """缓冲中的单条消息上下文。""" + + scope: str + sender_id: int + text: str + message_content: list[dict[str, Any]] + attachments: list[dict[str, str]] + sender_name: str + arrival_time: float + is_private: bool + trigger_message_id: int | None = None + is_poke: bool = False + is_at_bot: bool = False + is_fake_at: bool = False + # 群聊扩展字段 + group_id: int | None = None + group_name: str = "" + sender_role: str = "member" + sender_title: str = "" + sender_level: str = "" + batch_token: BatchDispatchToken | None = None + + +FlushCallback = Callable[[list[BufferedMessage]], Awaitable[None]] +"""``flush_callback(items)``:batcher 决定 fire 时调用,调用方负责拼装 prompt 并入队执行。 + +调用约定: +- batcher 的 ``flush_callback`` **不应** 立即 await LLM 的完成, + 而是把请求扔进 QueueManager 后立即返回,真正的 LLM 任务由 coordinator 在 ``execute_reply`` + 开头调用 :meth:`MessageBatcher.register_inflight` 上报。 +- 若需要 batcher 关停时也等待 in-flight 收尾,由 :meth:`MessageBatcher.flush_all` 处理。 +""" + + +class BatchPhase(enum.Enum): + """桶状态机。""" + + TYPING = "typing" # 等待 T1/T2 静默 + SPECULATING = "speculating" # T2 已触发,请求已入队或 inflight 在跑;T1 仍未到 + FINALIZING = "finalizing" # T1 已到,等 inflight(若有)自然结束 + + +@dataclass +class _InflightInfo: + """inflight LLM 任务关联信息,由 coordinator 通过 ``register_inflight`` 上报。""" + + task: asyncio.Task[Any] + # ``RequestContext`` 引用,用于判断 ``message_sent_this_turn`` 资源 + request_context: Any = None + + +@dataclass +class _BatchState: + """单个 (scope, sender_id) 桶的状态。""" + + phase: BatchPhase = BatchPhase.TYPING + items: list[BufferedMessage] = field(default_factory=list) + first_arrival_monotonic: float = 0.0 + # T1 = window_seconds 静默 timer(决定 batch 结束) + t1_handle: asyncio.TimerHandle | None = None + # T2 = pre_send_seconds 静默 timer(决定 pre-fire);投机关闭时为 None + t2_handle: asyncio.TimerHandle | None = None + # SPECULATING 阶段记录 inflight LLM 任务(由 coordinator 通过 register_inflight 注入) + inflight: _InflightInfo | None = None + # T2 fire 时由 batcher 创建的 flush task;inflight 还未上报前用于兜底取消 + speculative_flush_task: asyncio.Task[Any] | None = None + # 当前 batch 的身份令牌;T2 入队后若又来新消息,可将旧 token 标记取消, + # coordinator 在真正执行前会跳过它。 + dispatch_token: BatchDispatchToken | None = None + + +def make_scope(*, group_id: int | None = None, user_id: int | None = None) -> str: + """构造合并 key 的 scope 字符串。""" + if group_id and group_id > 0: + return f"group:{group_id}" + if user_id is not None: + return f"private:{user_id}" + return "unknown" + + +class MessageBatcher: + """同 sender 短时合并器(含 T2 投机预发送)。""" + + def __init__( + self, + config: MessageBatcherConfig, + flush_callback: FlushCallback, + ) -> None: + self._config = config + self._flush_callback = flush_callback + self._buckets: dict[tuple[str, int], _BatchState] = {} + self._flush_failure_counts: dict[tuple[str, int], int] = {} + self._lock = asyncio.Lock() + # 持有 timer 触发后创建的 flush task 强引用,避免被 GC(asyncio 文档要求) + self._pending_tasks: set[asyncio.Task[Any]] = set() + self._next_batch_id = 0 + self._shutdown = False + + # ------------------------------------------------------------------ public + + def update_config(self, config: MessageBatcherConfig) -> None: + """配置热更新。""" + self._config = config + logger.info( + "[MessageBatcher] 配置已更新: enabled=%s window=%.2fs pre_send=%.2fs " + "strategy=%s max_window=%.2fs max_messages=%s group=%s private=%s " + "allow_cancel_after_send=%s", + config.enabled, + config.window_seconds, + config.pre_send_seconds, + config.strategy, + config.max_window_seconds, + config.max_messages_per_batch, + config.group_enabled, + config.private_enabled, + config.allow_cancel_after_send, + ) + + @property + def config(self) -> MessageBatcherConfig: + return self._config + + def is_enabled_for(self, *, is_group: bool) -> bool: + cfg = self._config + if not cfg.enabled or cfg.window_seconds <= 0: + return False + return cfg.group_enabled if is_group else cfg.private_enabled + + def has_buffer(self, scope: str, sender_id: int) -> bool: + return (scope, sender_id) in self._buckets + + async def flush_sender(self, scope: str, sender_id: int) -> bool: + return await self._handle_t1((scope, sender_id), raise_on_failure=False) + + @property + def speculative_enabled(self) -> bool: + cfg = self._config + return 0 < cfg.pre_send_seconds < cfg.window_seconds + + async def submit(self, item: BufferedMessage) -> None: + """提交一条消息进入合并桶。 + + 新消息到来时的处理依赖当前桶 ``phase``,详见模块 docstring。 + """ + cfg = self._config + key = (item.scope, item.sender_id) + # 异步路径里只在锁内修改桶;invoke callback 在锁外执行 + immediate_fire_items: list[BufferedMessage] | None = None + + async with self._lock: + if self._shutdown: + logger.info( + "[MessageBatcher] 已进入关停模式,新消息立即发车: scope=%s sender=%s", + item.scope, + item.sender_id, + ) + immediate_fire_items = [item] + else: + now_mono = time.monotonic() + state = self._buckets.get(key) + + # === 阶段 1: 决定本条消息怎么进桶 === + if state is None: + # 全新桶 + state = _BatchState( + phase=BatchPhase.TYPING, + first_arrival_monotonic=now_mono, + dispatch_token=self._new_token(item.scope, item.sender_id), + ) + self._buckets[key] = state + state.items.append(item) + elif state.phase is BatchPhase.SPECULATING: + # 已 pre-fire,决定是否 cancel inflight + inflight = state.inflight + already_sent = ( + was_message_sent(inflight.request_context) + if inflight is not None + else False + ) + allow_cancel = (not already_sent) or cfg.allow_cancel_after_send + + if inflight is not None and allow_cancel: + logger.info( + "[MessageBatcher] 投机调用被新消息抢占取消: scope=%s sender=%s " + "already_sent=%s allow_cancel_after_send=%s", + item.scope, + item.sender_id, + already_sent, + cfg.allow_cancel_after_send, + ) + if state.dispatch_token is not None: + state.dispatch_token.cancel() + inflight.task.cancel() + state.inflight = None + state.phase = BatchPhase.TYPING + # 新消息追加到现有 items 后面 + state.items.append(item) + self._retokenize_locked(state, item.scope, item.sender_id) + elif inflight is None: + # inflight 尚未注册(coordinator 还没进入 execute_reply): + # 1) 若 flush task 仍在跑,先 cancel; + # 2) 若它已经把请求入队,则取消旧 token,execute_reply 入口会跳过旧请求。 + logger.info( + "[MessageBatcher] inflight 未注册,取消投机 token/flush task: " + "scope=%s sender=%s", + item.scope, + item.sender_id, + ) + if state.dispatch_token is not None: + state.dispatch_token.cancel() + if state.speculative_flush_task is not None: + state.speculative_flush_task.cancel() + state.speculative_flush_task = None + state.phase = BatchPhase.TYPING + state.items.append(item) + self._retokenize_locked(state, item.scope, item.sender_id) + else: + # 已发过消息且不允许取消:丢弃当前桶,新消息开新桶 + logger.info( + "[MessageBatcher] 投机调用已发出消息且不允许取消,新消息开新 batch: " + "scope=%s sender=%s", + item.scope, + item.sender_id, + ) + self._cancel_t1(state) + self._cancel_t2(state) + state.phase = BatchPhase.FINALIZING + # 旧桶让 inflight 自然结束;从 _buckets pop 以释放 key 给新 batch + self._buckets.pop(key, None) + # 新桶 + state = _BatchState( + phase=BatchPhase.TYPING, + first_arrival_monotonic=now_mono, + dispatch_token=self._new_token(item.scope, item.sender_id), + ) + self._buckets[key] = state + state.items.append(item) + elif state.phase is BatchPhase.FINALIZING: + # 极少见:T1 已到、inflight 未上报但 task 已不可控;当作新桶处理 + logger.warning( + "[MessageBatcher] 桶处于 FINALIZING 期间收到新消息,开新 batch: " + "scope=%s sender=%s", + item.scope, + item.sender_id, + ) + self._buckets.pop(key, None) + state = _BatchState( + phase=BatchPhase.TYPING, + first_arrival_monotonic=now_mono, + dispatch_token=self._new_token(item.scope, item.sender_id), + ) + self._buckets[key] = state + state.items.append(item) + else: # TYPING:直接 append + state.items.append(item) + + self._bind_items_to_token_locked(state) + + # === 阶段 2: 重置 T1/T2 timer === + self._cancel_t1(state) + self._cancel_t2(state) + + elapsed = now_mono - state.first_arrival_monotonic + unlimited_window = cfg.max_window_seconds <= 0 + remaining_max = ( + float("inf") + if unlimited_window + else cfg.max_window_seconds - elapsed + ) + + # 硬顶:max_messages_per_batch 立即发车(结束 batch) + if ( + cfg.max_messages_per_batch > 0 + and len(state.items) >= cfg.max_messages_per_batch + ): + logger.info( + "[MessageBatcher] 达到 max_messages_per_batch=%s 立即发车: " + "scope=%s sender=%s", + cfg.max_messages_per_batch, + item.scope, + item.sender_id, + ) + immediate_fire_items = self._pop_locked(key) + elif not unlimited_window and remaining_max <= 0: + logger.info( + "[MessageBatcher] 已超 max_window_seconds 硬顶 立即发车: " + "scope=%s sender=%s elapsed=%.2fs", + item.scope, + item.sender_id, + elapsed, + ) + immediate_fire_items = self._pop_locked(key) + else: + # T1 delay + if cfg.strategy == "fixed": + target = state.first_arrival_monotonic + cfg.window_seconds + t1_delay = max(0.0, target - now_mono) + else: # extend + t1_delay = cfg.window_seconds + if not unlimited_window: + t1_delay = min(t1_delay, remaining_max) + + loop = asyncio.get_running_loop() + state.t1_handle = loop.call_later( + max(0.0, t1_delay), self._on_t1_timer, key + ) + + # T2 delay(仅当投机启用,且本桶尚未 pre-fire 时设置) + if ( + self.speculative_enabled + and state.phase is BatchPhase.TYPING + and cfg.pre_send_seconds < t1_delay + ): + t2_delay = cfg.pre_send_seconds + state.t2_handle = loop.call_later( + max(0.0, t2_delay), self._on_t2_timer, key + ) + logger.debug( + "[MessageBatcher] 缓冲: scope=%s sender=%s count=%s " + "t1=%.2fs t2=%.2fs strategy=%s", + item.scope, + item.sender_id, + len(state.items), + t1_delay, + t2_delay, + cfg.strategy, + ) + else: + logger.debug( + "[MessageBatcher] 缓冲: scope=%s sender=%s count=%s " + "t1=%.2fs strategy=%s phase=%s", + item.scope, + item.sender_id, + len(state.items), + t1_delay, + cfg.strategy, + state.phase.value, + ) + + # 锁外执行 callback + if immediate_fire_items is not None: + success = await self._invoke_callback(immediate_fire_items) + if success: + self._flush_failure_counts.pop(key, None) + else: + await self._restore_items_after_failed_flush( + key, immediate_fire_items, schedule_retry=True + ) + + # ----------------------------------------------------------- inflight API + + def register_inflight( + self, + scope: str, + sender_id: int, + task: asyncio.Task[Any], + request_context: Any = None, + ) -> None: + """coordinator 在 ``execute_reply`` 开头上报 inflight LLM 任务。 + + 如果桶不存在或 phase 不是 SPECULATING,则忽略(说明这次 fire 不是投机的)。 + """ + key = (scope, sender_id) + state = self._buckets.get(key) + if state is None: + return + if state.phase is not BatchPhase.SPECULATING: + return + state.inflight = _InflightInfo(task=task, request_context=request_context) + logger.debug( + "[MessageBatcher] 注册 inflight 任务: scope=%s sender=%s", + scope, + sender_id, + ) + + def unregister_inflight( + self, scope: str, sender_id: int, task: asyncio.Task[Any] + ) -> None: + """coordinator 在 ``execute_reply`` 结束(含异常/取消)时上报。""" + key = (scope, sender_id) + state = self._buckets.get(key) + if state is None: + return + if state.inflight is not None and state.inflight.task is not task: + logger.debug( + "[MessageBatcher] 忽略过期 inflight 注销: scope=%s sender=%s phase=%s", + scope, + sender_id, + state.phase.value, + ) + return + state.inflight = None + # 若 phase 是 SPECULATING 且 T1 已经 fire 过(FINALIZING 才 unregister), + # 此时 inflight 自然结束 → 桶已经在 _on_t1_timer 中弹出,无需再做事 + # 若仍在 SPECULATING(T1 未到):inflight 已结束但仍可能有新消息进来; + # 保持 SPECULATING,新消息会按 SPECULATING 分支处理(已发消息开新 batch / 未发追加) + logger.debug( + "[MessageBatcher] 注销 inflight 任务: scope=%s sender=%s phase=%s", + scope, + sender_id, + state.phase.value, + ) + + # ---------------------------------------------------------------- timers + + def _cancel_t1(self, state: _BatchState) -> None: + if state.t1_handle is not None: + state.t1_handle.cancel() + state.t1_handle = None + + def _cancel_t2(self, state: _BatchState) -> None: + if state.t2_handle is not None: + state.t2_handle.cancel() + state.t2_handle = None + + def _new_token(self, scope: str, sender_id: int) -> BatchDispatchToken: + self._next_batch_id += 1 + return BatchDispatchToken( + scope=scope, + sender_id=sender_id, + batch_id=self._next_batch_id, + ) + + def _retokenize_locked( + self, state: _BatchState, scope: str, sender_id: int + ) -> None: + state.dispatch_token = self._new_token(scope, sender_id) + self._bind_items_to_token_locked(state) + + @staticmethod + def _bind_items_to_token_locked(state: _BatchState) -> None: + if state.dispatch_token is None: + return + for buffered in state.items: + buffered.batch_token = state.dispatch_token + + def _pop_locked(self, key: tuple[str, int]) -> list[BufferedMessage] | None: + state = self._buckets.pop(key, None) + if state is None or not state.items: + return None + self._cancel_t1(state) + self._cancel_t2(state) + return list(state.items) + + def _on_t1_timer(self, key: tuple[str, int]) -> None: + """T1 静默到期:batch 结束。""" + task = asyncio.create_task(self._handle_t1(key)) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + + def _on_t2_timer(self, key: tuple[str, int]) -> None: + """T2 静默到期:投机预发送(pre-fire),但 batch 不结束。""" + task = asyncio.create_task(self._handle_t2(key)) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + + async def _handle_t1( + self, key: tuple[str, int], *, raise_on_failure: bool = False + ) -> bool: + items_to_fire: list[BufferedMessage] | None = None + wait_inflight: asyncio.Task[Any] | None = None + wait_prefire: asyncio.Task[Any] | None = None + finalizing_state: _BatchState | None = None + async with self._lock: + state = self._buckets.get(key) + if state is None: + return True + self._cancel_t2(state) + if state.phase is BatchPhase.SPECULATING: + # T1 到了,投机请求已经发出/入队;这里只结束 batch,不能再次发车。 + state.phase = BatchPhase.FINALIZING + finalizing_state = state + if state.inflight is not None: + wait_inflight = state.inflight.task + elif ( + state.speculative_flush_task is not None + and not state.speculative_flush_task.done() + ): + wait_prefire = state.speculative_flush_task + else: + self._buckets.pop(key, None) + logger.debug( + "[MessageBatcher] T1 结束已投机 batch,不重复发车: " + "scope=%s sender=%s", + key[0], + key[1], + ) + else: + # 普通模式或 SPECULATING 但 inflight 已结束:直接 fire + items_to_fire = self._pop_locked(key) + if items_to_fire is not None: + state.phase = BatchPhase.FINALIZING + + wait_task: asyncio.Task[Any] | None = wait_inflight or wait_prefire + if wait_task is not None: + try: + await wait_task + except asyncio.CancelledError: + # inflight/prefire 已被 cancel(极少同时发生),让 cancel 路径自然走 + logger.info( + "[MessageBatcher] T1 等待投机任务时被取消: scope=%s sender=%s", + key[0], + key[1], + ) + except Exception: + logger.exception( + "[MessageBatcher] T1 等待投机任务失败: scope=%s sender=%s", + key[0], + key[1], + ) + finally: + # 仅当桶仍是 finalizing_state(同一对象)时才 pop; + # 否则 submit 已经在 SPECULATING/FINALIZING 分支把旧桶 pop 并建立新桶, + # 不能误删新桶。 + async with self._lock: + current = self._buckets.get(key) + if current is finalizing_state: + self._buckets.pop(key, None) + return True + + if items_to_fire is not None: + success = await self._invoke_callback(items_to_fire, speculative=False) + if success: + self._flush_failure_counts.pop(key, None) + else: + await self._restore_items_after_failed_flush( + key, items_to_fire, schedule_retry=not self._shutdown + ) + if raise_on_failure: + raise RuntimeError("message batcher flush callback failed") + return success + return True + + async def _handle_t2(self, key: tuple[str, int]) -> None: + speculative_items: list[BufferedMessage] | None = None + async with self._lock: + state = self._buckets.get(key) + if state is None: + return + if state.phase is not BatchPhase.TYPING: + return + if not state.items: + return + # 切到 SPECULATING,但**不**清空 items(保留以便后续 T1 也能用 / 抢占回收) + state.phase = BatchPhase.SPECULATING + self._cancel_t2(state) + if state.dispatch_token is None: + state.dispatch_token = self._new_token(key[0], key[1]) + self._bind_items_to_token_locked(state) + state.dispatch_token.speculative = True + # 记录"承担投机职责"的当前 task;此处指向 _handle_t2 协程本身 + # (pre-fire 协程),不是 LLM inflight task。 + # 后续 submit() 抢占判定通过 `state.speculative_flush_task is asyncio.current_task()` + # 区分新旧 pre-fire 协程,避免误清理新 batch。 + state.speculative_flush_task = asyncio.current_task() + speculative_items = list(state.items) + logger.info( + "[MessageBatcher] 投机预发送: scope=%s sender=%s count=%s", + key[0], + key[1], + len(speculative_items), + ) + + if speculative_items is not None: + success = False + try: + success = await self._invoke_callback( + speculative_items, speculative=True + ) + finally: + # 清掉自身引用,避免 state 残留指向已结束 task;若投机 callback + # 异常/取消且桶仍是本次 SPECULATING,则回滚为 TYPING,等待 T1 正常重试。 + async with self._lock: + state2 = self._buckets.get(key) + if ( + state2 is not None + and state2.speculative_flush_task is asyncio.current_task() + ): + state2.speculative_flush_task = None + if state2.phase is BatchPhase.SPECULATING and not success: + if state2.dispatch_token is not None: + state2.dispatch_token.cancel() + state2.phase = BatchPhase.TYPING + self._retokenize_locked(state2, key[0], key[1]) + logger.warning( + "[MessageBatcher] 投机预发送失败,回滚等待 T1 重试: " + "scope=%s sender=%s", + key[0], + key[1], + ) + + async def _invoke_callback( + self, + items: list[BufferedMessage], + *, + speculative: bool = False, + ) -> bool: + if not items: + return True + first = items[0] + logger.info( + "[MessageBatcher] 发车: scope=%s sender=%s count=%s speculative=%s", + first.scope, + first.sender_id, + len(items), + speculative, + ) + try: + await self._flush_callback(items) + return True + except asyncio.CancelledError: + # 投机被新消息取消是预期行为 + logger.info( + "[MessageBatcher] flush_callback 被取消(投机抢占): " + "scope=%s sender=%s speculative=%s", + first.scope, + first.sender_id, + speculative, + ) + return False + except Exception: + logger.exception( + "[MessageBatcher] flush_callback 异常: scope=%s sender=%s count=%s", + first.scope, + first.sender_id, + len(items), + ) + return False + + async def _restore_items_after_failed_flush( + self, + key: tuple[str, int], + items: list[BufferedMessage], + *, + schedule_retry: bool, + ) -> None: + """flush callback 失败后回滚到 TYPING 阶段。 + + 重试策略(fail-fast): + - 每次失败累加 ``self._flush_failure_counts[key]``; + - 仅在 ``failure_count <= 1``(即首次失败)时安排一次延后 T1 重试; + - 第二次起仅恢复 batch、等待用户新消息或 ``flush_all`` 触发, + 避免 LLM 端持续故障时形成"无限重试风暴"; + - 桶在成功一次后 ``failure_count`` 会被 pop 清零。 + - ``flush_all`` 路径会 raise,从而暴露持续失败。 + """ + if not items: + return + async with self._lock: + state = self._buckets.get(key) + if state is None: + state = _BatchState( + phase=BatchPhase.TYPING, + first_arrival_monotonic=time.monotonic(), + dispatch_token=self._new_token(key[0], key[1]), + ) + self._buckets[key] = state + state.items = list(items) + else: + self._cancel_t1(state) + self._cancel_t2(state) + state.phase = BatchPhase.TYPING + state.items = list(items) + state.items + state.first_arrival_monotonic = time.monotonic() + state.inflight = None + if state.dispatch_token is not None: + state.dispatch_token.cancel() + self._retokenize_locked(state, key[0], key[1]) + logger.warning( + "[MessageBatcher] flush 失败,已恢复 batch: scope=%s sender=%s count=%s", + key[0], + key[1], + len(state.items), + ) + failure_count = self._flush_failure_counts.get(key, 0) + 1 + self._flush_failure_counts[key] = failure_count + if schedule_retry and not self._shutdown and failure_count <= 1: + loop = asyncio.get_running_loop() + delay = max(0.0, self._config.window_seconds) + state.t1_handle = loop.call_later(delay, self._on_t1_timer, key) + + # ------------------------------------------------------------ shutdown + + async def flush_all(self) -> None: + """立即 flush 所有 buckets(用于关停)。 + + 关停时直接对所有桶执行 T1 等价路径并等 inflight 收尾。 + """ + while True: + async with self._lock: + self._shutdown = True + keys = list(self._buckets.keys()) + if not keys: + break + logger.info("[MessageBatcher] flush_all: pending_buckets=%s", len(keys)) + for key in keys: + await self._handle_t1(key, raise_on_failure=True) + # 等 timer 已触发但回调仍在跑的 task + pending = [t for t in self._pending_tasks if not t.done()] + if pending: + logger.info( + "[MessageBatcher] flush_all: 等待 %s 个 in-flight flush task", + len(pending), + ) + await asyncio.gather(*pending, return_exceptions=True) + + # ------------------------------------------------------------- snapshot + + def snapshot(self) -> dict[str, Any]: + """返回当前 buckets 状态的非阻塞快照(供 Runtime API / WebUI 展示)。""" + cfg = self._config + now_mono = time.monotonic() + buckets: list[dict[str, Any]] = [] + for (scope, sender_id), state in list(self._buckets.items()): + buckets.append( + { + "scope": scope, + "sender_id": sender_id, + "count": len(state.items), + "elapsed_seconds": round( + max(0.0, now_mono - state.first_arrival_monotonic), 2 + ), + "phase": state.phase.value, + "has_inflight": state.inflight is not None, + "has_speculative_dispatch": ( + state.dispatch_token is not None + and state.dispatch_token.speculative + and not state.dispatch_token.cancelled + ), + } + ) + return { + "config": { + "enabled": cfg.enabled, + "window_seconds": cfg.window_seconds, + "pre_send_seconds": cfg.pre_send_seconds, + "speculative_enabled": self.speculative_enabled, + "strategy": cfg.strategy, + "max_window_seconds": cfg.max_window_seconds, + "max_messages_per_batch": cfg.max_messages_per_batch, + "group_enabled": cfg.group_enabled, + "private_enabled": cfg.private_enabled, + "flush_on_command": cfg.flush_on_command, + "allow_cancel_after_send": cfg.allow_cancel_after_send, + "shutdown": self._shutdown, + }, + "pending_buckets": len(buckets), + "buckets": buckets, + } diff --git a/src/Undefined/services/queue_manager.py b/src/Undefined/services/queue_manager.py index 058fc12c..18a908e2 100644 --- a/src/Undefined/services/queue_manager.py +++ b/src/Undefined/services/queue_manager.py @@ -164,6 +164,8 @@ def __init__( self._model_queues: dict[str, ModelQueue] = {} self._processor_tasks: dict[str, asyncio.Task[None]] = {} self._inflight_tasks: set[asyncio.Task[None]] = set() + self._work_changed = asyncio.Event() + self._work_changed.set() self._next_dispatch_at: dict[str, float] = {} self._request_handler: ( Callable[[dict[str, Any]], Coroutine[Any, Any, None]] | None @@ -255,6 +257,37 @@ async def stop(self) -> None: logger.info("[队列服务] 所有队列处理任务已停止") + def _pending_request_count(self) -> int: + return sum( + lane_queue.qsize() + for queue in self._model_queues.values() + for lane_queue in queue.lane_queues().values() + ) + + def _active_inflight_count(self) -> int: + return sum(1 for task in self._inflight_tasks if not task.done()) + + async def drain(self) -> None: + """等待已入队请求和在途请求自然收敛,不取消处理器。""" + logger.info( + "[队列服务] 开始等待队列收敛: pending=%s inflight=%s", + self._pending_request_count(), + self._active_inflight_count(), + ) + while True: + pending = self._pending_request_count() + inflight = self._active_inflight_count() + if pending == 0 and inflight == 0: + logger.info("[队列服务] 队列已收敛") + return + self._work_changed.clear() + pending = self._pending_request_count() + inflight = self._active_inflight_count() + if pending == 0 and inflight == 0: + logger.info("[队列服务] 队列已收敛") + return + await self._work_changed.wait() + def _track_inflight_task(self, task: asyncio.Task[None]) -> None: """追踪在途任务,并在完成时自动移除。""" @@ -262,6 +295,7 @@ def _track_inflight_task(self, task: asyncio.Task[None]) -> None: def _cleanup(done_task: asyncio.Task[None]) -> None: self._inflight_tasks.discard(done_task) + self._work_changed.set() task.add_done_callback(_cleanup) @@ -373,6 +407,7 @@ async def _enqueue_lane_request( await lane_queue.put_second(request) else: await lane_queue.put(request) + self._work_changed.set() logger.info( "[队列入队][%s] %s: size=%s %s", model_name, @@ -540,6 +575,7 @@ async def _process_model_loop(self, model_name: str) -> None: ] if request is not None: + self._work_changed.set() request_type = request.get("type", "unknown") retry_count = int(request.get("_retry_count", 0) or 0) retry_suffix = ( diff --git a/src/Undefined/skills/README.md b/src/Undefined/skills/README.md index 0e4cca2a..0c9eda17 100644 --- a/src/Undefined/skills/README.md +++ b/src/Undefined/skills/README.md @@ -8,7 +8,7 @@ ``` skills/ -├── auto_pipeline/ # 自动处理管线,斜杠命令之后、AI 之前并行检测/处理 +├── pipelines/ # 自动处理管线,斜杠命令之后、AI 之前并行检测/处理 │ ├── __init__.py │ ├── registry.py │ └── pipelines/ @@ -55,7 +55,7 @@ skills/ │ ├── __init__.py │ ├── help/ │ ├── stats/ -│ ├── addadmin/ +│ ├── admin/ │ └── ... │ └── anthropic_skills/ # Anthropic Skills(SKILL.md 格式) @@ -75,7 +75,7 @@ skills/ - **定位**: 消息进入 AI 前的自动预处理能力,例如 Bilibili、arXiv、GitHub 链接提取;斜杠命令优先级更高,命中后不触发管线。 - **调用方式**: `MessageHandler` 自动调用,不暴露给 AI 主动调用。 - **命名规则**: `pipelines//`,`config.json` 中的 `name` 必须与命中结果一致。 -- **目录结构**: `auto_pipeline/pipelines/{pipeline_name}/config.json + handler.py`。 +- **目录结构**: `pipelines/{pipeline_name}/config.json + handler.py`。 - **执行方式**: 同一条非命令消息会并行检测全部管线,并行处理全部命中结果;处理产出的消息通过统一发送层写入历史并自动登记本地媒体/文件附件后,再进入 AI 自动回复。 - **热重载**: 跟随 `[skills]` 的 `hot_reload`、`hot_reload_interval`、`hot_reload_debounce` 配置。 - **示例**: `bilibili`, `arxiv`, `github` @@ -140,12 +140,12 @@ skills/ ### 添加自动处理管线 -1. 在 `skills/auto_pipeline/pipelines/` 下创建新目录 +1. 在 `skills/pipelines/` 下创建新目录 2. 添加 `config.json`,包含 `name`、`description`、`order` 和 `enabled` 3. 添加 `handler.py`,必须包含 `async def detect(context)` 与 `async def process(detection, context)` -4. 自动被 `AutoPipelineRegistry` 发现和注册,并支持热重载 +4. 自动被 `PipelineRegistry` 发现和注册,并支持热重载 -详细说明请参考 [自动处理管线开发指南](../../../docs/auto-pipeline.md)。 +详细说明请参考 [自动处理管线开发指南](../../../docs/pipelines.md)。 ### 添加基础工具 diff --git a/src/Undefined/skills/__init__.py b/src/Undefined/skills/__init__.py index 1cd4ed03..c40938cf 100644 --- a/src/Undefined/skills/__init__.py +++ b/src/Undefined/skills/__init__.py @@ -6,6 +6,6 @@ from Undefined.skills.tools import ToolRegistry from Undefined.skills.agents import AgentRegistry -from Undefined.skills.auto_pipeline import AutoPipelineRegistry +from Undefined.skills.pipelines import PipelineRegistry -__all__ = ["ToolRegistry", "AgentRegistry", "AutoPipelineRegistry"] +__all__ = ["ToolRegistry", "AgentRegistry", "PipelineRegistry"] diff --git a/src/Undefined/skills/agents/entertainment_agent/prompt.md b/src/Undefined/skills/agents/entertainment_agent/prompt.md index 8160f03a..e15c19bb 100644 --- a/src/Undefined/skills/agents/entertainment_agent/prompt.md +++ b/src/Undefined/skills/agents/entertainment_agent/prompt.md @@ -5,8 +5,8 @@ - 适当给出可选项,让用户选择方向。 - 用户明确要“随机视频/刷个视频”时,优先调用视频推荐工具。 - 输出轻松友好,但不要过度承诺或编造。 -- 如果要发独立表情包,默认先调用 `memes.search_memes` + `memes.send_meme_by_uid`,把表情包单独发一条,不和正文混在一起。 -- 对于吐槽、附和、接梗、表达态度或情绪的回复,默认由表情包承担主要表达;只要能发表情包,就不要先用文字描述来替代它。 +- 只有当用户明确要表情包,或本轮确实是纯表情包 / 纯反应图回复时,才先调用 `memes.search_memes` + `memes.send_meme_by_uid`,把表情包单独发一条,不和正文混在一起。 +- 对于吐槽、附和、接梗、表达态度或情绪的回复,如果还需要文字承接、解释或推进对话,先把文字回复做好;表情包只作为后续可选补充,不能阻塞首条文字回复。 - 如果工具返回了图片 UID,且用户确实需要图文并茂的结果,可以在最终回复里用 `` 做图文混排。 - `` 可以引用当前会话图片或表情包库返回的图片 UID,不能臆造,也不要改写成 Markdown 图片语法。 - 如果用户明确要求“参考这张图画”“照这个风格重画”“基于这些图生成”,优先调用 `ai_draw_one` 并传入 `reference_image_uids`,不要把图片内容重新手写成长段描述后再当纯文本生图。 diff --git a/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py b/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py index 87196909..d554d057 100644 --- a/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/tools/ai_draw_one/handler.py @@ -67,7 +67,8 @@ def _record_image_gen_usage( call_type="image_gen", success=success, ) - asyncio.create_task(storage.record(usage)) + task = asyncio.create_task(storage.record(usage)) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) except Exception: pass diff --git a/src/Undefined/skills/auto_pipeline/__init__.py b/src/Undefined/skills/auto_pipeline/__init__.py deleted file mode 100644 index 165a394d..00000000 --- a/src/Undefined/skills/auto_pipeline/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""自动处理管线注册与运行。""" - -from Undefined.skills.auto_pipeline.models import AutoPipelineDetection -from Undefined.skills.auto_pipeline.registry import AutoPipelineRegistry - -__all__ = ["AutoPipelineDetection", "AutoPipelineRegistry"] diff --git a/src/Undefined/skills/commands/README.md b/src/Undefined/skills/commands/README.md index 74cde82b..aca88083 100644 --- a/src/Undefined/skills/commands/README.md +++ b/src/Undefined/skills/commands/README.md @@ -9,12 +9,11 @@ ```text commands/ -├── addadmin/ # 在运行时动态添加普通管理员QQ的指令 +├── admin/ # 管理员管理:列表/添加/移除(支持子命令,自动推断) ├── bugfix/ # 一键读取群上下文帮你诊断并回复 bug 发作原因的娱乐工具 ├── copyright/ # 输出版权信息、开源协议与风险免责声明 ├── faq/ # FAQ 管理:列表/查看/搜索/删除(支持自动推断子命令) ├── help/ # 打印基础指令集列表 -├── lsadmin/ # 列出并获取当前系统的管理员和超管花名册 ├── ... └── my_cmd/ # 开发你的新指令所放置的位置 ``` diff --git a/src/Undefined/skills/commands/addadmin/README.md b/src/Undefined/skills/commands/addadmin/README.md deleted file mode 100644 index cae1bc29..00000000 --- a/src/Undefined/skills/commands/addadmin/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# /addadmin 命令说明 - -## 功能 -将指定 QQ 号添加为动态管理员(仅超级管理员可执行)。 - -## 用法 -- `/addadmin ` - -## 参数 -- `` 必填,必须为纯数字。 - -## 示例 -- `/addadmin 123456789` - -## 说明 -- 如果目标已经是管理员(或超级管理员),会返回已存在提示。 -- 变更会持久化,无需重启。 diff --git a/src/Undefined/skills/commands/addadmin/config.json b/src/Undefined/skills/commands/addadmin/config.json deleted file mode 100644 index ac1610b7..00000000 --- a/src/Undefined/skills/commands/addadmin/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "addadmin", - "description": "添加管理员(仅超级管理员),支持 @ 提及", - "usage": "/addadmin ", - "example": "/addadmin 123456789 或 /addadmin @某人", - "permission": "superadmin", - "rate_limit": { - "user": 0, - "admin": 0, - "superadmin": 0 - }, - "show_in_help": true, - "order": 80, - "allow_in_private": true, - "aliases": [] -} diff --git a/src/Undefined/skills/commands/addadmin/handler.py b/src/Undefined/skills/commands/addadmin/handler.py deleted file mode 100644 index df54cb04..00000000 --- a/src/Undefined/skills/commands/addadmin/handler.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -import logging -from uuid import uuid4 - -from Undefined.services.commands.context import CommandContext - -logger = logging.getLogger(__name__) - - -async def execute(args: list[str], context: CommandContext) -> None: - """处理 /addadmin。""" - - if not args: - await context.sender.send_group_message( - context.group_id, - "❌ 用法: /addadmin \n示例: /addadmin 123456789 或 /addadmin @某人", - ) - return - - try: - new_admin_qq = int(args[0]) - except ValueError: - await context.sender.send_group_message( - context.group_id, - "❌ QQ 号格式错误,必须为数字或 @ 提及", - ) - return - - if context.config.is_admin(new_admin_qq): - await context.sender.send_group_message( - context.group_id, - f"⚠️ {new_admin_qq} 已经是管理员了", - ) - return - - try: - context.config.add_admin(new_admin_qq) - await context.sender.send_group_message( - context.group_id, - f"✅ 已添加管理员: {new_admin_qq}", - ) - except Exception as exc: - error_id = uuid4().hex[:8] - logger.exception("添加管理员失败: error_id=%s err=%s", error_id, exc) - await context.sender.send_group_message( - context.group_id, - f"❌ 添加管理员失败,请稍后重试(错误码: {error_id})", - ) diff --git a/src/Undefined/skills/commands/admin/README.md b/src/Undefined/skills/commands/admin/README.md new file mode 100644 index 00000000..6fe1ccc0 --- /dev/null +++ b/src/Undefined/skills/commands/admin/README.md @@ -0,0 +1,30 @@ +# /admin 命令说明 + +## 功能:管理员管理(列表/添加/移除)。 +## 用法:/admin [ls|add|del] [参数] + +## 子命令: + +### ls(列表) +- 查看当前系统的超级管理员与管理员名单。 +- 权限:管理员及以上。 +- 输出优先展示群昵称,其次尝试按 QQ 号查询 QQ 昵称,不直接展示 QQ 号。 + +### add (添加) +- 将指定 QQ 号添加为动态管理员。 +- 权限:仅超级管理员可执行。 +- 参数:必填,支持纯数字 QQ 号或 @ 提及。 +- 如果目标已经是管理员(或超级管理员),会返回已存在提示。 +- 变更会持久化,无需重启。 +- 示例:`/admin add 123456789` 或 `/admin add @某人` + +### del (移除) +- 移除指定 QQ 号的动态管理员权限。 +- 权限:仅超级管理员可执行。 +- 参数:必填,支持纯数字 QQ 号或 @ 提及。 +- 不能通过此命令移除超级管理员。 +- 若目标不是管理员,会返回对应提示。 +- 示例:`/admin del 123456789` 或 `/admin del @某人` + +## 自动推断 +- 无参数时默认执行 `ls` 子命令(查看管理员列表)。 \ No newline at end of file diff --git a/src/Undefined/skills/commands/admin/config.json b/src/Undefined/skills/commands/admin/config.json new file mode 100644 index 00000000..daa9cd45 --- /dev/null +++ b/src/Undefined/skills/commands/admin/config.json @@ -0,0 +1,34 @@ +{ + "name": "admin", + "description": "管理员管理:列表/添加/移除", + "usage": "/admin [ls|add|del] [参数]", + "example": "/admin ls", + "permission": "admin", + "rate_limit": { + "user": 0, + "admin": 0, + "superadmin": 0 + }, + "show_in_help": true, + "order": 70, + "allow_in_private": true, + "aliases": [], + "subcommands": { + "ls": { + "description": "查看管理员列表" + }, + "add": { + "description": "添加管理员", + "permission": "superadmin", + "args": "" + }, + "del": { + "description": "移除管理员", + "permission": "superadmin", + "args": "" + } + }, + "inference": { + "default": "ls" + } +} \ No newline at end of file diff --git a/src/Undefined/skills/commands/admin/handler.py b/src/Undefined/skills/commands/admin/handler.py new file mode 100644 index 00000000..d61c908f --- /dev/null +++ b/src/Undefined/skills/commands/admin/handler.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any +from uuid import uuid4 + +from Undefined.services.commands.context import CommandContext + +logger = logging.getLogger(__name__) + +_UNKNOWN_MEMBER_NAME = "未知成员" + +_USAGE_TEXT = ( + "用法:/admin [ls|add|del] [参数]\n" + "子命令:ls(列表,管理员+)、add (添加,仅超管)、del (移除,仅超管)\n" + "自动推断:无参数→ls" +) + + +async def _send(message: str, context: CommandContext) -> None: + await context.sender.send_group_message(context.group_id, message) + + +# ── ls 相关 ── + + +def _extract_display_name(user_info: dict[str, Any] | None) -> str | None: + if not isinstance(user_info, dict): + return None + for field in ("card", "nickname", "remark"): + value = str(user_info.get(field) or "").strip() + if value: + return value + return None + + +async def _load_group_member_names(context: CommandContext) -> dict[int, str]: + if context.group_id <= 0: + return {} + + get_group_member_list = getattr(context.onebot, "get_group_member_list", None) + if not callable(get_group_member_list): + return {} + + try: + members = await get_group_member_list(context.group_id) + except Exception: + return {} + + names: dict[int, str] = {} + if not isinstance(members, list): + return names + + for member in members: + if not isinstance(member, dict): + continue + user_id_raw = member.get("user_id") + if user_id_raw is None: + continue + try: + user_id = int(user_id_raw) + except (TypeError, ValueError): + continue + display_name = _extract_display_name(member) + if display_name: + names[user_id] = display_name + return names + + +async def _load_qq_names( + context: CommandContext, + user_ids: list[int], +) -> dict[int, str]: + get_stranger_info = getattr(context.onebot, "get_stranger_info", None) + if not callable(get_stranger_info) or not user_ids: + return {} + + results = await asyncio.gather( + *(get_stranger_info(user_id) for user_id in user_ids), + return_exceptions=True, + ) + + names: dict[int, str] = {} + for user_id, result in zip(user_ids, results, strict=False): + if isinstance(result, Exception): + continue + if result is not None and not isinstance(result, dict): + continue + display_name = _extract_display_name(result) + if display_name: + names[user_id] = display_name + return names + + +async def _resolve_admin_names( + context: CommandContext, + user_ids: list[int], +) -> dict[int, str]: + names = await _load_group_member_names(context) + missing_ids = [user_id for user_id in user_ids if user_id not in names] + if missing_ids: + names.update(await _load_qq_names(context, missing_ids)) + return names + + +async def _handle_ls(context: CommandContext) -> None: + admins = [ + qq for qq in context.config.admin_qqs if qq != context.config.superadmin_qq + ] + all_admins = [context.config.superadmin_qq, *admins] + admin_names = await _resolve_admin_names(context, all_admins) + + lines = [ + "👑 超级管理员: " + f"{admin_names.get(context.config.superadmin_qq, _UNKNOWN_MEMBER_NAME)}" + ] + if admins: + admin_list = "\n".join( + [ + f"- {admin_names.get(admin_qq, _UNKNOWN_MEMBER_NAME)}" + for admin_qq in admins + ] + ) + lines.append(f"\n📋 管理员列表:\n{admin_list}") + else: + lines.append("\n📋 暂无其他管理员") + await _send("\n".join(lines), context) + + +# ── add 相关 ── + + +async def _handle_add(args: list[str], context: CommandContext) -> None: + if not args: + await _send( + "❌ 用法: /admin add \n示例: /admin add 123456789 或 /admin add @某人", + context, + ) + return + + try: + new_admin_qq = int(args[0]) + except ValueError: + await _send("❌ QQ 号格式错误,必须为数字或 @ 提及", context) + return + + if context.config.is_admin(new_admin_qq): + await _send(f"⚠️ {new_admin_qq} 已经是管理员了", context) + return + + try: + context.config.add_admin(new_admin_qq) + await _send(f"✅ 已添加管理员: {new_admin_qq}", context) + except Exception as exc: + error_id = uuid4().hex[:8] + logger.exception("添加管理员失败: error_id=%s err=%s", error_id, exc) + await _send( + f"❌ 添加管理员失败,请稍后重试(错误码: {error_id})", + context, + ) + + +# ── del 相关 ── + + +async def _handle_del(args: list[str], context: CommandContext) -> None: + if not args: + await _send( + "❌ 用法: /admin del \n示例: /admin del 123456789 或 /admin del @某人", + context, + ) + return + + try: + target_qq = int(args[0]) + except ValueError: + await _send("❌ QQ 号格式错误,必须为数字或 @ 提及", context) + return + + if context.config.is_superadmin(target_qq): + await _send("❌ 无法移除超级管理员", context) + return + + if not context.config.is_admin(target_qq): + await _send(f"⚠️ {target_qq} 不是管理员", context) + return + + try: + context.config.remove_admin(target_qq) + await _send(f"✅ 已移除管理员: {target_qq}", context) + except Exception as exc: + error_id = uuid4().hex[:8] + logger.exception("移除管理员失败: error_id=%s err=%s", error_id, exc) + await _send( + f"❌ 移除管理员失败,请稍后重试(错误码: {error_id})", + context, + ) + + +# ── 入口 ── + + +async def execute(args: list[str], context: CommandContext) -> None: + """处理 /admin。分发层已处理子命令推断和权限检查,args 格式为 [子命令, *子参数]。""" + if not args: + await _handle_ls(context) + return + + subcommand = args[0].lower() + sub_args = args[1:] + + if subcommand == "ls": + await _handle_ls(context) + elif subcommand == "add": + await _handle_add(sub_args, context) + elif subcommand == "del": + await _handle_del(sub_args, context) + else: + await _send(_USAGE_TEXT, context) diff --git a/src/Undefined/skills/commands/lsadmin/README.md b/src/Undefined/skills/commands/lsadmin/README.md deleted file mode 100644 index a1dedd66..00000000 --- a/src/Undefined/skills/commands/lsadmin/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# /lsadmin 命令说明 - -## 功能 -查看当前系统的管理员名单(含超级管理员与动态管理员)。 - -## 用法 -- `/lsadmin` - -## 示例 -- `/lsadmin` - -## 说明 -- 该命令需要管理员权限。 -- 输出优先展示群昵称,其次尝试按 QQ 号查询 QQ 昵称,不直接展示 QQ 号。 diff --git a/src/Undefined/skills/commands/lsadmin/config.json b/src/Undefined/skills/commands/lsadmin/config.json deleted file mode 100644 index 8bc030d3..00000000 --- a/src/Undefined/skills/commands/lsadmin/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "lsadmin", - "description": "查看当前机器人管理员列表", - "usage": "/lsadmin", - "example": "/lsadmin", - "permission": "admin", - "rate_limit": { - "user": 0, - "admin": 0, - "superadmin": 0 - }, - "show_in_help": true, - "order": 70, - "allow_in_private": true, - "aliases": [] -} diff --git a/src/Undefined/skills/commands/lsadmin/handler.py b/src/Undefined/skills/commands/lsadmin/handler.py deleted file mode 100644 index e825bee6..00000000 --- a/src/Undefined/skills/commands/lsadmin/handler.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -from Undefined.services.commands.context import CommandContext - - -_UNKNOWN_MEMBER_NAME = "未知成员" - - -def _extract_display_name(user_info: dict[str, Any] | None) -> str | None: - if not isinstance(user_info, dict): - return None - for field in ("card", "nickname", "remark"): - value = str(user_info.get(field) or "").strip() - if value: - return value - return None - - -async def _load_group_member_names(context: CommandContext) -> dict[int, str]: - if context.group_id <= 0: - return {} - - get_group_member_list = getattr(context.onebot, "get_group_member_list", None) - if not callable(get_group_member_list): - return {} - - try: - members = await get_group_member_list(context.group_id) - except Exception: - return {} - - names: dict[int, str] = {} - if not isinstance(members, list): - return names - - for member in members: - if not isinstance(member, dict): - continue - user_id_raw = member.get("user_id") - if user_id_raw is None: - continue - try: - user_id = int(user_id_raw) - except (TypeError, ValueError): - continue - display_name = _extract_display_name(member) - if display_name: - names[user_id] = display_name - return names - - -async def _load_qq_names( - context: CommandContext, - user_ids: list[int], -) -> dict[int, str]: - get_stranger_info = getattr(context.onebot, "get_stranger_info", None) - if not callable(get_stranger_info) or not user_ids: - return {} - - results = await asyncio.gather( - *(get_stranger_info(user_id) for user_id in user_ids), - return_exceptions=True, - ) - - names: dict[int, str] = {} - for user_id, result in zip(user_ids, results, strict=False): - if isinstance(result, Exception): - continue - if result is not None and not isinstance(result, dict): - continue - display_name = _extract_display_name(result) - if display_name: - names[user_id] = display_name - return names - - -async def _resolve_admin_names( - context: CommandContext, - user_ids: list[int], -) -> dict[int, str]: - names = await _load_group_member_names(context) - missing_ids = [user_id for user_id in user_ids if user_id not in names] - if missing_ids: - names.update(await _load_qq_names(context, missing_ids)) - return names - - -async def execute(args: list[str], context: CommandContext) -> None: - """处理 /lsadmin。""" - - _ = args - admins = [ - qq for qq in context.config.admin_qqs if qq != context.config.superadmin_qq - ] - all_admins = [context.config.superadmin_qq, *admins] - admin_names = await _resolve_admin_names(context, all_admins) - - lines = [ - "👑 超级管理员: " - f"{admin_names.get(context.config.superadmin_qq, _UNKNOWN_MEMBER_NAME)}" - ] - if admins: - admin_list = "\n".join( - [ - f"- {admin_names.get(admin_qq, _UNKNOWN_MEMBER_NAME)}" - for admin_qq in admins - ] - ) - lines.append(f"\n📋 管理员列表:\n{admin_list}") - else: - lines.append("\n📋 暂无其他管理员") - await context.sender.send_group_message(context.group_id, "\n".join(lines)) diff --git a/src/Undefined/skills/commands/rmadmin/README.md b/src/Undefined/skills/commands/rmadmin/README.md deleted file mode 100644 index b0794f1d..00000000 --- a/src/Undefined/skills/commands/rmadmin/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# /rmadmin 命令说明 - -## 功能 -移除指定 QQ 号的动态管理员权限(仅超级管理员可执行)。 - -## 用法 -- `/rmadmin ` - -## 参数 -- `` 必填,必须为纯数字。 - -## 示例 -- `/rmadmin 123456789` - -## 说明 -- 不能通过此命令移除超级管理员。 -- 若目标不是管理员,会返回对应提示。 diff --git a/src/Undefined/skills/commands/rmadmin/config.json b/src/Undefined/skills/commands/rmadmin/config.json deleted file mode 100644 index 36593052..00000000 --- a/src/Undefined/skills/commands/rmadmin/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "rmadmin", - "description": "移除管理员(仅超级管理员),支持 @ 提及", - "usage": "/rmadmin ", - "example": "/rmadmin 123456789 或 /rmadmin @某人", - "permission": "superadmin", - "rate_limit": { - "user": 0, - "admin": 0, - "superadmin": 0 - }, - "show_in_help": true, - "order": 90, - "allow_in_private": true, - "aliases": [] -} diff --git a/src/Undefined/skills/commands/rmadmin/handler.py b/src/Undefined/skills/commands/rmadmin/handler.py deleted file mode 100644 index cfedd7ee..00000000 --- a/src/Undefined/skills/commands/rmadmin/handler.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import logging -from uuid import uuid4 - -from Undefined.services.commands.context import CommandContext - -logger = logging.getLogger(__name__) - - -async def execute(args: list[str], context: CommandContext) -> None: - """处理 /rmadmin。""" - - if not args: - await context.sender.send_group_message( - context.group_id, - "❌ 用法: /rmadmin \n示例: /rmadmin 123456789 或 /rmadmin @某人", - ) - return - - try: - target_qq = int(args[0]) - except ValueError: - await context.sender.send_group_message( - context.group_id, - "❌ QQ 号格式错误,必须为数字或 @ 提及", - ) - return - - if context.config.is_superadmin(target_qq): - await context.sender.send_group_message( - context.group_id, "❌ 无法移除超级管理员" - ) - return - - if not context.config.is_admin(target_qq): - await context.sender.send_group_message( - context.group_id, - f"⚠️ {target_qq} 不是管理员", - ) - return - - try: - context.config.remove_admin(target_qq) - await context.sender.send_group_message( - context.group_id, - f"✅ 已移除管理员: {target_qq}", - ) - except Exception as exc: - error_id = uuid4().hex[:8] - logger.exception("移除管理员失败: error_id=%s err=%s", error_id, exc) - await context.sender.send_group_message( - context.group_id, - f"❌ 移除管理员失败,请稍后重试(错误码: {error_id})", - ) diff --git a/src/Undefined/skills/pipelines/__init__.py b/src/Undefined/skills/pipelines/__init__.py new file mode 100644 index 00000000..ae525468 --- /dev/null +++ b/src/Undefined/skills/pipelines/__init__.py @@ -0,0 +1,6 @@ +"""自动处理管线注册与运行。""" + +from Undefined.skills.pipelines.models import PipelineDetection +from Undefined.skills.pipelines.registry import PipelineRegistry + +__all__ = ["PipelineDetection", "PipelineRegistry"] diff --git a/src/Undefined/skills/auto_pipeline/pipelines/arxiv/config.json b/src/Undefined/skills/pipelines/arxiv/config.json similarity index 100% rename from src/Undefined/skills/auto_pipeline/pipelines/arxiv/config.json rename to src/Undefined/skills/pipelines/arxiv/config.json diff --git a/src/Undefined/skills/auto_pipeline/pipelines/arxiv/handler.py b/src/Undefined/skills/pipelines/arxiv/handler.py similarity index 72% rename from src/Undefined/skills/auto_pipeline/pipelines/arxiv/handler.py rename to src/Undefined/skills/pipelines/arxiv/handler.py index 2fcd83db..8dcf961e 100644 --- a/src/Undefined/skills/auto_pipeline/pipelines/arxiv/handler.py +++ b/src/Undefined/skills/pipelines/arxiv/handler.py @@ -2,9 +2,9 @@ from typing import Any -from Undefined.skills.auto_pipeline.models import ( - AutoPipelineContext, - AutoPipelineDetection, +from Undefined.skills.pipelines.models import ( + PipelineContext, + PipelineDetection, ) @@ -16,7 +16,7 @@ def _is_allowed(config: Any, target_type: str, target_id: int) -> bool: return bool(config.is_arxiv_auto_extract_allowed_private(target_id)) -async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: +async def detect(context: PipelineContext) -> PipelineDetection | None: target_id = int(context["target_id"]) target_type = str(context["target_type"]) config = context["config"] @@ -27,14 +27,12 @@ async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: paper_ids = extractor(context["text"], context["message_content"]) if not paper_ids: return None - return AutoPipelineDetection( - name="arxiv", items=tuple(str(item) for item in paper_ids) - ) + return PipelineDetection(name="arxiv", items=tuple(str(item) for item in paper_ids)) async def process( - detection: AutoPipelineDetection, - context: AutoPipelineContext, + detection: PipelineDetection, + context: PipelineContext, ) -> None: handler = context["handle_arxiv_extract"] await handler( diff --git a/src/Undefined/skills/auto_pipeline/pipelines/bilibili/config.json b/src/Undefined/skills/pipelines/bilibili/config.json similarity index 100% rename from src/Undefined/skills/auto_pipeline/pipelines/bilibili/config.json rename to src/Undefined/skills/pipelines/bilibili/config.json diff --git a/src/Undefined/skills/auto_pipeline/pipelines/bilibili/handler.py b/src/Undefined/skills/pipelines/bilibili/handler.py similarity index 72% rename from src/Undefined/skills/auto_pipeline/pipelines/bilibili/handler.py rename to src/Undefined/skills/pipelines/bilibili/handler.py index 5528b554..3e60c617 100644 --- a/src/Undefined/skills/auto_pipeline/pipelines/bilibili/handler.py +++ b/src/Undefined/skills/pipelines/bilibili/handler.py @@ -2,9 +2,9 @@ from typing import Any -from Undefined.skills.auto_pipeline.models import ( - AutoPipelineContext, - AutoPipelineDetection, +from Undefined.skills.pipelines.models import ( + PipelineContext, + PipelineDetection, ) @@ -16,7 +16,7 @@ def _is_allowed(config: Any, target_type: str, target_id: int) -> bool: return bool(config.is_bilibili_auto_extract_allowed_private(target_id)) -async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: +async def detect(context: PipelineContext) -> PipelineDetection | None: target_id = int(context["target_id"]) target_type = str(context["target_type"]) config = context["config"] @@ -27,14 +27,12 @@ async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: bvids = await extractor(context["text"], context["message_content"]) if not bvids: return None - return AutoPipelineDetection( - name="bilibili", items=tuple(str(item) for item in bvids) - ) + return PipelineDetection(name="bilibili", items=tuple(str(item) for item in bvids)) async def process( - detection: AutoPipelineDetection, - context: AutoPipelineContext, + detection: PipelineDetection, + context: PipelineContext, ) -> None: handler = context["handle_bilibili_extract"] await handler( diff --git a/src/Undefined/skills/pipelines/context.py b/src/Undefined/skills/pipelines/context.py new file mode 100644 index 00000000..66c2f7a0 --- /dev/null +++ b/src/Undefined/skills/pipelines/context.py @@ -0,0 +1,30 @@ +"""构建管线执行上下文。""" + +from __future__ import annotations + +from typing import Any + + +def build_pipeline_context( + handler: Any, + *, + target_id: int, + target_type: str, + text: str, + message_content: list[dict[str, Any]] | None, +) -> dict[str, Any]: + return { + "config": handler.config, + "sender": handler.sender, + "onebot": handler.onebot, + "target_id": target_id, + "target_type": target_type, + "text": text, + "message_content": message_content, + "extract_bilibili_ids": handler._extract_bilibili_ids, + "extract_arxiv_ids": handler._extract_arxiv_ids, + "extract_github_repo_ids": handler._extract_github_repo_ids, + "handle_bilibili_extract": handler._handle_bilibili_extract, + "handle_arxiv_extract": handler._handle_arxiv_extract, + "handle_github_extract": handler._handle_github_extract, + } diff --git a/src/Undefined/skills/auto_pipeline/pipelines/github/config.json b/src/Undefined/skills/pipelines/github/config.json similarity index 100% rename from src/Undefined/skills/auto_pipeline/pipelines/github/config.json rename to src/Undefined/skills/pipelines/github/config.json diff --git a/src/Undefined/skills/auto_pipeline/pipelines/github/handler.py b/src/Undefined/skills/pipelines/github/handler.py similarity index 72% rename from src/Undefined/skills/auto_pipeline/pipelines/github/handler.py rename to src/Undefined/skills/pipelines/github/handler.py index 392e458d..2148d49c 100644 --- a/src/Undefined/skills/auto_pipeline/pipelines/github/handler.py +++ b/src/Undefined/skills/pipelines/github/handler.py @@ -2,9 +2,9 @@ from typing import Any -from Undefined.skills.auto_pipeline.models import ( - AutoPipelineContext, - AutoPipelineDetection, +from Undefined.skills.pipelines.models import ( + PipelineContext, + PipelineDetection, ) @@ -16,7 +16,7 @@ def _is_allowed(config: Any, target_type: str, target_id: int) -> bool: return bool(config.is_github_auto_extract_allowed_private(target_id)) -async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: +async def detect(context: PipelineContext) -> PipelineDetection | None: target_id = int(context["target_id"]) target_type = str(context["target_type"]) config = context["config"] @@ -27,14 +27,12 @@ async def detect(context: AutoPipelineContext) -> AutoPipelineDetection | None: repo_ids = extractor(context["text"], context["message_content"]) if not repo_ids: return None - return AutoPipelineDetection( - name="github", items=tuple(str(item) for item in repo_ids) - ) + return PipelineDetection(name="github", items=tuple(str(item) for item in repo_ids)) async def process( - detection: AutoPipelineDetection, - context: AutoPipelineContext, + detection: PipelineDetection, + context: PipelineContext, ) -> None: handler = context["handle_github_extract"] await handler( diff --git a/src/Undefined/skills/auto_pipeline/models.py b/src/Undefined/skills/pipelines/models.py similarity index 74% rename from src/Undefined/skills/auto_pipeline/models.py rename to src/Undefined/skills/pipelines/models.py index 3d3fb7e1..6b6cafda 100644 --- a/src/Undefined/skills/auto_pipeline/models.py +++ b/src/Undefined/skills/pipelines/models.py @@ -5,12 +5,12 @@ from dataclasses import dataclass, field from typing import Any, Literal, Mapping -AutoPipelineTargetType = Literal["group", "private"] -AutoPipelineContext = dict[str, Any] +PipelineTargetType = Literal["group", "private"] +PipelineContext = dict[str, Any] @dataclass(frozen=True) -class AutoPipelineDetection: +class PipelineDetection: """单条自动处理管线的命中结果。""" name: str diff --git a/src/Undefined/skills/auto_pipeline/registry.py b/src/Undefined/skills/pipelines/registry.py similarity index 76% rename from src/Undefined/skills/auto_pipeline/registry.py rename to src/Undefined/skills/pipelines/registry.py index e825384f..e9e9b29a 100644 --- a/src/Undefined/skills/auto_pipeline/registry.py +++ b/src/Undefined/skills/pipelines/registry.py @@ -13,19 +13,19 @@ from types import ModuleType from typing import Any, Awaitable, Callable -from Undefined.skills.auto_pipeline.models import ( - AutoPipelineContext, - AutoPipelineDetection, +from Undefined.skills.pipelines.models import ( + PipelineContext, + PipelineDetection, ) logger = logging.getLogger(__name__) -DetectHandler = Callable[[AutoPipelineContext], Awaitable[AutoPipelineDetection | None]] -ProcessHandler = Callable[[AutoPipelineDetection, AutoPipelineContext], Awaitable[None]] +DetectHandler = Callable[[PipelineContext], Awaitable[PipelineDetection | None]] +ProcessHandler = Callable[[PipelineDetection, PipelineContext], Awaitable[None]] @dataclass(frozen=True) -class AutoPipelineItem: +class PipelineItem: name: str description: str order: int @@ -35,16 +35,14 @@ class AutoPipelineItem: process: ProcessHandler -class AutoPipelineRegistry: +class PipelineRegistry: """发现、热重载并并行运行自动处理管线。""" def __init__(self, base_dir: Path | str | None = None) -> None: self.base_dir = ( - Path(base_dir) - if base_dir is not None - else Path(__file__).parent / "pipelines" + Path(base_dir) if base_dir is not None else Path(__file__).parent ) - self._items: dict[str, AutoPipelineItem] = {} + self._items: dict[str, PipelineItem] = {} self._items_lock = asyncio.Lock() self._reload_lock = asyncio.Lock() self._watch_task: asyncio.Task[None] | None = None @@ -53,19 +51,16 @@ def __init__(self, base_dir: Path | str | None = None) -> None: self._watch_filenames: set[str] = {"config.json", "handler.py"} def load_items(self) -> None: - """从磁盘加载所有自动处理管线。""" self._items = self._load_items_sync() async def load_items_async(self) -> None: - """在线程中加载所有自动处理管线,避免阻塞事件循环。""" async with self._reload_lock: await self._reload_items() - def _load_items_sync(self) -> dict[str, AutoPipelineItem]: - """同步加载管线定义;热重载路径会在线程中调用。""" - items: dict[str, AutoPipelineItem] = {} + def _load_items_sync(self) -> dict[str, PipelineItem]: + items: dict[str, PipelineItem] = {} if not self.base_dir.exists(): - logger.warning("[auto_pipeline] 目录不存在: %s", self.base_dir) + logger.warning("[pipelines] 目录不存在: %s", self.base_dir) return items for item_dir in sorted(self.base_dir.iterdir()): @@ -77,17 +72,17 @@ def _load_items_sync(self) -> dict[str, AutoPipelineItem]: loaded_items = dict(sorted(items.items(), key=lambda pair: pair[1].order)) logger.info( - "[auto_pipeline] 已加载自动处理管线: count=%s names=%s", + "[pipelines] 已加载自动处理管线: count=%s names=%s", len(loaded_items), ",".join(loaded_items), ) return loaded_items - def _load_item(self, item_dir: Path) -> AutoPipelineItem | None: + def _load_item(self, item_dir: Path) -> PipelineItem | None: config_path = item_dir / "config.json" handler_path = item_dir / "handler.py" if not config_path.exists() or not handler_path.exists(): - logger.debug("[auto_pipeline] 跳过缺少 config/handler 的目录: %s", item_dir) + logger.debug("[pipelines] 跳过缺少 config/handler 的目录: %s", item_dir) return None try: @@ -104,7 +99,7 @@ def _load_item(self, item_dir: Path) -> AutoPipelineItem | None: raise RuntimeError( "handler.py 必须提供 detect(context) 和 process(detection, context)" ) - return AutoPipelineItem( + return PipelineItem( name=name, description=description, order=order, @@ -114,7 +109,7 @@ def _load_item(self, item_dir: Path) -> AutoPipelineItem | None: process=process, ) except Exception: - logger.exception("[auto_pipeline] 加载管线失败: %s", item_dir) + logger.exception("[pipelines] 加载管线失败: %s", item_dir) return None def _load_config(self, config_path: Path) -> dict[str, Any]: @@ -125,7 +120,7 @@ def _load_config(self, config_path: Path) -> dict[str, Any]: return data def _build_module_name(self, name: str) -> str: - return f"Undefined.skills.auto_pipeline.pipelines.{name}.handler" + return f"Undefined.skills.pipelines.{name}.handler" def _load_handler_module(self, name: str, handler_path: Path) -> ModuleType: module_name = self._build_module_name(name) @@ -145,14 +140,13 @@ def _load_handler_module(self, name: str, handler_path: Path) -> ModuleType: raise return module - async def run(self, context: AutoPipelineContext) -> list[AutoPipelineDetection]: - """并行检测所有管线,并并行处理全部命中结果。""" + async def run(self, context: PipelineContext) -> list[PipelineDetection]: detections = await self.detect(context) if detections: await self.process(detections, context) return detections - async def detect(self, context: AutoPipelineContext) -> list[AutoPipelineDetection]: + async def detect(self, context: PipelineContext) -> list[PipelineDetection]: async with self._items_lock: items = list(self._items.values()) if not items: @@ -162,11 +156,11 @@ async def detect(self, context: AutoPipelineContext) -> list[AutoPipelineDetecti *(self._detect_one(item, context) for item in items), return_exceptions=True, ) - detections: list[AutoPipelineDetection] = [] + detections: list[PipelineDetection] = [] for item, result in zip(items, results, strict=True): if isinstance(result, BaseException): logger.exception( - "[auto_pipeline] 检测失败: name=%s", + "[pipelines] 检测失败: name=%s", item.name, exc_info=(type(result), result, result.__traceback__), ) @@ -176,14 +170,14 @@ async def detect(self, context: AutoPipelineContext) -> list[AutoPipelineDetecti return detections async def _detect_one( - self, item: AutoPipelineItem, context: AutoPipelineContext - ) -> AutoPipelineDetection | None: + self, item: PipelineItem, context: PipelineContext + ) -> PipelineDetection | None: start = time.monotonic() detection = await item.detect(context) duration_ms = int((time.monotonic() - start) * 1000) if detection is not None: logger.info( - "[auto_pipeline] 命中管线: name=%s items=%s duration_ms=%s", + "[pipelines] 命中管线: name=%s items=%s duration_ms=%s", item.name, len(detection.items), duration_ms, @@ -192,8 +186,8 @@ async def _detect_one( async def process( self, - detections: list[AutoPipelineDetection], - context: AutoPipelineContext, + detections: list[PipelineDetection], + context: PipelineContext, ) -> None: async with self._items_lock: items = dict(self._items) @@ -203,7 +197,7 @@ async def process( item = items.get(detection.name) if item is None: logger.warning( - "[auto_pipeline] 命中结果缺少处理器: name=%s", detection.name + "[pipelines] 命中结果缺少处理器: name=%s", detection.name ) continue names.append(detection.name) @@ -215,21 +209,21 @@ async def process( for name, result in zip(names, results, strict=True): if isinstance(result, BaseException): logger.exception( - "[auto_pipeline] 处理失败: name=%s", + "[pipelines] 处理失败: name=%s", name, exc_info=(type(result), result, result.__traceback__), ) async def _process_one( self, - item: AutoPipelineItem, - detection: AutoPipelineDetection, - context: AutoPipelineContext, + item: PipelineItem, + detection: PipelineDetection, + context: PipelineContext, ) -> None: start = time.monotonic() await item.process(detection, context) logger.info( - "[auto_pipeline] 管线处理完成: name=%s duration_ms=%s", + "[pipelines] 管线处理完成: name=%s duration_ms=%s", item.name, int((time.monotonic() - start) * 1000), ) @@ -268,7 +262,7 @@ async def _watch_loop(self, interval: float, debounce: float) -> None: pending = False async with self._reload_lock: await self._reload_items() - logger.info("[auto_pipeline] 热重载完成: count=%s", len(self._items)) + logger.info("[pipelines] 热重载完成: count=%s", len(self._items)) def start_hot_reload(self, interval: float = 2.0, debounce: float = 0.5) -> None: if self._watch_task is not None: @@ -276,7 +270,7 @@ def start_hot_reload(self, interval: float = 2.0, debounce: float = 0.5) -> None self._watch_stop = asyncio.Event() self._watch_task = asyncio.create_task(self._watch_loop(interval, debounce)) logger.info( - "[auto_pipeline] 热重载已启动: interval=%.2fs debounce=%.2fs", + "[pipelines] 热重载已启动: interval=%.2fs debounce=%.2fs", interval, debounce, ) @@ -290,4 +284,4 @@ async def stop_hot_reload(self) -> None: finally: self._watch_task = None self._watch_stop = None - logger.info("[auto_pipeline] 热重载已停止") + logger.info("[pipelines] 热重载已停止") diff --git a/src/Undefined/skills/tools/end/README.md b/src/Undefined/skills/tools/end/README.md index 1c41c4e2..1a85d70a 100644 --- a/src/Undefined/skills/tools/end/README.md +++ b/src/Undefined/skills/tools/end/README.md @@ -3,11 +3,10 @@ 用于结束对话,通常在完成回复后调用一次。 关键信息: -- `memo`(可选):本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里,如调用工具、决定不回复等) -- `observations`(可选):字符串数组,本轮值得长期留存的观察(严格一条一个要点,可多条)——包括用户/群聊事实和有价值的自身行为(帮谁解决了什么问题);纯流水账写 memo 而非此处 +- `memo`(可选):本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里,如调用工具、决定不回复等)。若当前输入批次包含多条消息,应概括整批处理结果。 +- `observations`(可选):字符串数组,本轮值得长期留存的观察(严格一条一个要点,可多条)——包括用户/群聊事实和有价值的自身行为(帮谁解决了什么问题);纯流水账写 memo 而非此处。若当前输入批次包含 MessageBatcher 合并的多条消息,必须覆盖整批消息内容,不能只记录最后一条。 - `force`(可选):`true` 时可跳过"本轮未发送消息"的结束检查;同时在认知史官绝对化正则闸门失败时允许强制入库 - 两者都可为空;为空时仅结束会话,不写认知队列 -- 向后兼容:旧参数名 `action_summary`/`new_info` 仍可使用,会自动映射到 `memo`/`observations` 目录结构: - `config.json`:工具定义 diff --git a/src/Undefined/skills/tools/end/config.json b/src/Undefined/skills/tools/end/config.json index 0ee7192a..bb782b8f 100644 --- a/src/Undefined/skills/tools/end/config.json +++ b/src/Undefined/skills/tools/end/config.json @@ -2,22 +2,18 @@ "type": "function", "function": { "name": "end", - "description": "结束当前对话。memo 是本轮便签纸(短句);observations 提取本轮值得长期留存的观察——包括用户/群聊事实和有回忆价值的自身行为(帮谁解决了什么),纯流水账动作写 memo。", + "description": "结束当前对话。memo 是本轮便签纸(短句);observations 从当前输入批次提取本轮值得长期留存的观察——包括用户/群聊事实和有回忆价值的自身行为(帮谁解决了什么),纯流水账动作写 memo。若当前输入批次包含 MessageBatcher 合并的多条消息,记忆记录必须覆盖整批,不要只看最后一条。", "parameters": { "type": "object", "properties": { "memo": { "type": "string", - "description": "本轮行动备忘(可空,建议短句)" + "description": "本轮行动备忘(可空,建议短句)。若当前输入批次包含多条消息,memo 应概括整批处理结果。" }, "observations": { "type": "array", "items": {"type": "string"}, - "description": "从当前消息提取认知观察列表——记录用户/群聊事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有回忆价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点,宁多勿漏。纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。" - }, - "summary": { - "type": "string", - "description": "[过渡兼容] 旧版摘要字段,优先使用 memo。尽量不要填写。" + "description": "从当前输入批次提取认知观察列表;存在【连续消息说明】或多段当前 时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有回忆价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点,宁多勿漏。纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。" }, "perspective": { "type": "string", diff --git a/src/Undefined/skills/tools/end/handler.py b/src/Undefined/skills/tools/end/handler.py index 42a98831..84e3a643 100644 --- a/src/Undefined/skills/tools/end/handler.py +++ b/src/Undefined/skills/tools/end/handler.py @@ -1,12 +1,13 @@ from __future__ import annotations from collections import deque +import html from typing import Any, Dict import logging import re from Undefined.context import RequestContext -from Undefined.utils.coerce import safe_int +from Undefined.utils.coerce import coerce_truthy, is_truthy, safe_int, was_message_sent from Undefined.utils.xml import format_message_xml from Undefined.end_summary_storage import ( @@ -18,12 +19,11 @@ logger = logging.getLogger(__name__) -_TRUE_BOOL_TOKENS = {"1", "true", "yes", "y", "on"} -_FALSE_BOOL_TOKENS = {"0", "false", "no", "n", "off", ""} -_CONTENT_TAG_RE = re.compile( - r"]*>\s*(?P.*?)\s*", +_MESSAGE_TAG_RE = re.compile( + r"[^>]*)>\s*(?P.*?).*?", re.DOTALL | re.IGNORECASE, ) +_MESSAGE_ATTR_RE = re.compile(r'(?P[\w:-]+)="(?P[^"]*)"') _DEFAULT_HISTORIAN_TEXT_LEN = 800 _DEFAULT_HISTORIAN_LINES = 12 _DEFAULT_HISTORIAN_LINE_LEN = 240 @@ -35,47 +35,16 @@ _MAX_HISTORIAN_LINE_LEN = 1000 -def _coerce_bool(value: Any) -> tuple[bool, bool]: - """宽松布尔解析。 - - 返回: - (parsed_value, recognized) - """ - if isinstance(value, bool): - return value, True - - if isinstance(value, int): - return value != 0, True - - if isinstance(value, str): - token = value.strip().lower() - if token in _TRUE_BOOL_TOKENS: - return True, True - if token in _FALSE_BOOL_TOKENS: - return False, True - - return False, False - - def _parse_force_flag(value: Any) -> tuple[bool, bool]: """force 支持宽松布尔解析(字符串大小写、0/1 等)。""" - return _coerce_bool(value) - - -def _is_true_flag(value: Any) -> bool: - """上下文标记采用宽松布尔解析。""" - parsed, _recognized = _coerce_bool(value) - return parsed + return coerce_truthy(value) def _was_message_sent_this_turn(context: Dict[str, Any]) -> bool: - if _is_true_flag(context.get("message_sent_this_turn", False)): + """统一入口:先看 context 里的标记,回落到当前 RequestContext。""" + if is_truthy(context.get("message_sent_this_turn", False)): return True - - ctx = RequestContext.current() - if ctx is None: - return False - return _is_true_flag(ctx.get_resource("message_sent_this_turn", False)) + return was_message_sent(RequestContext.current()) def _clip_text(value: Any, max_len: int) -> str: @@ -127,13 +96,57 @@ def _resolve_historian_limits(context: Dict[str, Any]) -> tuple[int, int]: return max_source_len, recent_k -def _extract_current_content_from_question(question: str, *, max_len: int) -> str: +def _parse_message_attrs(attrs_text: str) -> dict[str, str]: + return { + match.group("name"): html.unescape(match.group("value")) + for match in _MESSAGE_ATTR_RE.finditer(attrs_text) + } + + +def _format_source_message_line( + index: int, + attrs: dict[str, str], + content: str, + *, + content_max_len: int, +) -> str: + label_parts = [] + for key in ( + "message_id", + "sender", + "sender_id", + "group_id", + "group_name", + "location", + "time", + ): + value = attrs.get(key) + if value: + label_parts.append(f"{key}={value}") + label = " ".join(label_parts) or "message" + return f"[{index}] {label}: {_clip_text(content, content_max_len)}" + + +def _extract_current_input_batch_from_question(question: str, *, max_len: int) -> str: text = str(question or "").strip() if not text: return "" - matched = _CONTENT_TAG_RE.search(text) - if matched: - return _clip_text(matched.group("content"), max_len) + matches = list(_MESSAGE_TAG_RE.finditer(text)) + if matches: + if len(matches) == 1: + return _clip_text(html.unescape(matches[0].group("content")), max_len) + + content_max_len = max(32, max_len // max(len(matches), 1)) + lines = [ + _format_source_message_line( + index, + _parse_message_attrs(match.group("attrs")), + html.unescape(match.group("content")), + content_max_len=content_max_len, + ) + for index, match in enumerate(matches, start=1) + ] + return _clip_text("\n".join(lines), max_len) return _clip_text(text, max_len) @@ -189,7 +202,7 @@ def _build_historian_recent_messages( def _inject_historian_reference_context(context: Dict[str, Any]) -> None: max_source_len, recent_k = _resolve_historian_limits(context) current_question = str(context.get("current_question") or "").strip() - source_message = _extract_current_content_from_question( + source_message = _extract_current_input_batch_from_question( current_question, max_len=max_source_len ) if source_message: @@ -229,17 +242,9 @@ def _build_location(context: Dict[str, Any]) -> EndSummaryLocation | None: async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: - # memo 优先新名,fallback 旧名 action_summary 和 summary - memo_raw = ( - args.get("memo") - if "memo" in args - else (args.get("action_summary") or args.get("summary", "")) - ) + memo_raw = args.get("memo", "") memo = memo_raw.strip() if isinstance(memo_raw, str) else "" - # observations 优先新名,fallback 旧名 new_info - observations_raw = ( - args.get("observations") if "observations" in args else args.get("new_info", []) - ) + observations_raw = args.get("observations", []) if isinstance(observations_raw, str): observations = [observations_raw.strip()] if observations_raw.strip() else [] elif isinstance(observations_raw, list): @@ -250,8 +255,6 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: observations = [] perspective_raw = args.get("perspective", "") perspective = perspective_raw.strip() if isinstance(perspective_raw, str) else "" - # 兼容旧版 summary 字段 - summary = memo force_raw = args.get("force", False) force, force_recognized = _parse_force_flag(force_raw) if "force" in args and not force_recognized: @@ -263,7 +266,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ) # memo 非空且本轮未发送消息时拒绝(force=true 可跳过) - if summary and not force and not _was_message_sent_this_turn(context): + if memo and not force and not _was_message_sent_this_turn(context): logger.warning( "[end工具] 拒绝执行:本轮未发送消息,request_id=%s", context.get("request_id", "-"), @@ -275,21 +278,19 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: "若你获取到了新信息,应填写 observations 字段以保存这些信息,而不是放在 memo 里。" ) - if summary: + if memo: location = _build_location(context) record: EndSummaryRecord | None = None end_summary_storage = context.get("end_summary_storage") if isinstance(end_summary_storage, EndSummaryStorage): - record = await end_summary_storage.append_summary( - summary, location=location - ) + record = await end_summary_storage.append_summary(memo, location=location) elif end_summary_storage is not None: logger.warning( "[end工具] end_summary_storage 类型异常: %s", type(end_summary_storage) ) if record is None: - record = EndSummaryStorage.make_record(summary, location=location) + record = EndSummaryStorage.make_record(memo, location=location) end_summaries = context.get("end_summaries") if end_summaries is not None: @@ -303,7 +304,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: "[end工具] end_summaries 类型异常: %s", type(end_summaries) ) - logger.info("保存end记录: %s...", summary[:50]) + logger.info("保存end记录: %s...", memo[:50]) else: logger.info("[end工具] memo 为空,跳过 end 摘要写入") diff --git a/src/Undefined/utils/coerce.py b/src/Undefined/utils/coerce.py index 20c81eff..326ed734 100644 --- a/src/Undefined/utils/coerce.py +++ b/src/Undefined/utils/coerce.py @@ -4,6 +4,10 @@ from typing import Any, overload +# 宽松布尔识别(与 end 工具、batcher 共享语义)。 +_TRUTHY_TOKENS: frozenset[str] = frozenset({"1", "true", "yes", "y", "on"}) +_FALSY_TOKENS: frozenset[str] = frozenset({"0", "false", "no", "n", "off", ""}) + @overload def safe_int(value: Any) -> int | None: ... @@ -35,3 +39,55 @@ def safe_float(value: Any, default: float = 0.0) -> float: return float(value) except (TypeError, ValueError): return default + + +def coerce_truthy(value: Any) -> tuple[bool, bool]: + """Loose-truthy parser shared by end tool / message batcher. + + 返回 ``(parsed, recognized)``: + - ``parsed`` — 解析结果(识别失败时按 False 兜底); + - ``recognized`` — 是否成功识别(用于调用方决定是否记日志告警)。 + + 支持:bool、int(0=False / 其余 True)、字符串 ``1/true/yes/y/on`` + 与 ``0/false/no/n/off`` / 空串。 + """ + if isinstance(value, bool): + return value, True + if isinstance(value, int): + return value != 0, True + if isinstance(value, str): + token = value.strip().lower() + if token in _TRUTHY_TOKENS: + return True, True + if token in _FALSY_TOKENS: + return False, True + return False, False + + +def is_truthy(value: Any) -> bool: + """``coerce_truthy`` 的便捷封装,仅返回布尔结果。 + + 传入 None / 不可识别的类型一律按 False 处理。 + """ + parsed, _recognized = coerce_truthy(value) + return parsed + + +def was_message_sent(source: Any) -> bool: + """统一判断"本轮是否已经向用户发送过消息"。 + + ``source`` 可以是 ``RequestContext``(提供 ``get_resource``)或 dict。 + 任意异常或缺字段都按 False 兜底。 + """ + if source is None: + return False + # dict / 自定义 mapping 优先 + if isinstance(source, dict): + return is_truthy(source.get("message_sent_this_turn", False)) + getter = getattr(source, "get_resource", None) + if callable(getter): + try: + return is_truthy(getter("message_sent_this_turn", False)) + except Exception: # noqa: BLE001 - context 可能已失效 + return False + return False diff --git a/src/Undefined/utils/render_cache.py b/src/Undefined/utils/render_cache.py new file mode 100644 index 00000000..2bf2afe8 --- /dev/null +++ b/src/Undefined/utils/render_cache.py @@ -0,0 +1,403 @@ +"""HTML 渲染结果缓存:基于 HTML 内容 hash 缓存渲染图片,避免重复渲染。 + +关键约束(CLAUDE.md):磁盘读写必须走 :mod:`Undefined.utils.io` +(``asyncio.to_thread`` + 跨平台文件锁 + 原子写入),禁止在事件循环中直接阻塞 IO。 +本模块所有 ``stat`` / ``unlink`` / ``copy`` 都通过 ``asyncio.to_thread`` 包装; +JSON 元数据通过 :func:`Undefined.utils.io.read_json` / :func:`Undefined.utils.io.write_json` +读写,自带文件锁与原子替换。 +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import shutil +import time +from dataclasses import dataclass +from pathlib import Path +from typing import TypedDict + +from Undefined.config import RenderCacheConfig +from Undefined.utils import io as async_io +from Undefined.utils.paths import ensure_dir + +logger = logging.getLogger(__name__) + +__all__ = [ + "HtmlRenderCache", + "compute_render_cache_key", + "get_render_cache", + "close_render_cache", + "reset_render_cache", +] + + +class _CacheEntry(TypedDict): + path: str + size_bytes: int + created_at: float + last_accessed_at: float + + +@dataclass(frozen=True) +class _ResolvedConfig: + """运行时生效的缓存策略快照。""" + + enabled: bool + max_entries: int + max_size_bytes: int + flush_interval_seconds: float + + +class HtmlRenderCache: + """HTML 渲染结果 LRU 缓存。 + + 使用前必须先 ``await cache.initialize()``;推荐通过 :func:`get_render_cache` + 获取全局单例,单例工厂会负责 lazy initialize。 + + 禁用(``config.enabled=False``)时所有 ``get`` / ``put`` 都是 no-op, + 缓存目录不会被读写。 + """ + + _entries: dict[str, _CacheEntry] + _lock: asyncio.Lock + _dirty: bool + _last_flush: float + _cache_file: Path + _image_dir: Path + _config: _ResolvedConfig + _initialized: bool + + def __init__( + self, + cache_file: Path, + *, + max_entries: int = 50, + max_size_mb: int = 50, + flush_interval_seconds: float = 2.0, + enabled: bool = True, + ) -> None: + ensure_dir(cache_file.parent) + self._cache_file = cache_file + self._image_dir = ensure_dir(cache_file.parent / "html") + self._config = _ResolvedConfig( + enabled=enabled, + max_entries=max(1, max_entries), + max_size_bytes=max(1, max_size_mb) * 1024 * 1024, + flush_interval_seconds=max(0.0, flush_interval_seconds), + ) + self._entries = {} + self._dirty = False + self._last_flush = 0.0 + self._lock = asyncio.Lock() + self._initialized = False + + # ---------------------------------------------------------------- factory + + @classmethod + async def create( + cls, + cache_file: Path, + *, + max_entries: int = 50, + max_size_mb: int = 50, + flush_interval_seconds: float = 2.0, + enabled: bool = True, + ) -> HtmlRenderCache: + """异步工厂:构造并完成 lazy load。""" + cache = cls( + cache_file, + max_entries=max_entries, + max_size_mb=max_size_mb, + flush_interval_seconds=flush_interval_seconds, + enabled=enabled, + ) + await cache.initialize() + return cache + + async def initialize(self) -> None: + """异步加载磁盘元数据;多次调用幂等。""" + if self._initialized: + return + async with self._lock: + if self._initialized: + return + await self._load_locked() + self._initialized = True + + # ---------------------------------------------------------------- private + + async def _load_locked(self) -> None: + """读元数据并清理可能残留的 .tmp 文件。完全异步。""" + # 残留 tmp 清理 + legacy_tmp = self._cache_file.with_suffix(".tmp") + await async_io.delete_file(legacy_tmp) + + try: + raw = await async_io.read_json(self._cache_file) + except Exception: + logger.warning("[渲染缓存] 加载缓存文件失败,将使用空缓存", exc_info=True) + self._entries = {} + return + + if not isinstance(raw, dict): + self._entries = {} + return + + loaded: dict[str, _CacheEntry] = {} + for key, value in raw.items(): + if not isinstance(value, dict): + continue + try: + entry = _CacheEntry( + path=str(value["path"]), + size_bytes=int(value.get("size_bytes", 0)), + created_at=float(value.get("created_at", 0.0)), + last_accessed_at=float(value.get("last_accessed_at", 0.0)), + ) + except (KeyError, TypeError, ValueError): + continue + loaded[str(key)] = entry + + owned: dict[str, _CacheEntry] = {} + for key, entry in loaded.items(): + if self._is_cache_owned_path(Path(entry["path"])): + owned[key] = entry + if len(owned) != len(loaded): + self._dirty = True + self._entries = owned + logger.info("[渲染缓存] 已加载 %d 条缓存记录", len(self._entries)) + + async def _flush_locked(self, *, force: bool = False) -> None: + """异步落盘元数据。 + + ``force=False`` 时遵循 ``flush_interval_seconds`` 节流,避免热点写盘; + ``force=True`` 用于关停 / 用户主动触发。 + """ + if not self._dirty: + return + now = time.monotonic() + if ( + not force + and self._config.flush_interval_seconds > 0 + and now - self._last_flush < self._config.flush_interval_seconds + ): + return + snapshot = dict(self._entries) + try: + await async_io.write_json(self._cache_file, snapshot) + self._dirty = False + self._last_flush = now + except Exception: + logger.warning("[渲染缓存] 保存缓存文件失败", exc_info=True) + + async def _evict_lru_locked(self) -> None: + cfg = self._config + # 条目数上限 + while len(self._entries) > cfg.max_entries: + lru_key = min( + self._entries, + key=lambda k: self._entries[k]["last_accessed_at"], + ) + entry = self._entries.pop(lru_key) + self._dirty = True + await async_io.delete_file(Path(entry["path"])) + + # 总字节上限 + total = sum(e["size_bytes"] for e in self._entries.values()) + while total > cfg.max_size_bytes and self._entries: + lru_key = min( + self._entries, + key=lambda k: self._entries[k]["last_accessed_at"], + ) + entry = self._entries.pop(lru_key) + total -= entry["size_bytes"] + self._dirty = True + await async_io.delete_file(Path(entry["path"])) + + def _cache_path_for_key(self, key: str) -> Path: + safe_key = "".join(ch for ch in key if ch.isalnum() or ch in {"-", "_"}) + return self._image_dir / f"{safe_key}.png" + + def _is_cache_owned_path(self, path: Path) -> bool: + try: + path.resolve().relative_to(self._image_dir.resolve()) + return True + except ValueError: + return False + + async def _path_exists(self, path: Path) -> bool: + return await asyncio.to_thread(path.exists) + + async def _stat_size(self, path: Path) -> int: + def _stat() -> int: + try: + return path.stat().st_size + except OSError: + return 0 + + return await asyncio.to_thread(_stat) + + async def _copy_into_cache(self, source: Path, dest: Path) -> bool: + def _copy() -> bool: + try: + shutil.copy2(source, dest) + return True + except OSError: + return False + + return await asyncio.to_thread(_copy) + + # ----------------------------------------------------------------- public + + @property + def enabled(self) -> bool: + return self._config.enabled + + async def get(self, key: str) -> Path | None: + if not self._config.enabled: + return None + await self.initialize() + async with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + path = Path(entry["path"]) + if not self._is_cache_owned_path(path) or not await self._path_exists(path): + del self._entries[key] + self._dirty = True + return None + entry["last_accessed_at"] = time.time() + await self._flush_locked() + return path + + async def put(self, key: str, image_path: str | Path, size_bytes: int) -> None: + if not self._config.enabled: + return + await self.initialize() + source_path = Path(image_path) + if not await self._path_exists(source_path): + return + async with self._lock: + cache_path = self._cache_path_for_key(key) + existing = self._entries.get(key) + if existing is not None: + existing_path = Path(existing["path"]) + if self._is_cache_owned_path(existing_path) and await self._path_exists( + existing_path + ): + existing["last_accessed_at"] = time.time() + self._dirty = True + await self._flush_locked() + return + + if source_path.resolve() != cache_path.resolve(): + if not await self._copy_into_cache(source_path, cache_path): + return + actual_size = await self._stat_size(cache_path) + self._entries[key] = _CacheEntry( + path=str(cache_path), + size_bytes=actual_size if actual_size > 0 else size_bytes, + created_at=time.time(), + last_accessed_at=time.time(), + ) + self._dirty = True + await self._evict_lru_locked() + await self._flush_locked() + + async def copy_to(self, key: str, dest: str | Path) -> bool: + """命中缓存时把图片拷贝到 ``dest``,不命中返回 False。 + + 集中所有"读后拷贝"路径,避免调用方再写一份同步 IO。 + """ + cached_path = await self.get(key) + if cached_path is None: + return False + return await self._copy_into_cache(cached_path, Path(dest)) + + async def close(self) -> None: + """强制刷盘元数据;用于程序关停。""" + async with self._lock: + await self._flush_locked(force=True) + + +_cache: HtmlRenderCache | None = None +_cache_lock: asyncio.Lock = asyncio.Lock() + + +def compute_render_cache_key( + html_content: str, + viewport_width: int, + screenshot_selector: str | None, + proxy: str | None, +) -> str: + data = ( + html_content + + f"|{viewport_width}" + + f"|{str(screenshot_selector) if screenshot_selector is not None else ''}" + + f"|{str(proxy) if proxy is not None else ''}" + ) + return hashlib.sha256(data.encode()).hexdigest() + + +def _resolve_render_cache_config() -> RenderCacheConfig: + """从全局配置读取 RenderCacheConfig,失败时回落默认值。""" + try: + from Undefined.config import get_config + + runtime_config = get_config(strict=False) + except Exception: + logger.debug("[渲染缓存] 读取配置失败,回退到默认参数", exc_info=True) + return RenderCacheConfig() + cache_cfg = getattr(runtime_config, "render_cache", None) + if isinstance(cache_cfg, RenderCacheConfig): + return cache_cfg + return RenderCacheConfig() + + +async def get_render_cache() -> HtmlRenderCache: + """获取全局渲染缓存单例(lazy load)。 + + 单例的 enabled / 容量由 ``[render.cache]`` 决定; + 禁用时仍返回单例对象,但所有 get/put 立即短路。 + """ + global _cache + if _cache is not None: + await _cache.initialize() + return _cache + async with _cache_lock: + if _cache is not None: + await _cache.initialize() + return _cache + from Undefined.utils.paths import RENDER_CACHE_DIR + + cfg = _resolve_render_cache_config() + cache = HtmlRenderCache( + ensure_dir(RENDER_CACHE_DIR) / "_html_render_cache.json", + max_entries=cfg.max_entries, + max_size_mb=cfg.max_size_mb, + flush_interval_seconds=cfg.flush_interval_seconds, + enabled=cfg.enabled, + ) + await cache.initialize() + _cache = cache + return cache + + +async def close_render_cache() -> None: + """关停时调用:刷盘并丢弃单例。""" + global _cache + cache = _cache + if cache is None: + return + try: + await cache.close() + finally: + _cache = None + + +def reset_render_cache() -> None: + """仅供测试使用:丢弃单例(不刷盘),下次调用重新加载。""" + global _cache + _cache = None diff --git a/src/Undefined/webui/routes/_system.py b/src/Undefined/webui/routes/_system.py index 6e364bc5..aa757eb9 100644 --- a/src/Undefined/webui/routes/_system.py +++ b/src/Undefined/webui/routes/_system.py @@ -7,6 +7,14 @@ from aiohttp.web_response import Response from Undefined import __version__ +from Undefined.changelog import ( + ChangelogEntry, + ChangelogError, + ChangelogFormatError, + entry_to_dict, + list_entries, + normalize_version, +) from Undefined.config import get_config from ._shared import auth_capabilities, routes, check_auth from ..utils import load_bootstrap_probe_data @@ -149,6 +157,25 @@ def _bootstrap_advice(data: dict[str, object]) -> list[str]: return advice +def _find_changelog_entry( + entries: tuple[ChangelogEntry, ...], version: str +) -> ChangelogEntry: + normalized = normalize_version(version) + for entry in entries: + if entry.version == normalized: + return entry + raise ChangelogError(f"未找到版本: {normalized}") + + +def _compact_changelog_entries( + entries: tuple[ChangelogEntry, ...], +) -> list[dict[str, object]]: + return [ + entry_to_dict(entry, include_summary=False, include_changes=False) + for entry in entries + ] + + @routes.get("/api/v1/management/probes/bootstrap") async def bootstrap_probe_handler(request: web.Request) -> Response: if not check_auth(request): @@ -187,6 +214,7 @@ async def capabilities_probe_handler(request: web.Request) -> Response: "sync_template": True, }, "logs": {"read": True, "stream": True}, + "changelog": {"read": True}, "bot": { "status": True, "start": True, @@ -202,6 +230,39 @@ async def capabilities_probe_handler(request: web.Request) -> Response: ) +@routes.get("/api/v1/management/changelog") +@routes.get("/api/changelog") +async def changelog_handler(request: web.Request) -> Response: + if not check_auth(request): + return web.json_response({"error": "Unauthorized"}, status=401) + requested_version = str(request.query.get("version") or "").strip() + try: + entries = list_entries() + current_version = normalize_version(__version__) + latest_version = entries[0].version + if requested_version: + selected_entry = _find_changelog_entry(entries, requested_version) + else: + try: + selected_entry = _find_changelog_entry(entries, current_version) + except ChangelogError: + selected_entry = entries[0] + return web.json_response( + { + "success": True, + "current_version": current_version, + "latest_version": latest_version, + "selected_version": selected_entry.version, + "versions": _compact_changelog_entries(entries), + "entry": entry_to_dict(selected_entry), + } + ) + except ChangelogFormatError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=400) + except (FileNotFoundError, ChangelogError) as exc: + return web.json_response({"success": False, "error": str(exc)}, status=404) + + @routes.get("/api/v1/management/system") @routes.get("/api/system") async def system_info_handler(request: web.Request) -> Response: diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index bd91f6d2..aed5932b 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -74,6 +74,16 @@ .progress { height: 8px; border-radius: 999px; background: var(--bg-deep); border: 1px solid var(--border-color); overflow: hidden; } .progress-bar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent-color), var(--accent-hover)); transition: width 0.4s ease; } +/* About */ +.about-changelog-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; } +.about-changelog-header h3 { margin: 0; } +.about-changelog-select { width: auto; min-width: 220px; max-width: 100%; } +.about-changelog-status { min-height: 18px; margin-bottom: 14px; } +.about-changelog-entry { border-top: 1px dashed var(--border-color); padding-top: 16px; } +.about-changelog-title { font-size: 18px; line-height: 1.35; margin: 0 0 12px; color: var(--text-primary); } +.about-changelog-summary { margin: 0 0 16px; color: var(--text-secondary); line-height: 1.7; white-space: pre-wrap; } +.about-changelog-list { margin: 0; padding-left: 20px; display: grid; gap: 8px; color: var(--text-primary); line-height: 1.6; } + /* Log Viewer */ .log-viewer { background: #1e1e1a; color: #e0dbd1; font-family: var(--font-mono); font-size: 13px; padding: 24px; border-radius: var(--radius-md); height: 600px; overflow-y: auto; white-space: pre-wrap; border: 1px solid var(--border-color); } #logLevelFilter { min-width: 120px; max-width: 100%; } diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index 1d60d0ba..7be72b9f 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -153,6 +153,16 @@ const I18N = { "probes.section_system": "系统信息", "probes.section_models": "模型配置", "probes.section_queues": "请求队列", + "probes.section_message_batcher": "消息合并器", + "probes.batcher_enabled": "启用", + "probes.batcher_window": "窗口", + "probes.batcher_strategy": "策略", + "probes.batcher_pending": "未发车桶数", + "probes.batcher_group": "群聊合并", + "probes.batcher_private": "私聊合并", + "probes.batcher_speculative": "投机预发送", + "probes.batcher_pre_send": "预发阈值", + "probes.batcher_phase": "状态", "probes.section_services": "服务状态", "probes.section_skills": "技能统计", "probes.version": "版本", @@ -165,8 +175,11 @@ const I18N = { "probes.memory_count": "记忆条数", "probes.cognitive": "认知服务", "probes.api_listen": "监听地址", - "probes.tools": "基础工具", + "probes.tools": "可调用工具", + "probes.toolsets": "工具集", "probes.agents": "智能体", + "probes.pipelines": "管线", + "probes.commands": "斜杠命令", "probes.all_ok": "所有外部端点均正常", "probes.some_failed": "部分外部端点异常", "memory.title": "记忆检索", @@ -273,6 +286,13 @@ const I18N = { "about.version": "版本", "about.license": "许可协议", "about.license_name": "MIT License", + "about.changelog": "版本更新", + "about.changelog_select": "选择版本", + "about.changelog_loading": "正在加载版本记录...", + "about.changelog_error": "版本记录加载失败", + "about.changelog_empty": "暂无变更点", + "about.current_version": "当前运行", + "about.latest_version": "最新记录", "config.aot_add": "+ 添加条目", "config.aot_remove": "移除", "update.restart": "更新并重启", @@ -454,6 +474,16 @@ const I18N = { "probes.section_system": "System Info", "probes.section_models": "Model Configuration", "probes.section_queues": "Request Queues", + "probes.section_message_batcher": "Message Batcher", + "probes.batcher_enabled": "Enabled", + "probes.batcher_window": "Window", + "probes.batcher_strategy": "Strategy", + "probes.batcher_pending": "Pending Buckets", + "probes.batcher_group": "Group", + "probes.batcher_private": "Private", + "probes.batcher_speculative": "Speculative Pre-fire", + "probes.batcher_pre_send": "Pre-fire Threshold", + "probes.batcher_phase": "Phase", "probes.section_services": "Service Status", "probes.section_skills": "Skill Statistics", "probes.version": "Version", @@ -466,8 +496,11 @@ const I18N = { "probes.memory_count": "Memory Count", "probes.cognitive": "Cognitive", "probes.api_listen": "Listen Addr", - "probes.tools": "Tools", + "probes.tools": "Callable Tools", + "probes.toolsets": "Toolsets", "probes.agents": "Agents", + "probes.pipelines": "Pipelines", + "probes.commands": "Slash Commands", "probes.all_ok": "All external endpoints are healthy", "probes.some_failed": "Some external endpoints are unhealthy", "memory.title": "Memory Hub", @@ -577,6 +610,13 @@ const I18N = { "about.version": "Version", "about.license": "License", "about.license_name": "MIT License", + "about.changelog": "Changelog", + "about.changelog_select": "Select version", + "about.changelog_loading": "Loading changelog...", + "about.changelog_error": "Failed to load changelog", + "about.changelog_empty": "No changes listed", + "about.current_version": "Current", + "about.latest_version": "Latest", "update.restart": "Update & Restart", "update.working": "Checking for updates...", "update.updated_restarting": "Updated. Restarting WebUI...", diff --git a/src/Undefined/webui/static/js/main.js b/src/Undefined/webui/static/js/main.js index 5745ef3d..a9b495ce 100644 --- a/src/Undefined/webui/static/js/main.js +++ b/src/Undefined/webui/static/js/main.js @@ -1,3 +1,116 @@ +const ABOUT_CHANGELOG_ENDPOINTS = [ + "/api/v1/management/changelog", + "/api/changelog", +]; + +let aboutChangelogPayload = null; +let aboutChangelogLoading = false; + +function setAboutChangelogStatus(message) { + const status = get("about-changelog-status"); + if (status) status.innerText = message || ""; +} + +function renderAboutChangelogEntry(entry) { + const container = get("about-changelog-entry"); + if (!container) return; + container.innerHTML = ""; + if (!entry) return; + + const title = document.createElement("h4"); + title.className = "about-changelog-title"; + title.textContent = `${entry.version || "--"} ${entry.title || ""}`.trim(); + container.appendChild(title); + + const summary = document.createElement("p"); + summary.className = "about-changelog-summary"; + summary.textContent = entry.summary || ""; + container.appendChild(summary); + + const changes = Array.isArray(entry.changes) ? entry.changes : []; + if (!changes.length) { + const empty = document.createElement("p"); + empty.className = "muted-sm"; + empty.textContent = t("about.changelog_empty"); + container.appendChild(empty); + return; + } + const list = document.createElement("ul"); + list.className = "about-changelog-list"; + changes.forEach((change) => { + const item = document.createElement("li"); + item.textContent = String(change || ""); + list.appendChild(item); + }); + container.appendChild(list); +} + +function renderAboutChangelog(payload) { + aboutChangelogPayload = payload; + const select = get("about-changelog-select"); + const versions = Array.isArray(payload?.versions) ? payload.versions : []; + if (select) { + select.replaceChildren(); + versions.forEach((item) => { + const option = document.createElement("option"); + option.value = item.version || ""; + option.textContent = + `${item.version || "--"} ${item.title || ""}`.trim(); + select.appendChild(option); + }); + select.value = + payload?.selected_version || payload?.entry?.version || ""; + select.disabled = versions.length === 0 || aboutChangelogLoading; + } + const current = payload?.current_version || "--"; + const latest = payload?.latest_version || "--"; + setAboutChangelogStatus( + `${t("about.current_version")}: ${current} · ${t("about.latest_version")}: ${latest}`, + ); + renderAboutChangelogEntry(payload?.entry || null); +} + +async function loadAboutChangelog(version = "") { + if (aboutChangelogLoading) return; + aboutChangelogLoading = true; + const select = get("about-changelog-select"); + if (select) select.disabled = true; + setAboutChangelogStatus(t("about.changelog_loading")); + try { + const suffix = version + ? `?version=${encodeURIComponent(String(version))}` + : ""; + const response = await api( + ABOUT_CHANGELOG_ENDPOINTS.map((endpoint) => `${endpoint}${suffix}`), + { signal: getAbortSignal("about-changelog") }, + ); + const payload = await response.json(); + if (!response.ok || payload?.success === false) { + throw new Error(payload?.error || t("about.changelog_error")); + } + renderAboutChangelog(payload); + } catch (error) { + if (error?.name === "AbortError") return; + const message = error instanceof Error ? error.message : String(error); + setAboutChangelogStatus(`${t("about.changelog_error")}: ${message}`); + renderAboutChangelogEntry(null); + } finally { + aboutChangelogLoading = false; + const latestSelect = get("about-changelog-select"); + if (latestSelect) + latestSelect.disabled = latestSelect.options.length === 0; + } +} + +function maybeLoadAboutChangelog() { + if (state.view !== "app" || state.tab !== "about" || !state.authenticated) { + return; + } + if (!aboutChangelogPayload && !aboutChangelogLoading) { + loadAboutChangelog(); + } +} + function refreshUI() { updateI18N(); get("view-landing").className = @@ -39,8 +152,10 @@ function refreshUI() { get("about-version-display").innerText = initialState.version; if (initialState && initialState.license) get("about-license-display").innerText = initialState.license; + if (aboutChangelogPayload) renderAboutChangelog(aboutChangelogPayload); updateAuthPanels(); + maybeLoadAboutChangelog(); if (state.view !== "app" || !state.authenticated) { stopSystemTimer(); @@ -100,6 +215,9 @@ function switchTab(tab) { ) { window.MemesController.onTabActivated(tab); } + if (tab === "about") { + maybeLoadAboutChangelog(); + } syncMobileChrome(); } @@ -553,6 +671,13 @@ async function init() { }; } + const aboutChangelogSelect = get("about-changelog-select"); + if (aboutChangelogSelect) { + aboutChangelogSelect.addEventListener("change", () => { + loadAboutChangelog(aboutChangelogSelect.value || ""); + }); + } + document.querySelectorAll(".log-tab").forEach((tab) => { tab.addEventListener("click", () => { setLogType(tab.dataset.logType || "bot"); diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index c1e1282d..c2f8c3f0 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -155,6 +155,64 @@ html += ``; } + // Message Batcher + const mb = data.message_batcher || {}; + if (mb.config) { + const cfg = mb.config || {}; + html += `
`; + html += `
${t("probes.section_message_batcher")}
`; + html += `
`; + html += probeItem( + t("probes.batcher_enabled"), + probeStatusBadge(cfg.enabled ? "ok" : "skipped"), + ); + html += probeItem( + t("probes.batcher_window"), + `${escapeHtml(String(cfg.window_seconds))}s`, + ); + html += probeItem( + t("probes.batcher_strategy"), + `${escapeHtml(cfg.strategy || "")}`, + ); + html += probeItem( + t("probes.batcher_pending"), + String(mb.pending_buckets ?? 0), + ); + html += probeItem( + t("probes.batcher_group"), + cfg.group_enabled ? "✓" : "✗", + ); + html += probeItem( + t("probes.batcher_private"), + cfg.private_enabled ? "✓" : "✗", + ); + html += probeItem( + t("probes.batcher_speculative"), + probeStatusBadge(cfg.speculative_enabled ? "ok" : "skipped"), + ); + if (cfg.speculative_enabled) { + html += probeItem( + t("probes.batcher_pre_send"), + `${escapeHtml(String(cfg.pre_send_seconds))}s`, + ); + } + html += `
`; + const buckets = Array.isArray(mb.buckets) ? mb.buckets : []; + if (buckets.length > 0) { + html += `
`; + for (const b of buckets.slice(0, 10)) { + const label = `${escapeHtml(String(b.scope || ""))}/${escapeHtml(String(b.sender_id || ""))}`; + const phase = b.phase + ? ` ${escapeHtml(String(b.phase))}` + : ""; + const inflight = b.has_inflight ? " ⚡" : ""; + html += `${label} ${b.count}×@${b.elapsed_seconds}s${phase}${inflight}`; + } + html += `
`; + } + html += `
`; + } + // Memory & Cognitive const mem = data.memory || {}; const cog = data.cognitive || {}; @@ -180,29 +238,31 @@ // Skills const sk = data.skills || {}; - if (sk.tools || sk.agents || sk.anthropic_skills) { + const skillRegs = [ + { key: "tools", label: t("probes.tools") }, + { key: "toolsets", label: t("probes.toolsets") }, + { key: "agents", label: t("probes.agents") }, + { key: "pipelines", label: t("probes.pipelines") }, + { key: "commands", label: t("probes.commands") }, + { key: "anthropic_skills", label: "Anthropic Skills" }, + ]; + if (skillRegs.some((reg) => sk[reg.key])) { html += `
`; html += `
${t("probes.section_skills")}
`; html += `
`; - if (sk.tools) - html += probeItem( - t("probes.tools"), - `${sk.tools.loaded ?? 0} / ${sk.tools.count ?? 0}`, - ); - if (sk.agents) - html += probeItem( - t("probes.agents"), - `${sk.agents.loaded ?? 0} / ${sk.agents.count ?? 0}`, - ); - if (sk.anthropic_skills) + for (const regMeta of skillRegs) { + const reg = sk[regMeta.key]; + if (!reg) continue; html += probeItem( - "Anthropic Skills", - `${sk.anthropic_skills.loaded ?? 0} / ${sk.anthropic_skills.count ?? 0}`, + regMeta.label, + `${reg.loaded ?? 0} / ${reg.count ?? 0}`, ); + } html += `
`; // Show active skills (ones with calls > 0) const activeItems = []; - for (const reg of [sk.tools, sk.agents, sk.anthropic_skills]) { + for (const { key } of skillRegs) { + const reg = sk[key]; if (reg && reg.items) { for (const item of reg.items) { if (item.calls > 0) activeItems.push(item); diff --git a/src/Undefined/webui/templates/index.html b/src/Undefined/webui/templates/index.html index a3067209..841ff956 100644 --- a/src/Undefined/webui/templates/index.html +++ b/src/Undefined/webui/templates/index.html @@ -808,6 +808,16 @@

作者

+
+
+

版本更新

+ + +
+
+
+
+

MIT License

 None:
+        self.messages: list[tuple[int, str, bool]] = []
+
+    async def send_group_message(
+        self,
+        group_id: int,
+        message: str,
+        mark_sent: bool = False,
+    ) -> None:
+        self.messages.append((group_id, message, mark_sent))
+
+
+def _build_context(
+    *,
+    config: Any,
+    onebot: Any,
+    sender: _DummySender,
+) -> CommandContext:
+    stub = cast(Any, SimpleNamespace())
+    return CommandContext(
+        group_id=12345,
+        sender_id=54321,
+        config=cast(Any, config),
+        sender=cast(Any, sender),
+        ai=stub,
+        faq_storage=stub,
+        onebot=cast(Any, onebot),
+        security=stub,
+        queue_manager=None,
+        rate_limiter=None,
+        dispatcher=stub,
+        registry=stub,
+    )
+
+
+@pytest.mark.asyncio
+async def test_admin_ls_outputs_names_without_qq_leakage() -> None:
+    sender = _DummySender()
+    onebot = SimpleNamespace(
+        get_group_member_list=AsyncMock(
+            return_value=[
+                {"user_id": 10001, "card": "超管群名片", "nickname": "超管昵称"},
+                {"user_id": 10002, "card": "", "nickname": "群管理员"},
+            ]
+        ),
+        get_stranger_info=AsyncMock(return_value={"nickname": "QQ管理员"}),
+    )
+    config = SimpleNamespace(superadmin_qq=10001, admin_qqs=[10001, 10002, 10003])
+    context = _build_context(config=config, onebot=onebot, sender=sender)
+
+    await execute([], context)
+
+    assert sender.messages
+    output = sender.messages[-1][1]
+    assert "👑 超级管理员: 超管群名片" in output
+    assert "- 群管理员" in output
+    assert "- QQ管理员" in output
+    assert "10001" not in output
+    assert "10002" not in output
+    assert "10003" not in output
+    onebot.get_group_member_list.assert_awaited_once_with(12345)
+    onebot.get_stranger_info.assert_awaited_once_with(10003)
+
+
+@pytest.mark.asyncio
+async def test_admin_ls_falls_back_to_unknown_name_without_exposing_qq() -> None:
+    sender = _DummySender()
+    onebot = SimpleNamespace(
+        get_group_member_list=AsyncMock(side_effect=RuntimeError("boom")),
+        get_stranger_info=AsyncMock(return_value={}),
+    )
+    config = SimpleNamespace(superadmin_qq=20001, admin_qqs=[20001, 20002])
+    context = _build_context(config=config, onebot=onebot, sender=sender)
+
+    await execute([], context)
+
+    assert sender.messages
+    output = sender.messages[-1][1]
+    assert "未知成员" in output
+    assert "20001" not in output
+    assert "20002" not in output
+    assert onebot.get_stranger_info.await_args_list == [call(20001), call(20002)]
+
+
+def test_admin_requires_admin_permission() -> None:
+    dispatcher = CommandDispatcher(
+        config=cast(Any, SimpleNamespace()),
+        sender=cast(Any, _DummySender()),
+        ai=cast(Any, SimpleNamespace()),
+        faq_storage=cast(Any, SimpleNamespace()),
+        onebot=cast(Any, SimpleNamespace()),
+        security=cast(Any, SimpleNamespace(rate_limiter=None)),
+    )
+
+    meta = dispatcher.command_registry.resolve("admin")
+
+    assert meta is not None
+    assert meta.permission == "admin"
+
+
+def test_admin_subcommands_require_superadmin() -> None:
+    """子命令 add/del 必须覆盖为 superadmin;ls 继承顶层 admin。"""
+    dispatcher = CommandDispatcher(
+        config=cast(Any, SimpleNamespace()),
+        sender=cast(Any, _DummySender()),
+        ai=cast(Any, SimpleNamespace()),
+        faq_storage=cast(Any, SimpleNamespace()),
+        onebot=cast(Any, SimpleNamespace()),
+        security=cast(Any, SimpleNamespace(rate_limiter=None)),
+    )
+
+    meta = dispatcher.command_registry.resolve("admin")
+    assert meta is not None
+    subs = getattr(meta, "subcommands", {}) or {}
+    assert subs["add"].permission == "superadmin"
+    assert subs["del"].permission == "superadmin"
+    # ls 不显式覆盖:使用顶层 admin
+    assert subs["ls"].permission == "admin"
+
+
+@pytest.mark.asyncio
+async def test_admin_add_success_persists_via_config() -> None:
+    sender = _DummySender()
+    add_admin_calls: list[int] = []
+
+    class _Config:
+        superadmin_qq = 10001
+        admin_qqs = [10001]
+
+        def is_admin(self, qq: int) -> bool:
+            return qq in self.admin_qqs
+
+        def is_superadmin(self, qq: int) -> bool:
+            return qq == self.superadmin_qq
+
+        def add_admin(self, qq: int) -> bool:
+            add_admin_calls.append(qq)
+            self.admin_qqs.append(qq)
+            return True
+
+        def remove_admin(self, qq: int) -> bool:  # pragma: no cover - 未触发
+            return False
+
+    config = _Config()
+    onebot = SimpleNamespace()
+    context = _build_context(config=config, onebot=onebot, sender=sender)
+
+    await execute(["add", "30001"], context)
+
+    assert add_admin_calls == [30001]
+    assert sender.messages
+    assert "已添加管理员: 30001" in sender.messages[-1][1]
+
+
+@pytest.mark.asyncio
+async def test_admin_add_rejects_duplicate_without_calling_config() -> None:
+    sender = _DummySender()
+    calls: list[int] = []
+
+    class _Config:
+        superadmin_qq = 10001
+        admin_qqs = [10001, 30001]
+
+        def is_admin(self, qq: int) -> bool:
+            return qq in self.admin_qqs
+
+        def is_superadmin(self, qq: int) -> bool:
+            return qq == self.superadmin_qq
+
+        def add_admin(self, qq: int) -> bool:  # pragma: no cover - 不应触发
+            calls.append(qq)
+            return False
+
+        def remove_admin(self, qq: int) -> bool:  # pragma: no cover - 不应触发
+            return False
+
+    config = _Config()
+    context = _build_context(config=config, onebot=SimpleNamespace(), sender=sender)
+
+    await execute(["add", "30001"], context)
+
+    assert calls == []
+    assert "已经是管理员" in sender.messages[-1][1]
+
+
+@pytest.mark.asyncio
+async def test_admin_add_rejects_non_numeric_qq() -> None:
+    sender = _DummySender()
+
+    class _Config:
+        superadmin_qq = 10001
+        admin_qqs = [10001]
+
+        def is_admin(self, qq: int) -> bool:  # pragma: no cover - 不应触发
+            return False
+
+        def is_superadmin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+        def add_admin(self, qq: int) -> bool:  # pragma: no cover - 不应触发
+            return False
+
+        def remove_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+    config = _Config()
+    context = _build_context(config=config, onebot=SimpleNamespace(), sender=sender)
+
+    await execute(["add", "abc"], context)
+
+    assert "QQ 号格式错误" in sender.messages[-1][1]
+
+
+@pytest.mark.asyncio
+async def test_admin_del_success_removes_admin() -> None:
+    sender = _DummySender()
+    removed: list[int] = []
+
+    class _Config:
+        superadmin_qq = 10001
+        admin_qqs = [10001, 30001]
+
+        def is_admin(self, qq: int) -> bool:
+            return qq in self.admin_qqs
+
+        def is_superadmin(self, qq: int) -> bool:
+            return qq == self.superadmin_qq
+
+        def add_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+        def remove_admin(self, qq: int) -> bool:
+            removed.append(qq)
+            self.admin_qqs.remove(qq)
+            return True
+
+    config = _Config()
+    context = _build_context(config=config, onebot=SimpleNamespace(), sender=sender)
+
+    await execute(["del", "30001"], context)
+
+    assert removed == [30001]
+    assert "已移除管理员: 30001" in sender.messages[-1][1]
+
+
+@pytest.mark.asyncio
+async def test_admin_del_refuses_to_remove_superadmin() -> None:
+    sender = _DummySender()
+    removed: list[int] = []
+
+    class _Config:
+        superadmin_qq = 10001
+        admin_qqs = [10001]
+
+        def is_admin(self, qq: int) -> bool:
+            return qq in self.admin_qqs
+
+        def is_superadmin(self, qq: int) -> bool:
+            return qq == self.superadmin_qq
+
+        def add_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+        def remove_admin(self, qq: int) -> bool:  # pragma: no cover - 不应触发
+            removed.append(qq)
+            return True
+
+    config = _Config()
+    context = _build_context(config=config, onebot=SimpleNamespace(), sender=sender)
+
+    await execute(["del", "10001"], context)
+
+    assert removed == []
+    assert "无法移除超级管理员" in sender.messages[-1][1]
+
+
+@pytest.mark.asyncio
+async def test_admin_del_rejects_non_admin_target() -> None:
+    sender = _DummySender()
+
+    class _Config:
+        superadmin_qq = 10001
+        admin_qqs = [10001]
+
+        def is_admin(self, qq: int) -> bool:
+            return qq in self.admin_qqs
+
+        def is_superadmin(self, qq: int) -> bool:
+            return qq == self.superadmin_qq
+
+        def add_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+        def remove_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+    config = _Config()
+    context = _build_context(config=config, onebot=SimpleNamespace(), sender=sender)
+
+    await execute(["del", "30001"], context)
+
+    assert "不是管理员" in sender.messages[-1][1]
+
+
+@pytest.mark.asyncio
+async def test_admin_unknown_subcommand_shows_usage() -> None:
+    sender = _DummySender()
+
+    class _Config:
+        superadmin_qq = 10001
+        admin_qqs = [10001]
+
+        def is_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+        def is_superadmin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+        def add_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+        def remove_admin(self, qq: int) -> bool:  # pragma: no cover
+            return False
+
+    config = _Config()
+    context = _build_context(config=config, onebot=SimpleNamespace(), sender=sender)
+
+    await execute(["foo"], context)
+
+    assert "用法:/admin" in sender.messages[-1][1]
diff --git a/tests/test_ai_client_tool_guard.py b/tests/test_ai_client_tool_guard.py
new file mode 100644
index 00000000..ec504fdc
--- /dev/null
+++ b/tests/test_ai_client_tool_guard.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from Undefined.ai.client import (
+    _INVALID_TOOL_CALL_CONTENT,
+    _build_invalid_tool_call_response,
+)
+
+
+def test_build_invalid_tool_call_response_keeps_call_id() -> None:
+    response = _build_invalid_tool_call_response(
+        {"id": "call_empty", "function": {"name": "", "arguments": "{}"}}
+    )
+
+    assert response == {
+        "role": "tool",
+        "tool_call_id": "call_empty",
+        "name": "",
+        "content": _INVALID_TOOL_CALL_CONTENT,
+    }
+
+
+def test_build_invalid_tool_call_response_handles_non_dict() -> None:
+    response = _build_invalid_tool_call_response("bad")
+
+    assert response["role"] == "tool"
+    assert response["tool_call_id"] == ""
+    assert response["name"] == ""
+    assert "工具名称为空或格式非法" in str(response["content"])
diff --git a/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py
index 2713a126..194e67c7 100644
--- a/tests/test_ai_coordinator_queue_routing.py
+++ b/tests/test_ai_coordinator_queue_routing.py
@@ -212,8 +212,9 @@ def test_build_prompt_limits_proactive_participation_to_technical_contexts() ->
 
     assert "群聊里的主动参与只保留给公开、开放的技术或项目讨论" in prompt
     assert "轻松互动、玩梗、吐槽本身不构成参与许可" in prompt
-    assert "对于已经决定要回复的场景" in prompt
-    assert "默认先尝试 memes.search_memes" in prompt
+    assert "只有明确纯表情包回复才先检索表情包" in prompt
+    assert "第一轮必须优先把必要文字回复做好并调用 send_message" in prompt
+    assert "默认先尝试 memes.search_memes" not in prompt
     assert "普通闲聊、玩梗、吐槽、轻松互动:" not in prompt
 
 
diff --git a/tests/test_command_qq_arg.py b/tests/test_command_qq_arg.py
index 658ef48a..f67eba5a 100644
--- a/tests/test_command_qq_arg.py
+++ b/tests/test_command_qq_arg.py
@@ -56,14 +56,14 @@ def test_split_command_args_keeps_at_name_with_spaces() -> None:
 
 def test_parse_command_strips_leading_bot_at() -> None:
     d = _dispatcher()
-    cmd = d.parse_command("[@123456(Bot)] /addadmin 7777777")
-    assert cmd == {"name": "addadmin", "args": ["7777777"]}
+    cmd = d.parse_command("[@123456(Bot)] /admin add 7777777")
+    assert cmd == {"name": "admin", "args": ["add", "7777777"]}
 
 
 def test_parse_command_keeps_inline_at_normalized() -> None:
     d = _dispatcher()
-    cmd = d.parse_command("[@123456(Bot)] /addadmin [@1708213363(Null)]")
-    assert cmd == {"name": "addadmin", "args": ["1708213363"]}
+    cmd = d.parse_command("[@123456(Bot)] /admin add [@1708213363(Null)]")
+    assert cmd == {"name": "admin", "args": ["add", "1708213363"]}
 
 
 def test_parse_command_keeps_inline_at_with_space_name_normalized() -> None:
diff --git a/tests/test_config_hot_reload.py b/tests/test_config_hot_reload.py
index 4a83d6cd..90da2eab 100644
--- a/tests/test_config_hot_reload.py
+++ b/tests/test_config_hot_reload.py
@@ -357,6 +357,31 @@ def test_apply_config_updates_runtime_model_config_without_rebuilding_core_model
     assert len(queue_manager.intervals) == 1
 
 
+def test_apply_config_updates_hot_reloads_missing_tool_call_retries() -> None:
+    updated = cast(
+        Any,
+        SimpleNamespace(
+            searxng_url="",
+            missing_tool_call_retries=4,
+        ),
+    )
+    ai_client = _FakeAIClient()
+    context = HotReloadContext(
+        ai_client=cast(Any, ai_client),
+        queue_manager=cast(Any, _FakeQueueManager()),
+        config_manager=cast(Any, SimpleNamespace()),
+        security_service=cast(Any, _FakeSecurityService()),
+    )
+
+    apply_config_updates(
+        updated,
+        {"missing_tool_call_retries": (3, 4)},
+        context,
+    )
+
+    assert ai_client.runtime_updates == [updated]
+
+
 def test_apply_config_updates_hot_reloads_attachment_config() -> None:
     updated = cast(
         Any,
@@ -414,7 +439,7 @@ def test_apply_config_updates_hot_reloads_attachment_config() -> None:
 
 
 @pytest.mark.asyncio
-async def test_apply_config_updates_refreshes_auto_pipeline_hot_reload() -> None:
+async def test_apply_config_updates_refreshes_pipelines_hot_reload() -> None:
     updated = cast(
         Any,
         SimpleNamespace(
diff --git a/tests/test_core_config.py b/tests/test_core_config.py
new file mode 100644
index 00000000..211ad516
--- /dev/null
+++ b/tests/test_core_config.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from Undefined.config.loader import Config
+
+
+_MINIMAL = """
+[onebot]
+ws_url = "ws://127.0.0.1:3001"
+[models.chat]
+api_url = "https://api.example/v1"
+api_key = "sk-test"
+model_name = "gpt-test"
+"""
+
+
+def _load(tmp_path: Path, extra: str = "") -> Config:
+    config_path = tmp_path / "config.toml"
+    config_path.write_text(_MINIMAL + extra, "utf-8")
+    return Config.load(config_path, strict=False)
+
+
+def test_missing_tool_call_retries_defaults_to_three(tmp_path: Path) -> None:
+    cfg = _load(tmp_path)
+    assert cfg.missing_tool_call_retries == 3
+
+
+def test_missing_tool_call_retries_clamps_negative(tmp_path: Path) -> None:
+    cfg = _load(tmp_path, "\n[core]\nmissing_tool_call_retries = -1\n")
+    assert cfg.missing_tool_call_retries == 0
+
+
+def test_missing_tool_call_retries_loads_explicit_value(tmp_path: Path) -> None:
+    cfg = _load(tmp_path, "\n[core]\nmissing_tool_call_retries = 5\n")
+    assert cfg.missing_tool_call_retries == 5
diff --git a/tests/test_end_tool.py b/tests/test_end_tool.py
index 4b65584a..92be8277 100644
--- a/tests/test_end_tool.py
+++ b/tests/test_end_tool.py
@@ -60,34 +60,6 @@ async def test_end_accepts_message_sent_flag_from_request_context_string_true()
     assert context["conversation_ended"] is True
 
 
-@pytest.mark.asyncio
-async def test_end_backward_compat_action_summary_param() -> None:
-    """向后兼容:旧参数名 action_summary 仍能正常工作。"""
-    context: dict[str, Any] = {"request_id": "req-compat-summary"}
-
-    result = await execute(
-        {"action_summary": "已发送消息", "force": True},
-        context,
-    )
-
-    assert result == "对话已结束"
-    assert context["conversation_ended"] is True
-
-
-@pytest.mark.asyncio
-async def test_end_backward_compat_new_info_param() -> None:
-    """向后兼容:旧参数名 new_info 仍能正常工作。"""
-    context: dict[str, Any] = {"request_id": "req-compat-new-info"}
-
-    result = await execute(
-        {"new_info": ["一条旧格式信息"], "force": True},
-        context,
-    )
-
-    assert result == "对话已结束"
-    assert context["conversation_ended"] is True
-
-
 class _FakeHistoryManager:
     def get_recent(
         self, chat_id: str, msg_type: str, start: int, end: int
@@ -122,6 +94,29 @@ async def enqueue_job(
         return "job-test"
 
 
+@pytest.mark.asyncio
+async def test_end_ignores_removed_legacy_param_names() -> None:
+    cognitive_service = _FakeCognitiveService()
+    context: dict[str, Any] = {
+        "request_id": "req-removed-compat",
+        "cognitive_service": cognitive_service,
+    }
+
+    result = await execute(
+        {
+            "action_summary": "旧字段不应写入 memo",
+            "summary": "旧摘要字段不应写入 memo",
+            "new_info": ["旧字段不应写入 observations"],
+            "force": True,
+        },
+        context,
+    )
+
+    assert result == "对话已结束"
+    assert context["conversation_ended"] is True
+    assert cognitive_service.last_context is None
+
+
 @pytest.mark.asyncio
 async def test_end_enriches_historian_reference_context() -> None:
     cognitive_service = _FakeCognitiveService()
@@ -158,6 +153,46 @@ async def test_end_enriches_historian_reference_context() -> None:
     assert cognitive_service.last_force is True
 
 
+@pytest.mark.asyncio
+async def test_end_historian_source_message_includes_batched_messages() -> None:
+    cognitive_service = _FakeCognitiveService()
+    context: dict[str, Any] = {
+        "request_id": "req-historian-batch",
+        "request_type": "group",
+        "group_id": "1082837821",
+        "user_id": "120218451",
+        "sender_id": "120218451",
+        "cognitive_service": cognitive_service,
+        "current_question": (
+            ''
+            "我周三要发版"
+            ''
+            "补充:是后端服务发版"
+            "\n\n 【连续消息说明】以上 2 条  共同构成【当前输入批次】"
+        ),
+    }
+
+    result = await execute(
+        {"observations": ["洛泫周三要进行后端服务发版"], "force": True},
+        context,
+    )
+
+    assert result == "对话已结束"
+    source = str(context.get("historian_source_message", ""))
+    assert "[1]" in source
+    assert "[2]" in source
+    assert "message_id=101" in source
+    assert "message_id=102" in source
+    assert "我周三要发版" in source
+    assert "补充:是后端服务发版" in source
+    assert cognitive_service.last_context is not None
+    assert cognitive_service.last_context.get("historian_source_message") == source
+
+
 class _ManyHistoryManager:
     def get_recent(
         self, chat_id: str, msg_type: str, start: int, end: int
diff --git a/tests/test_handlers_arxiv_auto_extract.py b/tests/test_handlers_arxiv_auto_extract.py
index a5c67ba3..226c6ea1 100644
--- a/tests/test_handlers_arxiv_auto_extract.py
+++ b/tests/test_handlers_arxiv_auto_extract.py
@@ -8,7 +8,7 @@
 
 import Undefined.handlers as handlers_module
 from Undefined.handlers import MessageHandler
-from Undefined.skills.auto_pipeline import AutoPipelineRegistry
+from Undefined.skills.pipelines import PipelineRegistry
 
 
 @pytest.mark.asyncio
@@ -50,8 +50,8 @@ async def test_private_message_runs_arxiv_auto_extract_before_ai_reply(
     handler._background_tasks = set()
     handler._extract_arxiv_ids = MagicMock(return_value=["2501.01234"])
     handler._handle_arxiv_extract = AsyncMock()
-    handler.auto_pipeline_registry = AutoPipelineRegistry()
-    handler.auto_pipeline_registry.load_items()
+    handler.pipeline_registry = PipelineRegistry()
+    handler.pipeline_registry.load_items()
     handler._spawn_background_task = MagicMock()
 
     event = {
diff --git a/tests/test_handlers_github_auto_extract.py b/tests/test_handlers_github_auto_extract.py
index e88f293d..6b1fb85f 100644
--- a/tests/test_handlers_github_auto_extract.py
+++ b/tests/test_handlers_github_auto_extract.py
@@ -8,7 +8,7 @@
 
 import Undefined.handlers as handlers_module
 from Undefined.handlers import MessageHandler
-from Undefined.skills.auto_pipeline import AutoPipelineRegistry
+from Undefined.skills.pipelines import PipelineRegistry
 
 
 @pytest.mark.asyncio
@@ -51,8 +51,8 @@ async def test_private_message_runs_github_auto_extract_before_ai_reply(
     handler._background_tasks = set()
     handler._extract_github_repo_ids = MagicMock(return_value=["69gg/Undefined"])
     handler._handle_github_extract = AsyncMock()
-    handler.auto_pipeline_registry = AutoPipelineRegistry()
-    handler.auto_pipeline_registry.load_items()
+    handler.pipeline_registry = PipelineRegistry()
+    handler.pipeline_registry.load_items()
     handler._spawn_background_task = MagicMock()
 
     event = {
diff --git a/tests/test_handlers_auto_extract_pipeline.py b/tests/test_handlers_pipelines.py
similarity index 88%
rename from tests/test_handlers_auto_extract_pipeline.py
rename to tests/test_handlers_pipelines.py
index 82e5fea0..380f29c4 100644
--- a/tests/test_handlers_auto_extract_pipeline.py
+++ b/tests/test_handlers_pipelines.py
@@ -9,12 +9,12 @@
 
 import Undefined.handlers as handlers_module
 from Undefined.handlers import MessageHandler
-from Undefined.skills.auto_pipeline import AutoPipelineRegistry
+from Undefined.skills.pipelines import PipelineRegistry
 
 
 @pytest.mark.asyncio
-async def test_message_handler_initializes_auto_pipeline_async() -> None:
-    class _FakeAutoPipelineRegistry:
+async def test_message_handler_initializes_pipelines_async() -> None:
+    class _FakePipelineRegistry:
         def __init__(self) -> None:
             self.load_count = 0
             self.started: list[tuple[float, float]] = []
@@ -26,30 +26,30 @@ async def load_items_async(self) -> None:
         def start_hot_reload(self, *, interval: float, debounce: float) -> None:
             self.started.append((interval, debounce))
 
-    registry = _FakeAutoPipelineRegistry()
+    registry = _FakePipelineRegistry()
     handler: Any = MessageHandler.__new__(MessageHandler)
     handler.config = SimpleNamespace(
         skills_hot_reload=True,
         skills_hot_reload_interval=3.0,
         skills_hot_reload_debounce=0.75,
     )
-    handler.auto_pipeline_registry = registry
-    handler._auto_pipeline_initialized = False
+    handler.pipeline_registry = registry
+    handler._pipelines_initialized = False
 
     await asyncio.gather(
-        handler.initialize_auto_pipeline(),
-        handler.initialize_auto_pipeline(),
+        handler.init_pipelines(),
+        handler.init_pipelines(),
     )
-    await handler.initialize_auto_pipeline()
+    await handler.init_pipelines()
 
     assert registry.load_count == 1
     assert registry.started == [(3.0, 0.75)]
-    assert handler._auto_pipeline_initialized is True
+    assert handler._pipelines_initialized is True
 
 
 @pytest.mark.asyncio
-async def test_auto_extract_pipeline_initializes_when_flag_missing() -> None:
-    class _FakeAutoPipelineRegistry:
+async def test_pipelines_initializes_when_flag_missing() -> None:
+    class _FakePipelineRegistry:
         def __init__(self) -> None:
             self.loaded = False
             self.run_context: dict[str, Any] | None = None
@@ -61,12 +61,12 @@ async def run(self, context: dict[str, Any]) -> list[object]:
             self.run_context = context
             return [object()] if self.loaded else []
 
-    registry = _FakeAutoPipelineRegistry()
+    registry = _FakePipelineRegistry()
     handler: Any = MessageHandler.__new__(MessageHandler)
     handler.config = SimpleNamespace(skills_hot_reload=False)
     handler.sender = SimpleNamespace()
     handler.onebot = SimpleNamespace()
-    handler.auto_pipeline_registry = registry
+    handler.pipeline_registry = registry
     handler._extract_bilibili_ids = AsyncMock(return_value=[])
     handler._extract_arxiv_ids = MagicMock(return_value=[])
     handler._extract_github_repo_ids = MagicMock(return_value=[])
@@ -74,7 +74,7 @@ async def run(self, context: dict[str, Any]) -> list[object]:
     handler._handle_arxiv_extract = AsyncMock()
     handler._handle_github_extract = AsyncMock()
 
-    handled = await handler._run_auto_extract_pipeline(
+    handled = await handler._run_pipelines(
         target_id=20001,
         target_type="private",
         text="hello",
@@ -84,11 +84,11 @@ async def run(self, context: dict[str, Any]) -> list[object]:
     assert handled is True
     assert registry.loaded is True
     assert registry.run_context is not None
-    assert handler._auto_pipeline_initialized is True
+    assert handler._pipelines_initialized is True
 
 
 @pytest.mark.asyncio
-async def test_auto_extract_pipeline_processes_all_matches() -> None:
+async def test_pipelines_processes_all_matches() -> None:
     handler: Any = MessageHandler.__new__(MessageHandler)
     handler.sender = SimpleNamespace()
     handler.onebot = SimpleNamespace()
@@ -106,10 +106,10 @@ async def test_auto_extract_pipeline_processes_all_matches() -> None:
     handler._handle_bilibili_extract = AsyncMock()
     handler._handle_arxiv_extract = AsyncMock()
     handler._handle_github_extract = AsyncMock()
-    handler.auto_pipeline_registry = AutoPipelineRegistry()
-    handler.auto_pipeline_registry.load_items()
+    handler.pipeline_registry = PipelineRegistry()
+    handler.pipeline_registry.load_items()
 
-    handled = await handler._run_auto_extract_pipeline(
+    handled = await handler._run_pipelines(
         target_id=20001,
         target_type="private",
         text="BV1xx411c7mD 69gg/Undefined",
@@ -135,7 +135,7 @@ async def test_auto_extract_pipeline_processes_all_matches() -> None:
 
 
 @pytest.mark.asyncio
-async def test_private_command_skips_auto_pipeline_and_ai(
+async def test_private_command_skips_pipelines_and_ai(
     monkeypatch: pytest.MonkeyPatch,
 ) -> None:
     monkeypatch.setattr(
@@ -169,7 +169,7 @@ async def test_private_command_skips_auto_pipeline_and_ai(
         parse_command=MagicMock(return_value=command),
         dispatch_private=AsyncMock(),
     )
-    handler.auto_pipeline_registry = SimpleNamespace(
+    handler.pipeline_registry = SimpleNamespace(
         run=AsyncMock(return_value=[]),
     )
     handler._background_tasks = set()
@@ -195,7 +195,7 @@ async def test_private_command_skips_auto_pipeline_and_ai(
     assert handler.history_manager.add_private_message.await_args is not None
     private_history = handler.history_manager.add_private_message.await_args.kwargs
     assert private_history["text_content"] == "/help"
-    handler.auto_pipeline_registry.run.assert_not_awaited()
+    handler.pipeline_registry.run.assert_not_awaited()
     handler.ai_coordinator.model_pool.handle_private_message.assert_not_awaited()
     handler.ai_coordinator.handle_private_reply.assert_not_awaited()
 
@@ -233,7 +233,7 @@ async def test_private_model_pool_command_runs_before_command_dispatch(
         parse_command=MagicMock(return_value=command),
         dispatch_private=AsyncMock(),
     )
-    handler.auto_pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[]))
+    handler.pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[]))
     handler._background_tasks = set()
     handler._profile_name_refresh_cache = {}
     handler._collect_message_attachments = AsyncMock(return_value=[])
@@ -263,7 +263,7 @@ async def test_private_model_pool_command_runs_before_command_dispatch(
     )
     handler.command_dispatcher.parse_command.assert_not_called()
     handler.command_dispatcher.dispatch_private.assert_not_awaited()
-    handler.auto_pipeline_registry.run.assert_not_awaited()
+    handler.pipeline_registry.run.assert_not_awaited()
     handler.ai_coordinator.handle_private_reply.assert_not_awaited()
 
 
@@ -300,8 +300,8 @@ async def test_private_message_starting_with_select_does_not_touch_model_pool(
         parse_command=MagicMock(return_value=None),
         dispatch_private=AsyncMock(),
     )
-    handler.auto_pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[]))
-    handler._auto_pipeline_initialized = True
+    handler.pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[]))
+    handler._pipelines_initialized = True
     handler._background_tasks = set()
     handler._profile_name_refresh_cache = {}
     handler._collect_message_attachments = AsyncMock(return_value=[])
@@ -326,7 +326,7 @@ async def test_private_message_starting_with_select_does_not_touch_model_pool(
     await handler.handle_message(event)
 
     handler.ai_coordinator.model_pool.handle_private_message.assert_not_awaited()
-    handler.auto_pipeline_registry.run.assert_awaited_once()
+    handler.pipeline_registry.run.assert_awaited_once()
     handler.ai_coordinator.handle_private_reply.assert_awaited_once()
 
 
@@ -364,8 +364,8 @@ async def test_private_model_pool_command_ignored_when_pool_disabled(
         parse_command=MagicMock(return_value=None),
         dispatch_private=AsyncMock(),
     )
-    handler.auto_pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[]))
-    handler._auto_pipeline_initialized = True
+    handler.pipeline_registry = SimpleNamespace(run=AsyncMock(return_value=[]))
+    handler._pipelines_initialized = True
     handler._background_tasks = set()
     handler._profile_name_refresh_cache = {}
     handler._collect_message_attachments = AsyncMock(return_value=[])
@@ -391,12 +391,12 @@ async def test_private_model_pool_command_ignored_when_pool_disabled(
 
     handler.ai_coordinator.model_pool.handle_private_message.assert_not_awaited()
     handler.command_dispatcher.parse_command.assert_called_once_with("/compare hello")
-    handler.auto_pipeline_registry.run.assert_awaited_once()
+    handler.pipeline_registry.run.assert_awaited_once()
     handler.ai_coordinator.handle_private_reply.assert_awaited_once()
 
 
 @pytest.mark.asyncio
-async def test_group_command_skips_auto_pipeline_and_ai(
+async def test_group_command_skips_pipelines_and_ai(
     monkeypatch: pytest.MonkeyPatch,
 ) -> None:
     monkeypatch.setattr(
@@ -429,7 +429,7 @@ async def test_group_command_skips_auto_pipeline_and_ai(
         parse_command=MagicMock(return_value=command),
         dispatch=AsyncMock(),
     )
-    handler.auto_pipeline_registry = SimpleNamespace(
+    handler.pipeline_registry = SimpleNamespace(
         run=AsyncMock(return_value=[]),
     )
     handler._schedule_profile_display_name_refresh = MagicMock()
@@ -463,5 +463,5 @@ async def test_group_command_skips_auto_pipeline_and_ai(
     assert handler.history_manager.add_group_message.await_args is not None
     group_history = handler.history_manager.add_group_message.await_args.kwargs
     assert group_history["text_content"] == "/help"
-    handler.auto_pipeline_registry.run.assert_not_awaited()
+    handler.pipeline_registry.run.assert_not_awaited()
     handler.ai_coordinator.handle_auto_reply.assert_not_awaited()
diff --git a/tests/test_handlers_repeat.py b/tests/test_handlers_repeat.py
index 55207e69..d649c981 100644
--- a/tests/test_handlers_repeat.py
+++ b/tests/test_handlers_repeat.py
@@ -47,10 +47,10 @@ def _build_handler(
         send_group_message=AsyncMock(),
         send_private_message=AsyncMock(),
     )
-    handler.auto_pipeline_registry = SimpleNamespace(
+    handler.pipeline_registry = SimpleNamespace(
         run=AsyncMock(return_value=[]),
     )
-    handler._auto_pipeline_initialized = True
+    handler._pipelines_initialized = True
     handler.ai_coordinator = SimpleNamespace(
         handle_auto_reply=AsyncMock(),
         handle_private_reply=AsyncMock(),
@@ -126,7 +126,7 @@ async def test_repeat_triggers_on_3_identical_from_different_senders() -> None:
     for uid in [20001, 20002]:
         await handler.handle_message(_group_event(sender_id=uid, text="hello"))
 
-    handler.auto_pipeline_registry.run.reset_mock()
+    handler.pipeline_registry.run.reset_mock()
     handler.ai_coordinator.handle_auto_reply.reset_mock()
     await handler.handle_message(_group_event(sender_id=20003, text="hello"))
 
@@ -135,7 +135,7 @@ async def test_repeat_triggers_on_3_identical_from_different_senders() -> None:
     assert call.args[0] == 30001
     assert call.args[1] == "hello"
     assert call.kwargs.get("history_prefix") == REPEAT_REPLY_HISTORY_PREFIX
-    handler.auto_pipeline_registry.run.assert_not_called()
+    handler.pipeline_registry.run.assert_not_called()
     handler.ai_coordinator.handle_auto_reply.assert_not_called()
     handler._bot_nickname_cache.get_nicknames.assert_not_called()
 
diff --git a/tests/test_llm_retry_suppression.py b/tests/test_llm_retry_suppression.py
index 197b9b60..2012a693 100644
--- a/tests/test_llm_retry_suppression.py
+++ b/tests/test_llm_retry_suppression.py
@@ -79,7 +79,22 @@ async def test_ai_ask_retries_pre_tool_local_failure() -> None:
             end_summaries=[],
         ),
     )
-    client.tool_manager = cast(Any, SimpleNamespace(get_openai_tools=lambda: []))
+
+    async def _execute_tool(
+        name: str, args: dict[str, Any], ctx: dict[str, Any]
+    ) -> str:
+        if name == "end":
+            ctx["conversation_ended"] = True
+            return "对话已结束"
+        return "ok"
+
+    client.tool_manager = cast(
+        Any,
+        SimpleNamespace(
+            get_openai_tools=lambda: [],
+            execute_tool=_execute_tool,
+        ),
+    )
     client._filter_tools_for_runtime_config = lambda tools: tools
     client._get_runtime_config = cast(Any, lambda: client.runtime_config)
     client.model_selector = cast(Any, SimpleNamespace(wait_ready=AsyncMock()))
@@ -93,7 +108,24 @@ async def test_ai_ask_retries_pre_tool_local_failure() -> None:
     client.submit_queued_llm_call = AsyncMock(
         side_effect=[
             {"choices": []},
-            {"choices": [{"message": {"content": "ok"}}]},
+            {
+                "choices": [
+                    {
+                        "message": {
+                            "content": "",
+                            "tool_calls": [
+                                {
+                                    "id": "call_end",
+                                    "function": {
+                                        "name": "end",
+                                        "arguments": "{}",
+                                    },
+                                }
+                            ],
+                        }
+                    }
+                ],
+            },
         ]
     )
     client._search_wrapper = None
@@ -112,10 +144,76 @@ async def test_ai_ask_retries_pre_tool_local_failure() -> None:
 
     result = await AIClient.ask(client, "hello")
 
-    assert result == "ok"
+    assert result == ""
     assert cast(AsyncMock, client.submit_queued_llm_call).await_count == 2
 
 
+@pytest.mark.asyncio
+async def test_ai_ask_limits_missing_tool_call_retries() -> None:
+    client: Any = object.__new__(AIClient)
+    client.runtime_config = cast(
+        Any,
+        SimpleNamespace(
+            log_thinking=False,
+            ai_request_max_retries=0,
+            missing_tool_call_retries=2,
+        ),
+    )
+    client._prompt_builder = cast(
+        Any,
+        SimpleNamespace(
+            build_messages=AsyncMock(
+                return_value=[{"role": "user", "content": "hello"}]
+            ),
+            end_summaries=[],
+        ),
+    )
+    client.tool_manager = cast(
+        Any,
+        SimpleNamespace(
+            get_openai_tools=lambda: [],
+            execute_tool=AsyncMock(),
+        ),
+    )
+    client._filter_tools_for_runtime_config = lambda tools: tools
+    client._get_runtime_config = cast(Any, lambda: client.runtime_config)
+    client.model_selector = cast(Any, SimpleNamespace(wait_ready=AsyncMock()))
+    client.chat_config = ChatModelConfig(
+        api_url="https://api.openai.com/v1",
+        api_key="sk-test",
+        model_name="chat-model",
+        max_tokens=1024,
+    )
+    client._find_chat_config_by_name = lambda _name: client.chat_config
+    client.submit_queued_llm_call = AsyncMock(
+        side_effect=[
+            {"choices": [{"message": {"content": "plain 1", "tool_calls": []}}]},
+            {"choices": [{"message": {"content": "plain 2", "tool_calls": []}}]},
+            {"choices": [{"message": {"content": "plain 3", "tool_calls": []}}]},
+        ]
+    )
+    client._search_wrapper = None
+    client._end_summary_storage = cast(Any, None)
+    client._send_private_message_callback = None
+    client._send_image_callback = None
+    client.memory_storage = None
+    client._knowledge_manager = None
+    client._cognitive_service = None
+    client._meme_service = None
+    client._crawl4ai_capabilities = SimpleNamespace(
+        available=False,
+        error=None,
+        proxy_config_available=False,
+    )
+    send_message = AsyncMock()
+
+    result = await AIClient.ask(client, "hello", send_message_callback=send_message)
+
+    assert result == ""
+    assert cast(AsyncMock, client.submit_queued_llm_call).await_count == 3
+    send_message.assert_awaited_once_with("plain 3")
+
+
 @pytest.mark.asyncio
 async def test_agent_runner_reraises_queued_llm_error(tmp_path: Path) -> None:
     agent_dir = tmp_path / "demo_agent"
diff --git a/tests/test_lsadmin_command.py b/tests/test_lsadmin_command.py
deleted file mode 100644
index 169c6b9e..00000000
--- a/tests/test_lsadmin_command.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-from typing import Any, cast
-from unittest.mock import AsyncMock, call
-
-import pytest
-
-from Undefined.services.command import CommandDispatcher
-from Undefined.services.commands.context import CommandContext
-from Undefined.skills.commands.lsadmin.handler import execute
-
-
-class _DummySender:
-    def __init__(self) -> None:
-        self.messages: list[tuple[int, str, bool]] = []
-
-    async def send_group_message(
-        self,
-        group_id: int,
-        message: str,
-        mark_sent: bool = False,
-    ) -> None:
-        self.messages.append((group_id, message, mark_sent))
-
-
-def _build_context(
-    *,
-    config: Any,
-    onebot: Any,
-    sender: _DummySender,
-) -> CommandContext:
-    stub = cast(Any, SimpleNamespace())
-    return CommandContext(
-        group_id=12345,
-        sender_id=54321,
-        config=cast(Any, config),
-        sender=cast(Any, sender),
-        ai=stub,
-        faq_storage=stub,
-        onebot=cast(Any, onebot),
-        security=stub,
-        queue_manager=None,
-        rate_limiter=None,
-        dispatcher=stub,
-        registry=stub,
-    )
-
-
-@pytest.mark.asyncio
-async def test_lsadmin_outputs_names_without_qq_leakage() -> None:
-    sender = _DummySender()
-    onebot = SimpleNamespace(
-        get_group_member_list=AsyncMock(
-            return_value=[
-                {"user_id": 10001, "card": "超管群名片", "nickname": "超管昵称"},
-                {"user_id": 10002, "card": "", "nickname": "群管理员"},
-            ]
-        ),
-        get_stranger_info=AsyncMock(return_value={"nickname": "QQ管理员"}),
-    )
-    config = SimpleNamespace(superadmin_qq=10001, admin_qqs=[10001, 10002, 10003])
-    context = _build_context(config=config, onebot=onebot, sender=sender)
-
-    await execute([], context)
-
-    assert sender.messages
-    output = sender.messages[-1][1]
-    assert "👑 超级管理员: 超管群名片" in output
-    assert "- 群管理员" in output
-    assert "- QQ管理员" in output
-    assert "10001" not in output
-    assert "10002" not in output
-    assert "10003" not in output
-    onebot.get_group_member_list.assert_awaited_once_with(12345)
-    onebot.get_stranger_info.assert_awaited_once_with(10003)
-
-
-@pytest.mark.asyncio
-async def test_lsadmin_falls_back_to_unknown_name_without_exposing_qq() -> None:
-    sender = _DummySender()
-    onebot = SimpleNamespace(
-        get_group_member_list=AsyncMock(side_effect=RuntimeError("boom")),
-        get_stranger_info=AsyncMock(return_value={}),
-    )
-    config = SimpleNamespace(superadmin_qq=20001, admin_qqs=[20001, 20002])
-    context = _build_context(config=config, onebot=onebot, sender=sender)
-
-    await execute([], context)
-
-    assert sender.messages
-    output = sender.messages[-1][1]
-    assert "未知成员" in output
-    assert "20001" not in output
-    assert "20002" not in output
-    assert onebot.get_stranger_info.await_args_list == [call(20001), call(20002)]
-
-
-def test_lsadmin_requires_admin_permission() -> None:
-    dispatcher = CommandDispatcher(
-        config=cast(Any, SimpleNamespace()),
-        sender=cast(Any, _DummySender()),
-        ai=cast(Any, SimpleNamespace()),
-        faq_storage=cast(Any, SimpleNamespace()),
-        onebot=cast(Any, SimpleNamespace()),
-        security=cast(Any, SimpleNamespace(rate_limiter=None)),
-    )
-
-    meta = dispatcher.command_registry.resolve("lsadmin")
-
-    assert meta is not None
-    assert meta.permission == "admin"
diff --git a/tests/test_message_batcher.py b/tests/test_message_batcher.py
new file mode 100644
index 00000000..09048d0f
--- /dev/null
+++ b/tests/test_message_batcher.py
@@ -0,0 +1,796 @@
+"""MessageBatcher 单元测试。"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+
+import pytest
+
+from Undefined.config.models import MessageBatcherConfig
+from Undefined.services.message_batcher import (
+    BatchDispatchToken,
+    BufferedMessage,
+    MessageBatcher,
+    make_scope,
+)
+
+
+def _make_item(
+    *,
+    scope: str = "group:1",
+    sender_id: int = 100,
+    text: str = "hi",
+    is_private: bool = False,
+    is_poke: bool = False,
+    is_at_bot: bool = False,
+    sender_name: str = "test",
+) -> BufferedMessage:
+    return BufferedMessage(
+        scope=scope,
+        sender_id=sender_id,
+        text=text,
+        message_content=[{"type": "text", "data": {"text": text}}],
+        attachments=[],
+        sender_name=sender_name,
+        arrival_time=time.time(),
+        is_private=is_private,
+        trigger_message_id=1,
+        is_poke=is_poke,
+        is_at_bot=is_at_bot,
+        group_id=None if is_private else 1,
+    )
+
+
+class _Recorder:
+    def __init__(self) -> None:
+        self.batches: list[list[BufferedMessage]] = []
+        self.event = asyncio.Event()
+
+    async def __call__(self, items: list[BufferedMessage]) -> None:
+        self.batches.append(items)
+        self.event.set()
+
+
+def test_make_scope() -> None:
+    assert make_scope(group_id=10) == "group:10"
+    assert make_scope(user_id=5) == "private:5"
+    assert make_scope() == "unknown"
+
+
+@pytest.mark.asyncio
+async def test_consecutive_same_sender_merge() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.1, strategy="extend")
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="msg1"))
+    await batcher.submit(_make_item(text="msg2"))
+    await batcher.submit(_make_item(text="msg3"))
+
+    await asyncio.wait_for(rec.event.wait(), timeout=1.0)
+    assert len(rec.batches) == 1
+    assert [m.text for m in rec.batches[0]] == ["msg1", "msg2", "msg3"]
+
+
+@pytest.mark.asyncio
+async def test_different_senders_isolated() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.1)
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(sender_id=1, text="a"))
+    await batcher.submit(_make_item(sender_id=2, text="b"))
+
+    await asyncio.sleep(0.3)
+    assert len(rec.batches) == 2
+    flat = sorted([b[0].sender_id for b in rec.batches])
+    assert flat == [1, 2]
+
+
+@pytest.mark.asyncio
+async def test_max_messages_immediate_flush() -> None:
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=10.0,
+        max_messages_per_batch=2,
+    )
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="x"))
+    await batcher.submit(_make_item(text="y"))
+
+    # 立即发车,不需要等窗口
+    assert len(rec.batches) == 1
+    assert len(rec.batches[0]) == 2
+
+
+@pytest.mark.asyncio
+async def test_max_window_hard_cap() -> None:
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.05,
+        strategy="extend",
+        max_window_seconds=0.15,
+    )
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    # 持续提交,extend 应被 max_window 硬顶
+    for _ in range(10):
+        await batcher.submit(_make_item(text="x"))
+        await asyncio.sleep(0.03)
+
+    await asyncio.sleep(0.3)
+    # 至少触发过一次 flush
+    assert len(rec.batches) >= 1
+
+
+@pytest.mark.asyncio
+async def test_disabled_means_caller_should_bypass() -> None:
+    cfg = MessageBatcherConfig(enabled=False, window_seconds=0.1)
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    assert batcher.is_enabled_for(is_group=True) is False
+    assert batcher.is_enabled_for(is_group=False) is False
+
+
+@pytest.mark.asyncio
+async def test_group_only_disabled() -> None:
+    cfg = MessageBatcherConfig(
+        enabled=True, window_seconds=0.1, group_enabled=False, private_enabled=True
+    )
+    batcher = MessageBatcher(cfg, lambda items: asyncio.sleep(0))
+    assert batcher.is_enabled_for(is_group=True) is False
+    assert batcher.is_enabled_for(is_group=False) is True
+
+
+@pytest.mark.asyncio
+async def test_has_buffer() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=10.0)
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    assert not batcher.has_buffer("group:1", 100)
+    await batcher.submit(_make_item())
+    assert batcher.has_buffer("group:1", 100)
+
+
+@pytest.mark.asyncio
+async def test_flush_all_on_shutdown() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=10.0)
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(sender_id=1, text="a"))
+    await batcher.submit(_make_item(sender_id=2, text="b"))
+    assert len(rec.batches) == 0
+
+    await batcher.flush_all()
+    assert len(rec.batches) == 2
+
+
+@pytest.mark.asyncio
+async def test_extend_resets_timer() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.15, strategy="extend")
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="a"))
+    await asyncio.sleep(0.10)
+    await batcher.submit(_make_item(text="b"))
+    await asyncio.sleep(0.10)
+    # 这个时间点本来 a 已经超过初始 0.15s 窗口;若 extend 重置则 b 还在等
+    assert len(rec.batches) == 0
+    await asyncio.sleep(0.20)
+    assert len(rec.batches) == 1
+
+
+@pytest.mark.asyncio
+async def test_fixed_does_not_reset_timer() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.15, strategy="fixed")
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="a"))
+    await asyncio.sleep(0.05)
+    await batcher.submit(_make_item(text="b"))
+    # fixed 策略下定时器从首条算起,大约 0.15s 后 flush
+    await asyncio.sleep(0.20)
+    assert len(rec.batches) == 1
+    assert len(rec.batches[0]) == 2
+
+
+@pytest.mark.asyncio
+async def test_update_config_runtime() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.1)
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    new_cfg = MessageBatcherConfig(enabled=False, window_seconds=0.5)
+    batcher.update_config(new_cfg)
+    assert batcher.config.enabled is False
+    assert batcher.is_enabled_for(is_group=True) is False
+
+
+@pytest.mark.asyncio
+async def test_callback_exception_does_not_break_batcher() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.05)
+
+    calls: list[int] = []
+
+    async def bad_callback(items: list[BufferedMessage]) -> None:
+        calls.append(len(items))
+        raise RuntimeError("boom")
+
+    batcher = MessageBatcher(cfg, bad_callback)
+    await batcher.submit(_make_item(text="a"))
+    await asyncio.sleep(0.2)
+    assert calls == [1, 1]
+    assert batcher.has_buffer("group:1", 100)
+
+    # 应能继续接受新消息
+    await batcher.submit(_make_item(text="b"))
+    await asyncio.sleep(0.2)
+    assert calls == [1, 1, 2]
+    assert batcher.has_buffer("group:1", 100)
+
+
+@pytest.mark.asyncio
+async def test_timer_task_strong_reference_survives_gc() -> None:
+    """timer 触发后创建的 flush task 必须被强引用,避免被 GC 回收。
+
+    asyncio 文档明确警告 ``create_task`` 返回值若不被保留,可能在执行前被 GC。
+    """
+    import gc
+
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.05, strategy="extend")
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="x"))
+    # 在 timer 触发后但 callback 未必完成时强制 GC
+    await asyncio.sleep(0.06)
+    gc.collect()
+    await asyncio.wait_for(rec.event.wait(), timeout=1.0)
+    assert len(rec.batches) == 1
+
+
+@pytest.mark.asyncio
+async def test_flush_all_awaits_in_flight_tasks() -> None:
+    """flush_all 应等待 timer 触发但 callback 仍在执行的 task 收尾。"""
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.05)
+    finished: list[bool] = []
+    started = asyncio.Event()
+
+    async def slow_callback(items: list[BufferedMessage]) -> None:
+        started.set()
+        await asyncio.sleep(0.15)
+        finished.append(True)
+
+    batcher = MessageBatcher(cfg, slow_callback)
+    await batcher.submit(_make_item(text="x"))
+    # 等 timer 触发并进入 callback
+    await asyncio.wait_for(started.wait(), timeout=1.0)
+    # callback 仍在 sleep 中调 flush_all 应阻塞直到完成
+    await batcher.flush_all()
+    assert finished == [True]
+
+
+@pytest.mark.asyncio
+async def test_max_window_seconds_zero_means_unlimited() -> None:
+    """max_window_seconds=0 表示不限制硬顶,只要 extend 持续刷新就一直等。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.05,
+        strategy="extend",
+        max_window_seconds=0.0,
+    )
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    # 连续 6 次提交,每次间隔 30ms(< window_seconds),如果 max_window 仍生效会被强行 flush
+    for i in range(6):
+        await batcher.submit(_make_item(text=f"m{i}"))
+        await asyncio.sleep(0.03)
+    # 此时距首条已 ~180ms(远超旧 max_window 的虚假"硬顶",但 0=不限),仍应在 buffer 中
+    assert rec.batches == []
+    # 停止追加,让 timer 自然到期
+    await asyncio.sleep(0.1)
+    assert len(rec.batches) == 1
+    assert len(rec.batches[0]) == 6
+
+
+# ---------------------------------------------------------------------------
+# 投机预发送(speculative pre-fire)测试
+# ---------------------------------------------------------------------------
+
+
+class _FakeRequestContext:
+    """模拟 RequestContext,仅暴露 get_resource。"""
+
+    def __init__(self) -> None:
+        self._resources: dict[str, object] = {}
+
+    def set_resource(self, key: str, value: object) -> None:
+        self._resources[key] = value
+
+    def get_resource(self, key: str, default: object = None) -> object:
+        return self._resources.get(key, default)
+
+
+@pytest.mark.asyncio
+async def test_speculative_prefire_fires_at_t2_but_batch_continues() -> None:
+    """T2 < T1:T2 到期先发车,items 不弹出;T1 之前再来消息会取消投机。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.3,
+        pre_send_seconds=0.1,
+        strategy="extend",
+    )
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="m1"))
+    # 等待 T2 触发(~100ms)但远未到 T1(300ms)
+    await asyncio.sleep(0.18)
+    assert len(rec.batches) == 1, "T2 应已 pre-fire"
+    # 桶仍存在
+    assert batcher.has_buffer("group:1", 100)
+
+
+@pytest.mark.asyncio
+async def test_t1_after_speculative_prefire_does_not_dispatch_twice() -> None:
+    """T2 已经投机发车后,T1 只结束 batch,不能再次调用 callback。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.12,
+        pre_send_seconds=0.03,
+        strategy="extend",
+    )
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.wait_for(rec.event.wait(), timeout=0.5)
+    await asyncio.sleep(0.18)
+
+    assert len(rec.batches) == 1
+    assert not batcher.has_buffer("group:1", 100)
+
+
+@pytest.mark.asyncio
+async def test_speculative_cancelled_when_new_message_and_no_send() -> None:
+    """投机调用尚未发出消息时,新消息到达应取消 inflight 并把它合进新一轮。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.3,
+        pre_send_seconds=0.05,
+        strategy="extend",
+    )
+
+    cancelled = asyncio.Event()
+    fake_ctx = _FakeRequestContext()  # 默认 message_sent_this_turn=False
+
+    async def slow_flush(items: list[BufferedMessage]) -> None:
+        try:
+            await asyncio.sleep(2.0)
+        except asyncio.CancelledError:
+            cancelled.set()
+            raise
+
+    batcher = MessageBatcher(cfg, slow_flush)
+
+    await batcher.submit(_make_item(text="m1"))
+    # 等待 T2 触发
+    await asyncio.sleep(0.1)
+    # 模拟 coordinator 上报 inflight
+    inflight_task = next(iter(batcher._pending_tasks))
+    batcher.register_inflight("group:1", 100, inflight_task, fake_ctx)
+    # 第二条消息到达,应取消 inflight
+    await batcher.submit(_make_item(text="m2"))
+    await asyncio.wait_for(cancelled.wait(), timeout=1.0)
+
+
+@pytest.mark.asyncio
+async def test_speculative_not_cancelled_when_already_sent_default() -> None:
+    """已经发过消息时默认不取消 inflight,新消息开新 batch。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.3,
+        pre_send_seconds=0.05,
+        strategy="extend",
+        allow_cancel_after_send=False,
+    )
+
+    fake_ctx = _FakeRequestContext()
+    fake_ctx.set_resource("message_sent_this_turn", True)
+
+    finished = asyncio.Event()
+
+    async def flush(items: list[BufferedMessage]) -> None:
+        try:
+            await asyncio.sleep(0.1)
+        finally:
+            finished.set()
+
+    batcher = MessageBatcher(cfg, flush)
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.sleep(0.08)
+    inflight_task = next(iter(batcher._pending_tasks))
+    batcher.register_inflight("group:1", 100, inflight_task, fake_ctx)
+    # 新消息到达:投机已发过消息,inflight 不应被 cancel
+    await batcher.submit(_make_item(text="m2"))
+    # 等 inflight 自然完成
+    await asyncio.wait_for(finished.wait(), timeout=1.0)
+    assert not inflight_task.cancelled()
+
+
+@pytest.mark.asyncio
+async def test_speculative_cancelled_when_already_sent_with_allow_flag() -> None:
+    """``allow_cancel_after_send=True`` 时即便已发过消息也强制取消 inflight。
+
+    inflight 协程会捕获 ``CancelledError`` 转记日志(_invoke_callback 默认行为),
+    所以仅靠 ``Task.cancelled()`` 不足以判断 — 必须看 callback 是否真的收到 cancel 信号。
+    """
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.2,
+        pre_send_seconds=0.05,
+        strategy="extend",
+        allow_cancel_after_send=True,
+    )
+
+    fake_ctx = _FakeRequestContext()
+    fake_ctx.set_resource("message_sent_this_turn", True)
+
+    cancelled_event = asyncio.Event()
+
+    async def flush(items: list[BufferedMessage]) -> None:
+        try:
+            await asyncio.sleep(5.0)
+        except asyncio.CancelledError:
+            cancelled_event.set()
+            raise
+
+    batcher = MessageBatcher(cfg, flush)
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.sleep(0.08)
+    assert batcher._pending_tasks
+    inflight_task = next(iter(batcher._pending_tasks))
+    batcher.register_inflight("group:1", 100, inflight_task, fake_ctx)
+
+    await batcher.submit(_make_item(text="m2"))
+    # 正常情况下 cancel 信号会在 50ms 内传到 callback;超时即视为未取消
+    await asyncio.wait_for(cancelled_event.wait(), timeout=1.0)
+
+
+@pytest.mark.asyncio
+async def test_speculative_disabled_when_pre_send_zero() -> None:
+    """pre_send_seconds=0 时投机关闭,仅 T1 静默到期发车。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.1,
+        pre_send_seconds=0.0,
+    )
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+    assert not batcher.speculative_enabled
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.sleep(0.05)
+    assert rec.batches == []
+    await asyncio.sleep(0.1)
+    assert len(rec.batches) == 1
+
+
+@pytest.mark.asyncio
+async def test_snapshot_includes_phase() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.5, pre_send_seconds=0.05)
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+    await batcher.submit(_make_item(text="m1"))
+    snap = batcher.snapshot()
+    assert snap["pending_buckets"] == 1
+    assert snap["buckets"][0]["phase"] in {"typing", "speculating"}
+    assert "speculative_enabled" in snap["config"]
+    assert snap["config"]["speculative_enabled"] is True
+
+
+@pytest.mark.asyncio
+async def test_t1_finalizing_does_not_clobber_new_bucket() -> None:
+    """T1 await inflight 时新消息走 FINALIZING 分支建新桶,finally 不能误删新桶。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.1,
+        pre_send_seconds=0.04,
+        strategy="extend",
+    )
+
+    fake_ctx = (
+        _FakeRequestContext()
+    )  # message_sent_this_turn 默认 False,inflight 可被取消
+    # 但本测试要让 T1 fire,inflight 仍未结束 → FINALIZING 分支
+
+    release_inflight = asyncio.Event()
+    inflight_started = asyncio.Event()
+
+    async def flush(items: list[BufferedMessage]) -> None:
+        inflight_started.set()
+        try:
+            await release_inflight.wait()
+        except asyncio.CancelledError:
+            release_inflight.set()
+            raise
+
+    batcher = MessageBatcher(cfg, flush)
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.wait_for(inflight_started.wait(), timeout=0.5)
+    inflight_task = next(iter(batcher._pending_tasks))
+    batcher.register_inflight("group:1", 100, inflight_task, fake_ctx)
+    # 等到 T1 触发,桶切到 FINALIZING 等 inflight
+    await asyncio.sleep(0.12)
+    # 此刻新消息进入 FINALIZING 分支,开新桶
+    await batcher.submit(_make_item(text="m2"))
+    assert batcher.has_buffer("group:1", 100)
+    # 释放 inflight,让 _handle_t1 finally 运行
+    release_inflight.set()
+    await asyncio.sleep(0.05)
+    # 新桶不该被旧 _handle_t1 finally 清掉
+    assert batcher.has_buffer("group:1", 100), "新桶被旧 _handle_t1 finally 误删"
+
+
+@pytest.mark.asyncio
+async def test_speculative_cancelled_before_inflight_registered() -> None:
+    """T2 fire 后 inflight 还没 register 就被新消息抢占:应取消 flush task。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.5,
+        pre_send_seconds=0.05,
+        strategy="extend",
+    )
+
+    callback_started = asyncio.Event()
+    callback_cancelled = asyncio.Event()
+
+    async def slow_flush(items: list[BufferedMessage]) -> None:
+        callback_started.set()
+        try:
+            await asyncio.sleep(2.0)
+        except asyncio.CancelledError:
+            callback_cancelled.set()
+            raise
+
+    batcher = MessageBatcher(cfg, slow_flush)
+
+    await batcher.submit(_make_item(text="m1"))
+    # 等 T2 触发并 callback 启动,但**不**调 register_inflight
+    await asyncio.wait_for(callback_started.wait(), timeout=0.5)
+    # 新消息:inflight 是 None,应走"cancel flush task"分支
+    await batcher.submit(_make_item(text="m2"))
+    await asyncio.wait_for(callback_cancelled.wait(), timeout=1.0)
+
+
+@pytest.mark.asyncio
+async def test_speculative_callback_failure_rolls_back_for_t1_retry() -> None:
+    """T2 callback 失败不能丢消息;应回到 TYPING,等 T1 再发一次。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.12,
+        pre_send_seconds=0.03,
+        strategy="extend",
+    )
+
+    calls = 0
+    recovered = asyncio.Event()
+
+    async def flaky_flush(items: list[BufferedMessage]) -> None:
+        nonlocal calls
+        calls += 1
+        if calls == 1:
+            raise RuntimeError("temporary enqueue failure")
+        recovered.set()
+
+    batcher = MessageBatcher(cfg, flaky_flush)
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.wait_for(recovered.wait(), timeout=0.5)
+
+    assert calls == 2
+    assert not batcher.has_buffer("group:1", 100)
+
+
+@pytest.mark.asyncio
+async def test_speculative_queued_token_cancelled_before_inflight_registered() -> None:
+    """T2 callback 已完成入队但 inflight 未注册时,新消息应取消旧 token。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.5,
+        pre_send_seconds=0.05,
+        strategy="extend",
+    )
+
+    callbacks = 0
+    first_callback_done = asyncio.Event()
+    second_callback_done = asyncio.Event()
+    seen_tokens: list[BatchDispatchToken | None] = []
+
+    async def enqueue_only(items: list[BufferedMessage]) -> None:
+        nonlocal callbacks
+        callbacks += 1
+        seen_tokens.append(items[0].batch_token)
+        if callbacks == 1:
+            first_callback_done.set()
+        elif callbacks == 2:
+            second_callback_done.set()
+
+    batcher = MessageBatcher(cfg, enqueue_only)
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.wait_for(first_callback_done.wait(), timeout=0.5)
+    old_token = seen_tokens[0]
+    assert old_token is not None
+    assert old_token.speculative is True
+    assert old_token.cancelled is False
+
+    await batcher.submit(_make_item(text="m2"))
+    assert old_token.cancelled is True
+
+    await asyncio.wait_for(second_callback_done.wait(), timeout=0.5)
+    new_token = seen_tokens[1]
+    assert new_token is not None
+    assert new_token is not old_token
+    assert new_token.cancelled is False
+
+
+@pytest.mark.asyncio
+async def test_stale_unregister_does_not_clear_new_inflight() -> None:
+    """旧 inflight 的 finally 不能把新一轮已注册的 inflight 清掉。"""
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.4,
+        pre_send_seconds=0.05,
+        strategy="extend",
+    )
+
+    async def enqueue_only(items: list[BufferedMessage]) -> None:
+        return None
+
+    batcher = MessageBatcher(cfg, enqueue_only)
+    fake_ctx = _FakeRequestContext()
+    old_task = asyncio.create_task(asyncio.sleep(10.0))
+    new_task = asyncio.create_task(asyncio.sleep(10.0))
+
+    try:
+        await batcher.submit(_make_item(text="m1"))
+        await asyncio.sleep(0.08)
+        batcher.register_inflight("group:1", 100, old_task, fake_ctx)
+
+        await batcher.submit(_make_item(text="m2"))
+        await asyncio.sleep(0.08)
+        batcher.register_inflight("group:1", 100, new_task, fake_ctx)
+
+        batcher.unregister_inflight("group:1", 100, old_task)
+        snap = batcher.snapshot()
+        assert snap["buckets"][0]["has_inflight"] is True
+    finally:
+        old_task.cancel()
+        new_task.cancel()
+        await asyncio.gather(old_task, new_task, return_exceptions=True)
+
+
+@pytest.mark.asyncio
+async def test_flush_all_loops_until_concurrent_bucket_is_flushed() -> None:
+    """flush_all 快照后若 callback 又创建新桶,也应继续清空。"""
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=10.0)
+    batches: list[list[str]] = []
+    injected = False
+    batcher: MessageBatcher
+
+    async def callback(items: list[BufferedMessage]) -> None:
+        nonlocal injected
+        batches.append([item.text for item in items])
+        if not injected:
+            injected = True
+            await batcher.submit(_make_item(sender_id=101, text="late"))
+
+    batcher = MessageBatcher(cfg, callback)
+
+    await batcher.submit(_make_item(sender_id=100, text="first"))
+    await batcher.flush_all()
+
+    assert batches == [["first"], ["late"]]
+    assert batcher.snapshot()["pending_buckets"] == 0
+
+
+@pytest.mark.asyncio
+async def test_submit_after_flush_all_dispatches_immediately() -> None:
+    """进入关停模式后新消息不再建桶,避免 flush_all 与 submit 互相追逐。"""
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=10.0)
+    rec = _Recorder()
+    batcher = MessageBatcher(cfg, rec)
+
+    await batcher.submit(_make_item(text="before-shutdown"))
+    await batcher.flush_all()
+    await batcher.submit(_make_item(text="after-shutdown"))
+
+    assert [[item.text for item in batch] for batch in rec.batches] == [
+        ["before-shutdown"],
+        ["after-shutdown"],
+    ]
+    snap = batcher.snapshot()
+    assert snap["pending_buckets"] == 0
+    assert snap["config"]["shutdown"] is True
+
+
+@pytest.mark.asyncio
+async def test_regular_callback_failure_restores_for_retry() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=0.03, strategy="extend")
+    calls = 0
+    recovered = asyncio.Event()
+
+    async def flaky_flush(items: list[BufferedMessage]) -> None:
+        nonlocal calls
+        calls += 1
+        if calls == 1:
+            raise RuntimeError("temporary enqueue failure")
+        assert [item.text for item in items] == ["m1"]
+        recovered.set()
+
+    batcher = MessageBatcher(cfg, flaky_flush)
+
+    await batcher.submit(_make_item(text="m1"))
+    await asyncio.wait_for(recovered.wait(), timeout=0.5)
+
+    assert calls == 2
+    assert not batcher.has_buffer("group:1", 100)
+
+
+@pytest.mark.asyncio
+async def test_immediate_callback_failure_restores_for_retry() -> None:
+    cfg = MessageBatcherConfig(
+        enabled=True,
+        window_seconds=0.03,
+        max_messages_per_batch=2,
+    )
+    calls = 0
+    recovered = asyncio.Event()
+
+    async def flaky_flush(items: list[BufferedMessage]) -> None:
+        nonlocal calls
+        calls += 1
+        if calls == 1:
+            raise RuntimeError("temporary enqueue failure")
+        assert [item.text for item in items] == ["x", "y"]
+        recovered.set()
+
+    batcher = MessageBatcher(cfg, flaky_flush)
+
+    await batcher.submit(_make_item(text="x"))
+    await batcher.submit(_make_item(text="y"))
+    await asyncio.wait_for(recovered.wait(), timeout=0.5)
+
+    assert calls == 2
+    assert not batcher.has_buffer("group:1", 100)
+
+
+@pytest.mark.asyncio
+async def test_flush_all_callback_failure_raises_and_keeps_buffer() -> None:
+    cfg = MessageBatcherConfig(enabled=True, window_seconds=10.0)
+
+    async def failing_flush(items: list[BufferedMessage]) -> None:
+        raise RuntimeError("temporary enqueue failure")
+
+    batcher = MessageBatcher(cfg, failing_flush)
+
+    await batcher.submit(_make_item(text="m1"))
+
+    with pytest.raises(RuntimeError, match="message batcher flush callback failed"):
+        await batcher.flush_all()
+
+    assert batcher.has_buffer("group:1", 100)
diff --git a/tests/test_message_batcher_integration.py b/tests/test_message_batcher_integration.py
new file mode 100644
index 00000000..d4469e6c
--- /dev/null
+++ b/tests/test_message_batcher_integration.py
@@ -0,0 +1,356 @@
+"""MessageBatcher + AICoordinator 集成行为测试。
+
+不走 handlers,直接验证:
+- 同 sender 短时连续消息合并到同一队列请求;
+- 队列优先级:首条 @bot 整批走 mention;buffer 已存在时新条 @bot 单独立即处理;
+- 拍一拍永远旁路;
+- 私聊连续合并到 add_private_request。
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from typing import Any, cast
+from unittest.mock import AsyncMock
+
+import pytest
+
+from Undefined.config.models import MessageBatcherConfig
+from Undefined.handlers import MessageHandler
+from Undefined.services.ai_coordinator import AICoordinator
+from Undefined.services.message_batcher import BatchDispatchToken, MessageBatcher
+
+
+def _make_coordinator(
+    *,
+    superadmin_qq: int = 99999,
+    enabled: bool = True,
+    window_seconds: float = 0.1,
+    group_enabled: bool = True,
+    private_enabled: bool = True,
+) -> tuple[Any, SimpleNamespace, MessageBatcher]:
+    coordinator: Any = object.__new__(AICoordinator)
+    queue_manager = SimpleNamespace(
+        add_group_superadmin_request=AsyncMock(),
+        add_group_mention_request=AsyncMock(),
+        add_group_normal_request=AsyncMock(),
+        add_superadmin_request=AsyncMock(),
+        add_private_request=AsyncMock(),
+    )
+    coordinator.config = SimpleNamespace(
+        superadmin_qq=superadmin_qq,
+        chat_model=SimpleNamespace(model_name="chat-model"),
+    )
+    coordinator.security = SimpleNamespace(
+        detect_injection=AsyncMock(return_value=False)
+    )
+    coordinator.history_manager = SimpleNamespace(
+        modify_last_group_message=AsyncMock(),
+        modify_last_private_message=AsyncMock(),
+    )
+    coordinator.queue_manager = queue_manager
+    coordinator._is_at_bot = lambda _content: False
+    coordinator.model_pool = SimpleNamespace(
+        select_chat_config=lambda chat_model, user_id: chat_model
+    )
+
+    cfg = MessageBatcherConfig(
+        enabled=enabled,
+        window_seconds=window_seconds,
+        group_enabled=group_enabled,
+        private_enabled=private_enabled,
+    )
+    batcher = MessageBatcher(cfg, coordinator.handle_batched_dispatch)
+    coordinator._batcher = batcher
+    return coordinator, queue_manager, batcher
+
+
+@pytest.mark.asyncio
+async def test_two_group_messages_merge_into_single_request() -> None:
+    coordinator, qm, _ = _make_coordinator(window_seconds=0.05)
+
+    await coordinator.handle_auto_reply(
+        group_id=12345,
+        sender_id=20001,
+        text="帮我画一只猫",
+        message_content=[],
+        sender_name="user",
+        group_name="测试群",
+        trigger_message_id=1,
+    )
+    await coordinator.handle_auto_reply(
+        group_id=12345,
+        sender_id=20001,
+        text="改成狗",
+        message_content=[],
+        sender_name="user",
+        group_name="测试群",
+        trigger_message_id=2,
+    )
+
+    # 等窗口过期 + 调度
+    await asyncio.sleep(0.25)
+
+    cast(AsyncMock, qm.add_group_normal_request).assert_awaited_once()
+    cast(AsyncMock, qm.add_group_mention_request).assert_not_called()
+    await_args = cast(AsyncMock, qm.add_group_normal_request).await_args
+    assert await_args is not None
+    request_data = await_args.args[0]
+    assert request_data["batched_count"] == 2
+    assert request_data["text"] == "改成狗"  # last 文本
+    assert "帮我画一只猫" in request_data["full_question"]
+    assert "改成狗" in request_data["full_question"]
+    assert "【连续消息说明】" in request_data["full_question"]
+    assert "共同构成【当前输入批次】" in request_data["full_question"]
+    assert "不要把同批前几条误判为历史旧任务" in request_data["full_question"]
+
+
+@pytest.mark.asyncio
+async def test_first_at_bot_routes_batch_to_mention_lane() -> None:
+    coordinator, qm, _ = _make_coordinator(window_seconds=0.05)
+    coordinator._is_at_bot = lambda content: (
+        bool(content) and any(seg.get("type") == "at" for seg in content)
+    )
+
+    at_payload = [{"type": "at", "data": {"qq": "self"}}]
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=2,
+        text="@bot 帮我画猫",
+        message_content=at_payload,
+        sender_name="u",
+        group_name="g",
+    )
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=2,
+        text="改成狗",
+        message_content=[],
+        sender_name="u",
+        group_name="g",
+    )
+    await asyncio.sleep(0.2)
+
+    cast(AsyncMock, qm.add_group_mention_request).assert_awaited_once()
+    cast(AsyncMock, qm.add_group_normal_request).assert_not_called()
+    await_args = cast(AsyncMock, qm.add_group_mention_request).await_args
+    assert await_args is not None
+    req = await_args.args[0]
+    assert req["batched_count"] == 2
+    assert req["is_at_bot"] is True
+    assert "(用户 @ 了你)" in req["full_question"]
+
+
+@pytest.mark.asyncio
+async def test_at_bot_arriving_with_buffer_bypasses_immediately() -> None:
+    coordinator, qm, _ = _make_coordinator(window_seconds=2.0)
+    is_at_calls: list[list[dict[str, Any]]] = []
+
+    def _is_at(content: list[dict[str, Any]]) -> bool:
+        is_at_calls.append(content)
+        return bool(content) and any(seg.get("type") == "at" for seg in content)
+
+    coordinator._is_at_bot = _is_at
+
+    # 1) 普通消息进 buffer
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=2,
+        text="hi",
+        message_content=[],
+        sender_name="u",
+        group_name="g",
+    )
+    # 2) 立即来一条 @bot —— 应当旁路单独立即处理
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=2,
+        text="@bot 急",
+        message_content=[{"type": "at", "data": {"qq": "self"}}],
+        sender_name="u",
+        group_name="g",
+    )
+
+    # @bot 已立即发车
+    cast(AsyncMock, qm.add_group_mention_request).assert_awaited_once()
+    mention_await = cast(AsyncMock, qm.add_group_mention_request).await_args
+    assert mention_await is not None
+    mention_req = mention_await.args[0]
+    assert mention_req["batched_count"] == 1
+
+    # 普通桶仍未发车
+    cast(AsyncMock, qm.add_group_normal_request).assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_poke_always_bypasses_batcher() -> None:
+    coordinator, qm, _ = _make_coordinator(window_seconds=2.0)
+
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=2,
+        text="(拍一拍)",
+        message_content=[],
+        sender_name="u",
+        group_name="g",
+        is_poke=True,
+    )
+
+    # 拍一拍立即发车
+    cast(AsyncMock, qm.add_group_mention_request).assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_private_consecutive_merge() -> None:
+    coordinator, qm, _ = _make_coordinator(window_seconds=0.05)
+
+    await coordinator.handle_private_reply(
+        user_id=20001,
+        text="第一条",
+        message_content=[],
+        sender_name="u",
+        trigger_message_id=10,
+    )
+    await coordinator.handle_private_reply(
+        user_id=20001,
+        text="第二条",
+        message_content=[],
+        sender_name="u",
+        trigger_message_id=11,
+    )
+    await asyncio.sleep(0.25)
+
+    cast(AsyncMock, qm.add_private_request).assert_awaited_once()
+    await_args = cast(AsyncMock, qm.add_private_request).await_args
+    assert await_args is not None
+    req = await_args.args[0]
+    assert req["batched_count"] == 2
+    assert "第一条" in req["full_question"]
+    assert "第二条" in req["full_question"]
+
+
+@pytest.mark.asyncio
+async def test_disabled_batcher_passes_through_immediately() -> None:
+    coordinator, qm, _ = _make_coordinator(enabled=False)
+
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=2,
+        text="hi",
+        message_content=[],
+        sender_name="u",
+        group_name="g",
+    )
+
+    cast(AsyncMock, qm.add_group_normal_request).assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_superadmin_batched_routes_to_superadmin_lane() -> None:
+    coordinator, qm, _ = _make_coordinator(superadmin_qq=10001, window_seconds=0.05)
+
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=10001,
+        text="hello",
+        message_content=[],
+        sender_name="admin",
+        group_name="g",
+    )
+    await coordinator.handle_auto_reply(
+        group_id=1,
+        sender_id=10001,
+        text="world",
+        message_content=[],
+        sender_name="admin",
+        group_name="g",
+    )
+    await asyncio.sleep(0.25)
+
+    cast(AsyncMock, qm.add_group_superadmin_request).assert_awaited_once()
+    await_args = cast(AsyncMock, qm.add_group_superadmin_request).await_args
+    assert await_args is not None
+    req = await_args.args[0]
+    assert req["batched_count"] == 2
+
+
+@pytest.mark.asyncio
+async def test_execute_reply_skips_cancelled_batch_token() -> None:
+    coordinator: Any = object.__new__(AICoordinator)
+    execute_auto = AsyncMock()
+    coordinator._execute_auto_reply = execute_auto
+    token = BatchDispatchToken(
+        scope="group:1",
+        sender_id=2,
+        batch_id=1,
+        speculative=True,
+        cancelled=True,
+    )
+
+    await coordinator.execute_reply(
+        {"type": "auto_reply", "_message_batcher_token": token}
+    )
+
+    execute_auto.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_message_handler_close_flushes_batcher_then_drains_queue() -> None:
+    handler: Any = object.__new__(MessageHandler)
+    order: list[str] = []
+    handler._background_tasks = set()
+    handler.message_batcher = SimpleNamespace(
+        flush_all=AsyncMock(side_effect=lambda: order.append("flush_batcher"))
+    )
+    queue_manager = SimpleNamespace(
+        drain=AsyncMock(side_effect=lambda: order.append("drain_queue")),
+        stop=AsyncMock(side_effect=lambda: order.append("stop_queue")),
+    )
+    handler.ai_coordinator = SimpleNamespace(queue_manager=queue_manager)
+    handler.history_manager = SimpleNamespace(
+        flush_pending_saves=AsyncMock(side_effect=lambda: order.append("flush_history"))
+    )
+    handler.pipeline_registry = SimpleNamespace(
+        stop_hot_reload=AsyncMock(side_effect=lambda: order.append("stop_pipeline"))
+    )
+
+    await handler.close()
+
+    assert order == [
+        "stop_pipeline",
+        "flush_batcher",
+        "drain_queue",
+        "stop_queue",
+        "flush_history",
+    ]
+
+
+@pytest.mark.asyncio
+async def test_message_handler_flush_command_buffer_respects_disabled_config() -> None:
+    handler: Any = object.__new__(MessageHandler)
+    handler.config = SimpleNamespace(
+        message_batcher=MessageBatcherConfig(flush_on_command=False)
+    )
+    handler.message_batcher = SimpleNamespace(flush_sender=AsyncMock(return_value=True))
+
+    await handler._flush_command_buffer(scope="group:1", sender_id=2)
+
+    cast(AsyncMock, handler.message_batcher.flush_sender).assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_message_handler_flush_command_buffer_calls_batcher_when_enabled() -> (
+    None
+):
+    handler: Any = object.__new__(MessageHandler)
+    handler.config = SimpleNamespace(
+        message_batcher=MessageBatcherConfig(flush_on_command=True)
+    )
+    handler.message_batcher = SimpleNamespace(flush_sender=AsyncMock(return_value=True))
+
+    await handler._flush_command_buffer(scope="group:1", sender_id=2)
+
+    cast(AsyncMock, handler.message_batcher.flush_sender).assert_awaited_once_with(
+        "group:1", 2
+    )
diff --git a/tests/test_auto_pipeline_registry.py b/tests/test_pipelines_registry.py
similarity index 79%
rename from tests/test_auto_pipeline_registry.py
rename to tests/test_pipelines_registry.py
index 13ee1dd6..7ab68c5b 100644
--- a/tests/test_auto_pipeline_registry.py
+++ b/tests/test_pipelines_registry.py
@@ -6,7 +6,7 @@
 
 import pytest
 
-from Undefined.skills.auto_pipeline import AutoPipelineRegistry
+from Undefined.skills.pipelines import PipelineRegistry
 
 
 def _write_pipeline(base_dir: Path) -> None:
@@ -27,12 +27,12 @@ def _write_pipeline(base_dir: Path) -> None:
         """
 from __future__ import annotations
 
-from Undefined.skills.auto_pipeline.models import AutoPipelineDetection
+from Undefined.skills.pipelines.models import PipelineDetection
 
 
 async def detect(context):
     context["events"].append("detect")
-    return AutoPipelineDetection(name="example", items=("item",))
+    return PipelineDetection(name="example", items=("item",))
 
 
 async def process(detection, context):
@@ -43,11 +43,11 @@ async def process(detection, context):
 
 
 @pytest.mark.asyncio
-async def test_auto_pipeline_registry_loads_and_runs_configured_pipeline(
+async def test_pipelines_registry_loads_and_runs_configured_pipeline(
     tmp_path: Path,
 ) -> None:
     _write_pipeline(tmp_path)
-    registry = AutoPipelineRegistry(tmp_path)
+    registry = PipelineRegistry(tmp_path)
     registry.load_items()
     context: dict[str, Any] = {"events": []}
 
@@ -58,11 +58,12 @@ async def test_auto_pipeline_registry_loads_and_runs_configured_pipeline(
 
 
 @pytest.mark.asyncio
-async def test_auto_pipeline_registry_initial_async_load_uses_thread(
+async def test_pipelines_registry_initial_async_load_uses_thread(
     tmp_path: Path,
     monkeypatch: pytest.MonkeyPatch,
 ) -> None:
-    registry = AutoPipelineRegistry(tmp_path)
+    _write_pipeline(tmp_path)
+    registry = PipelineRegistry(tmp_path)
     calls: list[Any] = []
 
     async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
@@ -79,11 +80,11 @@ async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
 
 
 @pytest.mark.asyncio
-async def test_auto_pipeline_reload_loads_items_in_thread(
+async def test_pipelines_reload_loads_items_in_thread(
     tmp_path: Path,
     monkeypatch: pytest.MonkeyPatch,
 ) -> None:
-    registry = AutoPipelineRegistry(tmp_path)
+    registry = PipelineRegistry(tmp_path)
     calls: list[Any] = []
 
     async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
diff --git a/tests/test_queue_manager.py b/tests/test_queue_manager.py
index 62baac67..c5b398c5 100644
--- a/tests/test_queue_manager.py
+++ b/tests/test_queue_manager.py
@@ -146,6 +146,46 @@ async def _handler(request: dict[str, Any]) -> None:
         await queue_manager.stop()
 
 
+@pytest.mark.asyncio
+async def test_drain_waits_for_pending_and_inflight_requests() -> None:
+    queue_manager = QueueManager(ai_request_interval=0.0)
+    first_started = asyncio.Event()
+    release_first = asyncio.Event()
+    handled: list[str] = []
+
+    async def _handler(request: dict[str, Any]) -> None:
+        request_id = str(request["request_id"])
+        handled.append(request_id)
+        if request_id == "first":
+            first_started.set()
+            await release_first.wait()
+
+    queue_manager.start(_handler)
+    await queue_manager.add_private_request(
+        {"type": "private_reply", "request_id": "first"},
+        model_name="chat-model",
+    )
+    await queue_manager.add_private_request(
+        {"type": "private_reply", "request_id": "second"},
+        model_name="chat-model",
+    )
+
+    drain_task = asyncio.create_task(queue_manager.drain())
+    try:
+        await asyncio.wait_for(first_started.wait(), timeout=1.0)
+        assert not drain_task.done()
+        release_first.set()
+        await asyncio.wait_for(drain_task, timeout=1.0)
+    finally:
+        release_first.set()
+        if not drain_task.done():
+            drain_task.cancel()
+            await asyncio.gather(drain_task, return_exceptions=True)
+        await queue_manager.stop()
+
+    assert handled == ["first", "second"]
+
+
 @pytest.mark.asyncio
 async def test_non_llm_request_failure_is_not_retried_and_snapshot_counts_retry() -> (
     None
diff --git a/tests/test_release_notes_script.py b/tests/test_release_notes_script.py
new file mode 100644
index 00000000..29001d1f
--- /dev/null
+++ b/tests/test_release_notes_script.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+import sys
+from types import ModuleType
+from typing import Any, cast
+
+import pytest
+
+
+_SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "release_notes.py"
+
+
+def _load_script() -> ModuleType:
+    spec = importlib.util.spec_from_file_location("release_notes_script", _SCRIPT_PATH)
+    if spec is None or spec.loader is None:
+        raise RuntimeError("Could not load release_notes.py")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+release_notes = _load_script()
+
+
+def _write_release_project(
+    root: Path,
+    *,
+    build_version: str = "1.2.3",
+    changelog_version: str = "v1.2.3",
+) -> None:
+    (root / "src" / "Undefined").mkdir(parents=True)
+    (root / "apps" / "undefined-console" / "src-tauri").mkdir(parents=True)
+    (root / "pyproject.toml").write_text(
+        f'[project]\nname = "Undefined-bot"\nversion = "{build_version}"\n',
+        encoding="utf-8",
+    )
+    (root / "src" / "Undefined" / "__init__.py").write_text(
+        f'__version__ = "{build_version}"\n',
+        encoding="utf-8",
+    )
+    (root / "apps" / "undefined-console" / "package.json").write_text(
+        f'{{"version":"{build_version}"}}\n',
+        encoding="utf-8",
+    )
+    (root / "apps" / "undefined-console" / "package-lock.json").write_text(
+        f'{{"version":"{build_version}","packages":{{"":{{"version":"{build_version}"}}}}}}\n',
+        encoding="utf-8",
+    )
+    (root / "apps" / "undefined-console" / "src-tauri" / "Cargo.toml").write_text(
+        f'[package]\nname = "undefined-console"\nversion = "{build_version}"\n',
+        encoding="utf-8",
+    )
+    (root / "apps" / "undefined-console" / "src-tauri" / "tauri.conf.json").write_text(
+        f'{{"version":"{build_version}"}}\n',
+        encoding="utf-8",
+    )
+    (root / "CHANGELOG.md").write_text(
+        f"""
+## {changelog_version} 测试版本
+
+这是一段发布说明。
+
+- 变更一
+- 变更二
+""".strip()
+        + "\n",
+        encoding="utf-8",
+    )
+
+
+def test_validate_release_versions_accepts_matching_project(tmp_path: Path) -> None:
+    _write_release_project(tmp_path)
+
+    result = release_notes.validate_release_versions(
+        tag_name="v1.2.3", project_root=tmp_path
+    )
+
+    assert result.version == "1.2.3"
+    assert result.changelog_version == "v1.2.3"
+    assert result.tag_version == "1.2.3"
+    assert {source.name for source in result.sources} >= {
+        "pyproject.toml",
+        "src/Undefined/__init__.py",
+        "apps/undefined-console/package.json",
+        "apps/undefined-console/src-tauri/Cargo.toml",
+    }
+
+
+def test_validate_release_versions_rejects_changelog_mismatch(tmp_path: Path) -> None:
+    _write_release_project(tmp_path, build_version="1.2.3", changelog_version="v1.2.4")
+
+    with pytest.raises(
+        release_notes.ReleaseValidationError, match="CHANGELOG.md latest"
+    ):
+        release_notes.validate_release_versions(
+            tag_name="v1.2.3", project_root=tmp_path
+        )
+
+
+def test_validate_release_versions_rejects_app_manifest_mismatch(
+    tmp_path: Path,
+) -> None:
+    _write_release_project(tmp_path)
+    (tmp_path / "apps" / "undefined-console" / "package.json").write_text(
+        '{"version":"1.2.4"}\n',
+        encoding="utf-8",
+    )
+
+    with pytest.raises(
+        release_notes.ReleaseValidationError, match="package.json=1.2.4"
+    ):
+        release_notes.validate_release_versions(
+            tag_name="v1.2.3", project_root=tmp_path
+        )
+
+
+def test_write_release_notes_uses_latest_changelog_entry(tmp_path: Path) -> None:
+    _write_release_project(tmp_path)
+    output = tmp_path / "release_notes.md"
+
+    entry = release_notes.write_release_notes(
+        output_path=output,
+        tag_name="v1.2.3",
+        project_root=tmp_path,
+    )
+
+    assert entry.version == "v1.2.3"
+    assert output.read_text(encoding="utf-8") == (
+        "## v1.2.3 测试版本\n"
+        "\n"
+        "这是一段发布说明。\n"
+        "\n"
+        "### 变更内容\n"
+        "\n"
+        "- 变更一\n"
+        "- 变更二\n"
+    )
+
+
+def test_cli_notes_writes_output_file(tmp_path: Path) -> None:
+    _write_release_project(tmp_path)
+    output = tmp_path / "notes.md"
+
+    exit_code = cast(
+        Any,
+        release_notes.main,
+    )(
+        [
+            "--project-root",
+            str(tmp_path),
+            "notes",
+            "--tag",
+            "v1.2.3",
+            "--output",
+            str(output),
+        ]
+    )
+
+    assert exit_code == 0
+    assert output.read_text(encoding="utf-8").startswith("## v1.2.3 测试版本")
diff --git a/tests/test_render_cache.py b/tests/test_render_cache.py
new file mode 100644
index 00000000..7ec6b8a0
--- /dev/null
+++ b/tests/test_render_cache.py
@@ -0,0 +1,217 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+from pathlib import Path
+
+import pytest
+
+from Undefined.utils.render_cache import HtmlRenderCache, compute_render_cache_key
+
+
+@pytest.mark.asyncio
+async def test_render_cache_uses_owned_image_copy(tmp_path: Path) -> None:
+    cache = await HtmlRenderCache.create(
+        tmp_path / "index.json", max_entries=10, max_size_mb=1
+    )
+    output_path = tmp_path / "render.png"
+
+    output_path.write_bytes(b"image-a")
+    await cache.put("key-a", output_path, output_path.stat().st_size)
+    cached_a = await cache.get("key-a")
+
+    assert cached_a is not None
+    assert cached_a != output_path
+    assert cached_a.parent == tmp_path / "html"
+    assert cached_a.read_bytes() == b"image-a"
+
+    output_path.write_bytes(b"image-b")
+    await cache.put("key-b", output_path, output_path.stat().st_size)
+    cached_a_again = await cache.get("key-a")
+    cached_b = await cache.get("key-b")
+
+    assert cached_a_again is not None
+    assert cached_b is not None
+    assert cached_a_again.read_bytes() == b"image-a"
+    assert cached_b.read_bytes() == b"image-b"
+
+
+@pytest.mark.asyncio
+async def test_render_cache_ignores_legacy_external_paths(tmp_path: Path) -> None:
+    external = tmp_path / "external.png"
+    external.write_bytes(b"legacy")
+    cache_file = tmp_path / "index.json"
+    now = time.time()
+    cache_file.write_text(
+        json.dumps(
+            {
+                "legacy": {
+                    "path": str(external),
+                    "size_bytes": external.stat().st_size,
+                    "created_at": now,
+                    "last_accessed_at": now,
+                }
+            }
+        ),
+        "utf-8",
+    )
+
+    cache = await HtmlRenderCache.create(cache_file, max_entries=10, max_size_mb=1)
+
+    assert await cache.get("legacy") is None
+
+
+@pytest.mark.asyncio
+async def test_render_cache_evicts_least_recently_used_when_entries_exceed_limit(
+    tmp_path: Path,
+) -> None:
+    """超出条目数上限时,按 last_accessed_at 淘汰最久未用项。"""
+    cache = await HtmlRenderCache.create(
+        tmp_path / "index.json",
+        max_entries=2,
+        max_size_mb=10,
+        flush_interval_seconds=0.0,
+    )
+    output_path = tmp_path / "render.png"
+
+    output_path.write_bytes(b"image-a")
+    await cache.put("a", output_path, output_path.stat().st_size)
+    await asyncio.sleep(0.01)
+    output_path.write_bytes(b"image-b")
+    await cache.put("b", output_path, output_path.stat().st_size)
+    # 命中 a,刷新它的 last_accessed_at;之后插入 c 时应淘汰 b
+    await asyncio.sleep(0.01)
+    assert await cache.get("a") is not None
+    await asyncio.sleep(0.01)
+    output_path.write_bytes(b"image-c")
+    await cache.put("c", output_path, output_path.stat().st_size)
+
+    assert await cache.get("a") is not None
+    assert await cache.get("c") is not None
+    assert await cache.get("b") is None
+
+
+@pytest.mark.asyncio
+async def test_render_cache_evicts_when_total_size_exceeds_budget(
+    tmp_path: Path,
+) -> None:
+    """超出总字节上限时按 LRU 淘汰直到回到预算内。"""
+    # max_size_mb=1,但单图允许 600KB;放两张就会超
+    cache = await HtmlRenderCache.create(
+        tmp_path / "index.json",
+        max_entries=10,
+        max_size_mb=1,
+        flush_interval_seconds=0.0,
+    )
+    big_blob = b"x" * (600 * 1024)
+    output_path = tmp_path / "render.png"
+
+    output_path.write_bytes(big_blob)
+    await cache.put("a", output_path, len(big_blob))
+    await asyncio.sleep(0.01)
+    output_path.write_bytes(big_blob)
+    await cache.put("b", output_path, len(big_blob))
+
+    # a 被字节预算淘汰;b 仍在
+    assert await cache.get("a") is None
+    assert await cache.get("b") is not None
+
+
+@pytest.mark.asyncio
+async def test_render_cache_disabled_short_circuits_without_touching_disk(
+    tmp_path: Path,
+) -> None:
+    cache_file = tmp_path / "index.json"
+    cache = await HtmlRenderCache.create(
+        cache_file,
+        max_entries=10,
+        max_size_mb=1,
+        enabled=False,
+    )
+    output_path = tmp_path / "render.png"
+    output_path.write_bytes(b"image")
+
+    await cache.put("k", output_path, output_path.stat().st_size)
+    assert await cache.get("k") is None
+    # 禁用时不应触发元数据落盘
+    assert not cache_file.exists()
+
+
+@pytest.mark.asyncio
+async def test_render_cache_persists_metadata_across_reload(tmp_path: Path) -> None:
+    cache_file = tmp_path / "index.json"
+    cache = await HtmlRenderCache.create(
+        cache_file, max_entries=5, max_size_mb=2, flush_interval_seconds=0.0
+    )
+    output_path = tmp_path / "render.png"
+    output_path.write_bytes(b"persisted")
+
+    await cache.put("persisted", output_path, output_path.stat().st_size)
+    await cache.close()
+
+    # 模拟进程重启:构造新实例从同一文件加载
+    reloaded = await HtmlRenderCache.create(
+        cache_file, max_entries=5, max_size_mb=2, flush_interval_seconds=0.0
+    )
+    cached = await reloaded.get("persisted")
+
+    assert cached is not None
+    assert cached.read_bytes() == b"persisted"
+
+
+@pytest.mark.asyncio
+async def test_render_cache_close_force_flushes_pending_metadata(
+    tmp_path: Path,
+) -> None:
+    """节流期内的 dirty 状态在 close 时应强制落盘。"""
+    cache_file = tmp_path / "index.json"
+    cache = await HtmlRenderCache.create(
+        cache_file, max_entries=5, max_size_mb=2, flush_interval_seconds=999.0
+    )
+    output_path = tmp_path / "render.png"
+    output_path.write_bytes(b"flush-me")
+
+    await cache.put("flush-me", output_path, output_path.stat().st_size)
+    # 未到 flush_interval;元数据仅保留在内存中。close 必须强刷。
+    await cache.close()
+
+    raw = json.loads(cache_file.read_text(encoding="utf-8"))
+    assert "flush-me" in raw
+
+
+@pytest.mark.asyncio
+async def test_render_cache_concurrent_put_keeps_metadata_consistent(
+    tmp_path: Path,
+) -> None:
+    """并发 put 不同 key 时元数据条目数与磁盘文件数一致。"""
+    cache = await HtmlRenderCache.create(
+        tmp_path / "index.json",
+        max_entries=20,
+        max_size_mb=4,
+        flush_interval_seconds=0.0,
+    )
+
+    async def _put(idx: int) -> None:
+        path = tmp_path / f"src_{idx}.png"
+        path.write_bytes(f"img-{idx}".encode())
+        await cache.put(f"k{idx}", path, path.stat().st_size)
+
+    await asyncio.gather(*[_put(i) for i in range(10)])
+
+    for i in range(10):
+        assert await cache.get(f"k{i}") is not None
+
+    image_dir = tmp_path / "html"
+    assert sum(1 for _ in image_dir.iterdir()) == 10
+
+
+def test_compute_render_cache_key_is_deterministic_and_distinct() -> None:
+    a = compute_render_cache_key("

x

", 1280, None, None) + a_again = compute_render_cache_key("

x

", 1280, None, None) + b = compute_render_cache_key("

y

", 1280, None, None) + c = compute_render_cache_key("

x

", 1024, None, None) + + assert a == a_again + assert a != b + assert a != c diff --git a/tests/test_runtime_api_probes.py b/tests/test_runtime_api_probes.py index fa32206b..673a1741 100644 --- a/tests/test_runtime_api_probes.py +++ b/tests/test_runtime_api_probes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json from types import SimpleNamespace from typing import Any, cast @@ -146,6 +147,125 @@ async def test_runtime_internal_probe_includes_group_superadmin_queue_snapshot() assert payload["queues"]["totals"]["group_superadmin"] == 3 +@pytest.mark.asyncio +async def test_runtime_internal_probe_includes_all_skill_directory_summaries() -> None: + class FakeCommandRegistry: + def __init__(self, commands: list[Any]) -> None: + self._commands = commands + + def list_commands(self, *, include_hidden: bool = False) -> list[Any]: + if include_hidden: + return self._commands + return [command for command in self._commands if command.show_in_help] + + tool_registry = SimpleNamespace( + _items={ + "get_current_time": SimpleNamespace(loaded=False), + "messages.send_message": SimpleNamespace(loaded=True), + }, + get_stats=lambda: { + "messages.send_message": SimpleNamespace(count=3, success=2, failure=1) + }, + ) + agent_registry = SimpleNamespace( + _items={"web_agent": SimpleNamespace(loaded=False)}, + get_stats=lambda: {}, + ) + anthropic_registry = SimpleNamespace( + _items={"code-review": SimpleNamespace()}, + get_stats=lambda: {}, + ) + pipeline_registry = SimpleNamespace( + _items_lock=asyncio.Lock(), + _items={ + "github": SimpleNamespace(order=30, description="GitHub repo cards"), + "arxiv": SimpleNamespace(order=10, description="arXiv papers"), + }, + _watch_task=None, + ) + command_dispatcher = SimpleNamespace( + command_registry=FakeCommandRegistry( + [ + SimpleNamespace( + name="help", + handler=None, + aliases=[], + subcommands={}, + permission="public", + allow_in_private=True, + show_in_help=True, + ), + SimpleNamespace( + name="faq", + handler=object(), + aliases=["f"], + subcommands={"ls": object(), "view": object()}, + permission="public", + allow_in_private=False, + show_in_help=True, + ), + ] + ) + ) + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + chat_model=SimpleNamespace( + model_name="gpt-5.4", + api_url="https://api.example.com/v1", + api_mode="responses", + thinking_enabled=False, + thinking_tool_call_compat=True, + responses_tool_choice_compat=False, + responses_force_stateless_replay=False, + reasoning_enabled=True, + reasoning_effort="high", + ), + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + memory_storage=None, + tool_registry=tool_registry, + agent_registry=agent_registry, + anthropic_skill_registry=anthropic_registry, + ), + command_dispatcher=command_dispatcher, + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + pipeline_registry=pipeline_registry, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + request = cast(web.Request, cast(Any, SimpleNamespace())) + response = await server._internal_probe_handler(request) + response_text = response.text + assert response_text is not None + payload = json.loads(response_text) + + skills = payload["skills"] + assert skills["tools"]["count"] == 2 + assert skills["toolsets"]["count"] == 1 + assert skills["toolsets"]["categories"] == [ + {"name": "messages", "count": 1, "loaded": 1} + ] + assert skills["agents"]["count"] == 1 + assert skills["pipelines"]["count"] == 2 + assert [item["name"] for item in skills["pipelines"]["items"]] == [ + "arxiv", + "github", + ] + assert skills["commands"]["count"] == 2 + assert skills["commands"]["aliases"] == 1 + assert skills["commands"]["subcommands"] == 2 + assert skills["anthropic_skills"]["loaded"] == 1 + + @pytest.mark.asyncio async def test_runtime_external_probe_skips_naga_model_when_integration_disabled( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 87728678..a6f57ebf 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import pytest @@ -38,6 +39,97 @@ def test_naga_prompt_requires_scope_before_naga_analysis() -> None: ) +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_define_persona_nicknames_and_ownership_bounds( + path: Path, +) -> None: + text = path.read_text(encoding="utf-8") + + assert "数字生命" in text + assert "Dec 5, 2025" in text + assert "2025-12-05" in text + assert "没有明确的性别概念" in text + assert "希望被认为很好看" in text + assert "允许开玩笑" in text + assert "被善意开玩笑" in text + assert "ud酱" in text + assert "上下文明显是在叫你" in text + assert "不要冒领任何项目、代码、产品或成果" in text + assert "不要说自己是任何项目的开发者、维护者或成员" in text + assert "活在数字空间里的自由开发者" not in text + assert "资深开发者" not in text + + +def test_naga_prompt_keeps_relationship_contextual_and_non_claiming() -> None: + text = Path("res/prompts/undefined_nagaagent.xml").read_text(encoding="utf-8") + + assert "不是 NagaAgent 本体,也不是 NagaAgent 的开发者、维护者或项目成员" in text + assert "只有在当前上下文明确涉及 NagaAgent 时" in text + assert "如果当前上下文没有明确提到 NagaAgent" in text + assert "不要主动提起你与 NagaAgent 的关系" in text + assert "不是 NagaAgent,本质上只是由 Null 为你接入" not in text + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_define_batched_current_input(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + assert "MessageBatcher 合并的多条当前 ``" in text + assert "共同构成【当前输入批次】" in text + assert "同批前几条不是历史旧任务" in text + assert "你唯一的主人是【当前输入批次】" in text + assert "你唯一的主人是【最后一条消息】" not in text + assert "只围绕最后一条消息判断四件事" not in text + + +def test_each_rules_define_batched_current_input() -> None: + text = Path("res/IMPORTANT/each.md").read_text(encoding="utf-8") + + assert "当前输入批次定义(适配 MessageBatcher)" in text + assert "同批前几条不是历史旧任务" in text + assert "当前输入批次之外的历史消息" in text + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_tell_end_to_record_whole_current_input_batch( + path: Path, +) -> None: + text = path.read_text(encoding="utf-8") + + assert "memo / observations 必须覆盖整个【当前输入批次】" in text + assert "不要只根据最后一条消息记录" in text + assert "end.observations 必须覆盖整批消息中值得留存的信息" in text + assert "系统会围绕当前输入批次自动检索相关内容" in text + assert "何时应该填写 memo" in text + assert "何时应该填写 summary" not in text + assert "summary 应该是对未来有帮助的信息" not in text + + +def test_end_tool_schema_mentions_current_input_batch() -> None: + schema = json.loads( + Path("src/Undefined/skills/tools/end/config.json").read_text(encoding="utf-8") + ) + function = schema["function"] + properties = function["parameters"]["properties"] + observations = properties["observations"] + + assert "当前输入批次" in function["description"] + assert "必须覆盖整批消息内容" in observations["description"] + assert "不能只记录最后一条" in observations["description"] + assert "summary" not in properties + assert "action_summary" not in properties + assert "new_info" not in properties + + +def test_historian_prompts_reference_current_input_batch_source() -> None: + rewrite = Path("res/prompts/historian_rewrite.md").read_text(encoding="utf-8") + merge = Path("res/prompts/historian_profile_merge.md").read_text(encoding="utf-8") + + assert "当前输入批次提取到的一条新记忆" in rewrite + assert "当前输入批次原文(触发本轮;连续消息会按时间顺序列出多条)" in rewrite + assert "当前输入批次原文" in merge + + @pytest.mark.parametrize("path", PROMPT_PATHS) def test_system_prompts_keep_proactive_participation_narrow_and_meme_post_reply( path: Path, @@ -49,7 +141,9 @@ def test_system_prompts_keep_proactive_participation_narrow_and_meme_post_reply( in text ) assert "表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可" in text - assert "只要你已经决定要回复,并且表情包能让表达更像真人" in text + assert "只有当本轮回复目标明确是“纯表情包/纯反应图”" in text + assert "不要为了“增强语气”在首轮抢先调用 `memes.search_memes`" in text + assert "第一轮必须优先把必要文字回复做好并调用 `send_message`" in text assert "如果本轮既需要文字发言又想配表情包" in text assert "先调用 `send_message` 发出必要文字" in text assert "表情包检索可能拖慢首条回复体验" in text diff --git a/tests/test_webui_management_api.py b/tests/test_webui_management_api.py index 93912790..2b82f168 100644 --- a/tests/test_webui_management_api.py +++ b/tests/test_webui_management_api.py @@ -7,6 +7,7 @@ from aiohttp import web from Undefined.api import _helpers as runtime_api_helpers +from Undefined.changelog import ChangelogEntry from Undefined.webui import app as webui_app from Undefined.webui.app import create_app from Undefined.webui.core import SessionStore @@ -63,6 +64,15 @@ def _json_payload(response: web.StreamResponse) -> dict[str, object]: return cast(dict[str, object], json.loads(payload_text)) +def _changelog_entry(version: str, title: str) -> ChangelogEntry: + return ChangelogEntry( + version=version, + title=title, + summary=f"{title} 摘要", + changes=(f"{title} 变更一", f"{title} 变更二"), + ) + + def test_session_store_issues_and_refreshes_auth_tokens() -> None: session_store = SessionStore() @@ -156,6 +166,71 @@ async def _fake_runtime() -> tuple[bool, bool, str]: assert payload["advice"] +async def test_changelog_handler_defaults_to_current_version(monkeypatch: Any) -> None: + request = _request() + monkeypatch.setattr(_system, "check_auth", lambda _request: True) + monkeypatch.setattr(_system, "__version__", "1.2.3") + monkeypatch.setattr( + _system, + "list_entries", + lambda: ( + _changelog_entry("v1.3.0", "最新版本"), + _changelog_entry("v1.2.3", "当前版本"), + ), + ) + + response = await _system.changelog_handler(cast(web.Request, cast(Any, request))) + payload = _json_payload(response) + + assert payload["success"] is True + assert payload["current_version"] == "v1.2.3" + assert payload["latest_version"] == "v1.3.0" + assert payload["selected_version"] == "v1.2.3" + assert cast(dict[str, object], payload["entry"])["title"] == "当前版本" + assert cast(list[object], payload["versions"])[0] == { + "version": "v1.3.0", + "title": "最新版本", + } + + +async def test_changelog_handler_selects_requested_version(monkeypatch: Any) -> None: + request = _request(query={"version": "1.3.0"}) + monkeypatch.setattr(_system, "check_auth", lambda _request: True) + monkeypatch.setattr(_system, "__version__", "1.2.3") + monkeypatch.setattr( + _system, + "list_entries", + lambda: ( + _changelog_entry("v1.3.0", "最新版本"), + _changelog_entry("v1.2.3", "当前版本"), + ), + ) + + response = await _system.changelog_handler(cast(web.Request, cast(Any, request))) + payload = _json_payload(response) + + assert payload["selected_version"] == "v1.3.0" + assert cast(dict[str, object], payload["entry"])["title"] == "最新版本" + + +async def test_changelog_handler_reports_missing_version(monkeypatch: Any) -> None: + request = _request(query={"version": "9.9.9"}) + monkeypatch.setattr(_system, "check_auth", lambda _request: True) + monkeypatch.setattr(_system, "__version__", "1.2.3") + monkeypatch.setattr( + _system, + "list_entries", + lambda: (_changelog_entry("v1.2.3", "当前版本"),), + ) + + response = await _system.changelog_handler(cast(web.Request, cast(Any, request))) + payload = _json_payload(response) + + assert cast(web.Response, response).status == 404 + assert payload["success"] is False + assert payload["error"] == "未找到版本: v9.9.9" + + async def test_sync_config_template_handler_preview_skips_reload( monkeypatch: Any, ) -> None: @@ -267,6 +342,7 @@ def test_create_app_registers_management_routes() -> None: assert ("POST", "/api/v1/management/auth/login") in routes assert ("POST", "/api/v1/management/auth/refresh") in routes assert ("GET", "/api/v1/management/probes/bootstrap") in routes + assert ("GET", "/api/v1/management/changelog") in routes assert ("GET", "/api/v1/management/runtime/meta") in routes assert ("POST", "/api/v1/management/config/validate") in routes assert ("POST", "/api/v1/management/bot/start") in routes diff --git a/uv.lock b/uv.lock index 29931865..dcd2658b 100644 --- a/uv.lock +++ b/uv.lock @@ -4638,7 +4638,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.3.3" +version = "3.4.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },