Compare commits

1 Commits
Author SHA1 Message Date
lxy db46e74d3e 修复: 安全 8 项 + create_project 自动建目录
- ① relay token 必设环境变量 DF_RELAY_TOKEN,移除硬编码默认
- ② AiProviderRecord Debug 脱敏 api_key/model_configs/config
- ③ ScriptNode 危险命令告警(rm -rf/DROP TABLE/Format 等)
- ④ bind_directory 路径规范化,拒绝含 .. 的原始路径
- ⑤ StateMachine 文档警告不得在 await 期间持锁
- ⑥ EventBus send 错误改 tracing::warn 不再静默吞噬
- ⑦ probe_pwsh OnceLock 全局缓存,异步探测不阻塞 tokio
- ⑧ retry jitter 改 rand crate,范围扩大到 ±50%
- create_project 目录不存在时自动创建,消除死锁链
2026-06-28 13:36:18 +08:00
646 changed files with 20528 additions and 144347 deletions
-16
View File
@@ -1,21 +1,5 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link) # musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行 # df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
#
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
#
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
#
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
[target.x86_64-unknown-linux-musl] [target.x86_64-unknown-linux-musl]
linker = "rust-lld" linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"] rustflags = ["-C", "link-self-contained=y"]
# ── 主机编译加速(Windows MSVC) ────────────────────────────
# rust-lld 比 MSVC link.exe 快 3-5x,且 Rust 自带零安装
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
# 并行编译单元,留部分核心给 Zed UI 和系统
[build]
jobs = 8
+11
View File
@@ -0,0 +1,11 @@
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
target
node_modules
dist
dist-ssr
.git
*.db
*.log
apps/df-miniapp/node_modules
apps/df-miniapp/dist
src-tauri/target
-48
View File
@@ -3,9 +3,6 @@ logs
*.log *.log
npm-debug.log* npm-debug.log*
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
/swap-install.lock
# Dependencies # Dependencies
node_modules/ node_modules/
@@ -28,17 +25,12 @@ unpackage/
# Rust / Cargo # Rust / Cargo
target/ target/
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
!Cargo.lock !Cargo.lock
# Tauri # Tauri
src-tauri/target/ src-tauri/target/
src-tauri/Cargo.lock src-tauri/Cargo.lock
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
*.key
*.key.pub
# Tauri 自动生成的 schema(每次构建变,可重建) # Tauri 自动生成的 schema(每次构建变,可重建)
src-tauri/gen/schemas/ src-tauri/gen/schemas/
@@ -64,50 +56,10 @@ coverage/
tmp/ tmp/
.temp/ .temp/
# 临时杂项(2026-08-05 误入的开发机临时物:网易爬虫/播放脚本/mp3,勿提交)
archive/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码) # AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/ workflows/
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
/analysis_*.txt
/analysis_*.py
/core_domains.txt
/core_domains.py
/final_report.txt
/grand_output.txt
/im_live_verify.txt
/liang_deep.py
/liangxianyou_deep.txt
/member_profiles.py
/read_all.py
/report_p2.txt
/report_wall.txt
/team_analysis.py
/team_deep_analysis.py
/team_member_profiles.txt
/team_report_final.txt
/tmp_*.py
/tmp_*.txt
/verify_im_live*.py
# MCP/SQLite 测试库(mcp_test2 --db 残留) # MCP/SQLite 测试库(mcp_test2 --db 残留)
devflow-dev.db devflow-dev.db
devflow-dev.db-shm devflow-dev.db-shm
devflow-dev.db-wal devflow-dev.db-wal
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
apps/df-miniapp/src/manifest.json
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
temp/
# 本地工具链缓存(protoc 等,不入库)
.tools/
# df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/
# Playwright 运行产物
test-results/
playwright-report/
-48
View File
@@ -1,48 +0,0 @@
# DevFlow 提交规范
## 提交注释格式
```
<动词>: <简短描述>
<详细说明(可选,仅在需要补充上下文时)>
```
## 动词词表
| 动词 | 使用场景 |
|------|---------|
| `新增` | 新功能、新特性、新文件 |
| `修复` | Bug 修复、问题修正 |
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
| `重构` | 代码重构(行为不变的结构调整) |
| `删除` | 移除功能、删除文件 |
| `优化` | 性能优化、体验改进 |
| `合并` | 分支合并 |
## 规范要求
1. **动词 + 冒号 + 空格**:`修复: 审批按钮永久转圈`
2. **主题行不超过 72 字符**
3. **主语用中文**,描述清楚改动内容
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
5. **不要用分隔线**:禁止 `---` 或 `===` 等装饰线
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
## 示例
```
修复: 审批按钮永久转圈和等待计数不停
```
```
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 中文编码修复: git 命令注入 LC_ALL 环境变量
```
-29
View File
@@ -1,29 +0,0 @@
# DevFlow 开发规则
## 技术栈
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
- 后端: Rust Workspace (8 crate) / SQLite
- 包管理: bun (前端) / cargo (Rust)
- 路径: `E:/wk-lab/devflow`
## 架构原则
1. **治本不治标**:修复问题时找到根因,不做表面补丁
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
3. **长远考虑**:不追求局部最优,考虑整体架构演进
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
## 代码规范
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
4. **TypeScript 文件名**:camelCase(`fileExplorer.ts`)
5. **Vue 组件文件名**:PascalCase(`FileExplorer.vue`)
## i18n
- 中英文双语言同步:`src/i18n/en/` 和 `src/i18n/zh-CN/`
- 新增组件时同时添加对应语言的翻译文件
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)
-181
View File
@@ -1,181 +0,0 @@
{
// ── 项目级 Zed 配置: rust-analyzer 性能极限优化 ──────────────
//
// workspace: src-tauri + 7 个 df-* crate 的 Rust Monorepo
// flcheck 单次 600+ 行 artifact JSON → Zed 解析+渲染冻结 UI
// 核心策略: 关保存即 check · 限制检查范围 · 裁切 LSP 负载 · 排 IO 争抢
// ─────────────────────────────────────────────────────────────────
"lsp": {
"rust-analyzer": {
"initialization_options": {
// ── cargo 编译参数 ───────────────────────────────────
"cargo": {
"features": [],
"allTargets": false, // 只为 host target 解析
"noDefaultFeatures": false,
"targetDir": null,
"buildScripts": {
"enable": true,
"invocationStrategy": "once", // 只跑一次,不反复
"rerun": "on-save"
},
"extraArgs": []
},
// ── check / flycheck ────────────────────────────────
"check": {
"onSave": false, // ★ 核心!关保存即 check
"workspace": false, // 手动 check 也只查当前 crate
"command": "check", // 用 check,非 clippy
"invocationStrategy": "once", // 只跑一轮,不反复
"features": [],
"allTargets": false,
"ignore": [ // 跳过不常用的目录
"benches",
"examples",
"xtask",
"tests"
]
},
// ── 缓存预热(关掉,省首次启动 CPU 爆发) ──────────────
"cachePriming": {
"enable": false
},
// ── 诊断 ────────────────────────────────────────────
"diagnostics": {
"enable": true,
"experimental": {
"enable": false
},
"disabled": [ // 关掉非关键诊断,降 CPU
"unresolved-proc-macro",
"inactive-code",
"macro-error"
]
},
// ── proc-macro(必须开,否则 tauri 全报红) ────────────
"procMacro": {
"enable": true,
"attributes": {
"enable": true
}
},
// ── 符号检索 ────────────────────────────────────────
"workspace": {
"symbol": {
"search": {
"kind": "only_types",
"limit": 64
}
}
},
// ── 补全(限流减少 LSP 计算量) ──────────────────────
"completion": {
"autoclose": true,
"autoimport": {
"enable": true
},
"callable": {
"snippets": "fill_arguments"
},
"fullFunction": {
"enable": false
},
"limit": 200, // 补全条目上限
"postfix": {
"enable": false // 关 postfix snippet 减少候选
}
},
// ── 内联提示(大文件里关掉多数减少 UI 重绘) ──────────
"inlayHints": {
"bindingModeHints": {
"enable": false
},
"chainingHints": {
"enable": false
},
"closingBraceHints": {
"enable": false
},
"closureReturnTypeHints": {
"enable": "never"
},
"constructorHints": "never",
"discriminantHints": {
"enable": "never"
},
"expressionAdjustmentHints": {
"mode": "never"
},
"implicitDrops": "never",
"lifetimeElisionHints": {
"enable": "skip_trivial"
},
"maxLength": 100,
"parameterHints": {
"enable": true
},
"reborrowHints": {
"enable": "never"
},
"renderColons": true,
"typeHints": {
"enable": true,
"hideNamedConstructor": false
}
},
// ── 语义高亮(大文件关掉字符串部分减少重绘) ──────────
"semanticHighlights": {
"strings": {
"enable": false
}
},
// ── 高亮关联(关掉减少 LSP 图构建) ──────────────────
"highlightRelated": {
"references": {
"enable": false
}
},
// ── 测试解释器(关掉) ───────────────────────────────
"interpret": {
"tests": false
},
// ── rust-analyzer 文件扫描排除 ─────────────────────
"files": {
"excludeDirs": [
".cargo",
"target",
"target-musl",
"node_modules",
"dist",
".zed",
".git"
]
}
}
}
},
// ── Zed 文件监视排除(减少 File Watcher IO 争抢) ────────────
// 和全局 ~/.config/zed/settings.json 的 file_scan_exclusions 叠加
"file_scan_exclusions": [
"**/target/**",
"**/target-musl/**",
"**/node_modules/**",
"**/dist/**",
"**/.cargo/**",
"**/.git/**",
"**/build/**"
]
}
+22 -201
View File
@@ -1,6 +1,6 @@
# DevFlow — 产研全流程工作流平台 # DevFlow — 产研全流程工作流平台
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证 > 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
## 一、项目定位 ## 一、项目定位
@@ -155,9 +155,9 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的
devflow/ devflow/
├── Cargo.toml # Workspace 根 ├── Cargo.toml # Workspace 根
├── crates/ ├── crates/
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation │ ├── df-core/ # 核心类型、错误、常量、事件
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心) │ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human/Git/HTTP/Notify/Subflow/Docker/自审/推进) │ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator) │ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
│ ├── df-execute/ # 执行运行时 (Shell) │ ├── df-execute/ # 执行运行时 (Shell)
│ ├── df-storage/ # 存储层 (SQLite) │ ├── df-storage/ # 存储层 (SQLite)
@@ -320,9 +320,8 @@ pub struct Decision {
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅ - **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定 - **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现 - **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现 - **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅ - **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
### 5.5 内置节点 (df-nodes) ### 5.5 内置节点 (df-nodes)
@@ -330,25 +329,23 @@ pub struct Decision {
| 节点 | 功能 | 状态 | | 节点 | 功能 | 状态 |
|------|------|------| |------|------|------|
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 | | AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 | | ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 | | HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 | | DockerNode | Docker 容器操作 | ❌ 未实现 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 | | GitNode | Git 操作 (libgit2) | ❌ 未实现 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 | | NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 | | HTTPNode | HTTP 请求 | ❌ 未实现 |
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 | | SubflowNode | 嵌套子工作流 | ❌ 未实现 |
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 含 10 个 `impl Node`(ai/ai_self_review/script/human/task_advance/git/http/notify/subflow/docker,全部已实现)。 > `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
## 六、数据模型 ## 六、数据模型
### SQLite 表结构 ### SQLite 表结构
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V41,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。 > 真相源:`crates/df-storage/src/migrations.rs`(V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
共 **24 张业务表**(另含内部表 `schema_version` 记录迁移版本)。 共 **13 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
```sql ```sql
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores) -- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
@@ -364,8 +361,6 @@ CREATE TABLE ideas (
promoted_to TEXT, -- 晋升后的 project_id (V2) promoted_to TEXT, -- 晋升后的 project_id (V2)
ai_analysis TEXT, -- AI 分析结果 JSON (V2) ai_analysis TEXT, -- AI 分析结果 JSON (V2)
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2) scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24)
deleted_at TEXT, -- 软删回收站 (V28)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -384,27 +379,18 @@ CREATE TABLE projects (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch / V20 补 idea_id / V14 补 deleted_at / -- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch)
-- V15 补 review_rounds / V17 补 output_json / V29 补 queue+parent_id+content_json / V41 补 module_id)
CREATE TABLE tasks ( CREATE TABLE tasks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id), project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
priority INTEGER NOT NULL DEFAULT 2, -- 0=critical..3=low,默认 2=medium priority INTEGER NOT NULL DEFAULT 1,
branch_name TEXT, branch_name TEXT,
assignee TEXT, assignee TEXT,
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2) workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
base_branch TEXT, -- 基础分支 (V2) base_branch TEXT, -- 基础分支 (V2)
idea_id TEXT REFERENCES ideas(id), -- 关联灵感 (V20)
review_rounds INTEGER NOT NULL DEFAULT 0, -- review 退回累计轮数 (V15)
output_json TEXT, -- AiNode 自审闭环产出 (V17)
queue TEXT NOT NULL DEFAULT 'todo', -- 管理池 backlog/todo/decision/active/done (V29)
parent_id TEXT REFERENCES tasks(id), -- 父任务(限 1 级嵌套) (V29)
content_json TEXT, -- 结构化需求规格 JSON (V29)
module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL, -- 关联工程 (V41)
deleted_at TEXT, -- 软删回收站 (V14)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -445,8 +431,7 @@ CREATE TABLE workflow_executions (
project_id TEXT, -- 关联项目 ID (V2) project_id TEXT, -- 关联项目 ID (V2)
task_id TEXT, -- 关联任务 ID (V2) task_id TEXT, -- 关联任务 ID (V2)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
completed_at TEXT, completed_at TEXT
updated_at TEXT -- 更新时间戳,排序/增量同步 (V33)
); );
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照 -- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
@@ -463,7 +448,7 @@ CREATE TABLE node_executions (
completed_at TEXT completed_at TEXT
); );
-- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight) -- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象
CREATE TABLE ai_providers ( CREATE TABLE ai_providers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -474,38 +459,29 @@ CREATE TABLE ai_providers (
models TEXT, -- JSON array of model names models TEXT, -- JSON array of model names
is_default INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0,
config TEXT, -- JSON extra config config TEXT, -- JSON extra config
model_configs TEXT, -- 模型能力配置 JSON (V18)
enabled INTEGER NOT NULL DEFAULT 1, -- 负载均衡池开关 (V19)
weight INTEGER NOT NULL DEFAULT 50, -- 负载均衡权重 (V19)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models / -- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models)
-- V16 补 pinned / V32 补 pinned_goals / V33 补 pending_approvals)
CREATE TABLE ai_conversations ( CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT, title TEXT,
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage (V21 起消息拆分到 ai_messages) messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage
provider_id TEXT, provider_id TEXT,
model TEXT, model TEXT,
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6) models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4) archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16)
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5) prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5) completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
pinned_goals TEXT DEFAULT '[]', -- 目标钉扎持久化 (V32)
pending_approvals TEXT DEFAULT '{}', -- 挂起审批快照,重启恢复 (V33)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 工具执行审计 (V9 建表 / V21 补 message_id / V27 状态统一 executed→completed / V36 补 subtask_id) -- AI 工具执行审计 (V9 建表) — Agent 工具调用的审批/执行留痕
-- Agent 工具调用的审批/执行留痕
CREATE TABLE ai_tool_executions ( CREATE TABLE ai_tool_executions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
conversation_id TEXT, conversation_id TEXT,
message_id TEXT, -- 关联消息 (V21)
tool_call_id TEXT NOT NULL, tool_call_id TEXT NOT NULL,
tool_name TEXT NOT NULL, tool_name TEXT NOT NULL,
arguments TEXT NOT NULL, arguments TEXT NOT NULL,
@@ -514,8 +490,7 @@ CREATE TABLE ai_tool_executions (
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
requested_at TEXT NOT NULL, requested_at TEXT NOT NULL,
executed_at TEXT, executed_at TEXT,
decided_by TEXT, -- human/auto decided_by TEXT -- human/auto
subtask_id TEXT -- 归属子任务 (V36)
); );
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层 -- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
@@ -534,7 +509,6 @@ CREATE TABLE knowledges (
source_ref TEXT, -- 来源实体引用(如 conv:{id}) source_ref TEXT, -- 来源实体引用(如 conv:{id})
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10) reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8) embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -556,159 +530,6 @@ CREATE TABLE app_settings (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 消息 (V9 新库建表 / V21 老库补建 / V36 补 subtask_id / V38 补 token 列 / V39 补 cache/reason 列)
-- V21 起 ChatMessage 逐条拆行存储,支撑消息级 token / 分页 / 溯源
CREATE TABLE ai_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
parts TEXT,
tool_call_id TEXT,
tool_calls TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'active',
reasoning_content TEXT,
timestamp INTEGER,
subtask_id TEXT, -- 归属子任务 (V36)
created_at TEXT NOT NULL,
prompt_tokens INTEGER, -- 消息级输入 token (V38)
completion_tokens INTEGER, -- 消息级输出 token (V38)
prompt_cache_hit_tokens INTEGER, -- 缓存命中 token (V39)
prompt_cache_miss_tokens INTEGER, -- 缓存未命中 token (V39)
reasoning_tokens INTEGER, -- 思考 token (V39)
UNIQUE(conversation_id, seq)
);
-- 灵感评估历史 (V22 建表 / V25 补唯一约束) — 追加型审计,version 单调递增
CREATE TABLE idea_evaluations (
id TEXT PRIMARY KEY,
idea_id TEXT NOT NULL,
version INTEGER NOT NULL,
ai_analysis TEXT,
scores TEXT,
score REAL,
evaluated_by TEXT,
evaluated_at TEXT NOT NULL,
UNIQUE(idea_id, version) -- 评估版本并发重复兜底 (V25)
);
-- 任务横向关联 (V29 建表) — 任务间 depends_on/blocks/relates_to,循环检测走应用层
CREATE TABLE task_links (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES tasks(id),
target_id TEXT NOT NULL REFERENCES tasks(id),
link_type TEXT NOT NULL,
remark TEXT,
created_at TEXT NOT NULL
);
-- 项目事件流 (V30 建表) — 跨实体统一事件,追加型审计(只增不改)
CREATE TABLE project_events (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
from_state TEXT,
to_state TEXT,
context_json TEXT,
source TEXT, -- ai/human/system
conversation_id TEXT,
created_at TEXT NOT NULL
);
-- 项目基础设施配置 (V31 建表) — 记录"项目用了什么基础设施",不存敏感凭证
CREATE TABLE project_services (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
service_type TEXT NOT NULL, -- mysql/postgresql/sqlite/redis/mongodb/mq/api/other
endpoint TEXT,
config_json TEXT,
environment TEXT NOT NULL DEFAULT 'development',
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程 (V34 建表 / V40 补 description + status) — 项目下多工程(Monorepo/微服务/前后端分离)
CREATE TABLE project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
git_url TEXT,
stack TEXT,
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
description TEXT, -- 工程职责描述 (V40)
status TEXT, -- active/archived (V40)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程依赖 (V35 建表) — 工程间依赖边,支撑依赖图
CREATE TABLE module_dependencies (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
dep_type TEXT NOT NULL DEFAULT 'library', -- library/api/mq/shared/custom
label TEXT,
created_at TEXT NOT NULL
);
-- 多 Agent 并行执行 (V36 建表) — ai_plans/ai_subtasks/ai_conflicts 三表 + ai_messages/ai_tool_executions 补 subtask_id
CREATE TABLE ai_plans (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
user_message_id TEXT,
status TEXT NOT NULL DEFAULT 'planning',
subtask_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_subtasks (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
persona_id TEXT,
intent TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
layer INTEGER NOT NULL DEFAULT 0,
deps TEXT,
branch TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_conflicts (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
file_path TEXT NOT NULL DEFAULT '',
conflict_type TEXT NOT NULL DEFAULT 'file',
subtask_a TEXT,
subtask_b TEXT,
diff_a TEXT,
diff_b TEXT,
resolution TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT,
created_at TEXT NOT NULL,
resolved_at TEXT
);
-- 对话版本化快照 (V37 建表)
CREATE TABLE conversation_checkpoints (
id TEXT PRIMARY KEY,
conv_id TEXT NOT NULL,
snapshot TEXT NOT NULL,
token_total INTEGER NOT NULL,
label TEXT,
created_at TEXT NOT NULL
);
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录 -- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
``` ```
@@ -731,7 +552,7 @@ CREATE TABLE conversation_checkpoints (
## 八、Phase 规划 ## 八、Phase 规划
### Phase 1 — 引擎骨架 (4-6 周) ### Phase 1 — 引擎骨架 (4-6 周)
- df-types + df-workflow (DAG + Node trait + Executor) - df-core + df-workflow (DAG + Node trait + Executor)
- df-storage (SQLite 基础表) - df-storage (SQLite 基础表)
- df-execute (Shell 执行) - df-execute (Shell 执行)
- 最小前端:项目列表 + 工作流执行日志 - 最小前端:项目列表 + 工作流执行日志
-25
View File
@@ -1,25 +0,0 @@
# DevFlow 工作规范
## 任务看板自动领单(最高优先级纪律)
1. **会话启动即读板**:任何会话开工前,先读 `docs/todo.md`——按优先级领任务:🔴 高危项 > B 批次/编号项 > C 类候选。
2. **批收敛即回读**:每完成一个任务批(提交后),重读看板领下一项——挂机/连续工作模式下每批必回读,禁凭记忆连续开工。
3. **领单动作**:领单后将条目改为 `[→]`(进行中),完成后改 `[x]` 并附验收命令输出(G4);无法完成的改 `[!]` 附卡点,禁静默跳过。
4. **🔴 门禁(G2)**:看板存在未清零 🔴 条目时,只允许领 🔴 销账任务,新功能任务禁领(除非用户明示)。
5. **提交纪律(G3/G4)**:提交描述与 diff 机械对应;一笔一事或逐件列全;分组提交按看板标注的主题分组。
6. **汇报实测**:任何进度/完成汇报,先跑 `git log -1 --format="%h %s"` 与 `git status --short | wc -l` 实测再报,禁凭记忆报状态。
7. **看板顶部「挂机必读」警示**:启动时若存在,视为最高优先级上下文覆盖。
## 自主修复纪律
- 明确 bug(实证在案/用户指出/唯一正确解或用户已给修法方向):直接修并报告,禁反问「要不要修」(2026-10-06 用户纠正:发现换装脚本缺陷后反问=自主性差)。
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
## 命名与代码风格(2026-10-10 用户重申)
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
## 其他规范
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
Generated
+50 -1095
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -22,15 +22,3 @@ regex = "1"
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。 # Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。 # 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] } keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
dashmap = "6"
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
# ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
# (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
# 根因是增量编译 CGU 划分与跨 crate 内联冲突。cargo check 不链接故不暴露,
# cargo build/tauri build 链接期必挂。release 禁用 incremental 为 Rust 官方推荐。
# [profile.release]
# incremental = true
@@ -1,6 +1,6 @@
# DevFlow — 项目进展与工作交接 # DevFlow — 项目进展与工作交接
> 创建: 2026-06-10 | 最后更新: 2026-07-02 | 当前阶段: 多 Agent 并行执行架构落地完成(Plan DAG + worktree 隔离 + JoinSet 并行 + Reviewer 仲裁 + 模板系统 + 全节点补齐),详见 [Batch.md](Batch.md) > 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量);2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
--- ---
@@ -11,9 +11,9 @@
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 | | 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite | | 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` | | 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) | | 架构文档 | `ARCHITECTURE.md` (22,745 字) |
| Git 状态 | 已纳入版本控制(main 分支) | | Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
| AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 | | AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
--- ---
@@ -23,7 +23,7 @@
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 | | Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------| |-------|--------|--------|--------|---------|
| df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation | | df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 | | df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 | | df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) | | df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) |
@@ -59,7 +59,7 @@
``` ```
真实可用路径(有端到端逻辑): 真实可用路径(有端到端逻辑):
df-types (类型基础) df-core (类型基础)
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus) → df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
→ df-execute/shell (tokio::process Shell 执行) → df-execute/shell (tokio::process Shell 执行)
→ df-storage (SQLite 连接 + 迁移 + 建表) → df-storage (SQLite 连接 + 迁移 + 建表)
@@ -98,7 +98,7 @@
| 任务 | 状态 | 说明 | | 任务 | 状态 | 说明 |
|------|------|------| |------|------|------|
| df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation | | df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 |
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus | | df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD | | df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 | | df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
@@ -190,7 +190,7 @@
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC(三代理并行) ### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC(三代理并行)
**工作内容**(三个子代理并行,目录互不重叠): **工作内容**(三个子代理并行,目录互不重叠):
- **Migrations V2**(df-storage/df-types):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列 - **Migrations V2**(df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列
- **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层) - **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层)
- **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode - **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
@@ -665,7 +665,7 @@
**遗留 / 下一步(修复批次建议)**: **遗留 / 下一步(修复批次建议)**:
- **批1 数据安全**: ①(AI delete 改 soft_delete)+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法) - **批1 数据安全**: ①(AI delete 改 soft_delete)+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-types)+ ⑤(tool_registry 复用 allowed_columns_for) - **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-core)+ ⑤(tool_registry 复用 allowed_columns_for)
- **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal) - **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal)
- ⚪ 可选: ⑨⑩⑪⑫ 按需 - ⚪ 可选: ⑨⑩⑪⑫ 按需
@@ -1031,46 +1031,17 @@
--- ---
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进 ### 2026-06-28 — 最新对话 DB & Log 诊断:审批死锁 + Windows 兼容 + 标题 LLM 失败
> 本轮(Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。 > 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。
> 代码变更 50 文件,+2454/-1251 行,未提交。 > 7 次工具连续失败后 L1 断路器熔断,1 条 pending 审批导致全链路死锁。
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)** - [ ] **P0 — 审批 pending 超时自动取消**:`try_continue` 入口清理 >5min 的 pending。
- [ ] **P0 — `create_project` 加 auto_create_dir**:消除建项目→建目录→绑定的死锁链。
| Phase | 内容 | - [ ] **P1 — `run_command` 失败追加 shell 适配提示**:引导 LLM 改正 PowerShell 命令。
|---|---| - [ ] **P1 — PowerShell 反斜杠自动转义**:消除 `os error 123`(Unicode 转义误识别)。
| Phase0-1 | DashMap 骨架 + AppState 接入(Claude Code 完成)| - [ ] **P2 — 标题摘要合并连续同 role**:避免标题 LLM 的 GLM 1214 拒绝。
| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states | - [ ] **P3 — migration V33 + L0-handshake 防抖**。
| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 |
| Phase4 | conversation_delete 同步 conv_states.remove |
**前端修复**
- P0-1: watchdog `convStates.clear()` → `delete(activeConversationId)`
- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正
- 每轮 token 独立显示(🔣 XXX in · YYY out)
- STREAM_TIMEOUT_MS 45s → 90s
**后端修复**
- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离
- `env_snapshot.rs` 5s 超时兜底(Windows Store alias 卡死)
- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留
- `openai_compat.rs` send 阶段 60s timeout
- 原生 SSE 解析器替代 eventsource-stream(Windows 兼容)
- run_agentic_loop 20s AiHeartbeat 保活
- grep 正则自动修复(未分组 `|` → `(?:...)` 包裹)
- read_file 路径不存在提示 list_directory
**标题策略改进**
- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取)
- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题
**代码审查清理**
- 修复 lib.rs 重复 handler 注册(🔴 严重)
- 清理 unused import / [STUCK] 调试日志 / 过时注释
- 340 tests passed, vue-tsc clean
**待提交**:50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复
## 七、开发约定 ## 七、开发约定
File diff suppressed because it is too large Load Diff
+25 -28
View File
@@ -12,7 +12,6 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
@@ -22,7 +21,6 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
@@ -4717,6 +4715,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4730,6 +4729,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4743,6 +4743,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4756,6 +4757,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4769,6 +4771,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4782,6 +4785,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4795,6 +4799,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4808,6 +4813,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4821,6 +4827,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4834,6 +4841,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4847,6 +4855,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4860,6 +4869,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4873,6 +4883,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4886,6 +4897,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4899,6 +4911,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4912,6 +4925,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4925,6 +4939,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4938,6 +4953,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4951,6 +4967,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4964,6 +4981,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4977,6 +4995,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4990,6 +5009,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5003,6 +5023,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5016,6 +5037,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5029,6 +5051,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5057,16 +5080,6 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
@@ -8039,15 +8052,6 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -13006,13 +13010,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz",
+1 -3
View File
@@ -7,7 +7,7 @@
"dev:h5": "uni", "dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin", "dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build", "build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs", "build:mp-weixin": "uni build -p mp-weixin",
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -15,7 +15,6 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
@@ -25,7 +24,6 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
-69
View File
@@ -1,69 +0,0 @@
//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题
//!
//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?<!1)")`,
//! 微信开发者工具转译器静态检测到 `(?<=` 字符串 → 误判需 @swc/helpers/_wrap_reg_exp
//! helper → 注入 require → 产物无此模块 → 小程序白屏。
//!
//! 微信基础库运行时不支持 lookbehind,marked 本就走降级路径(Te=false)。
//! 本脚本把产物里的 lookbehind 检测替换为 Te=false,绕过微信工具静态误判,
//! 同时保留 marked 完整功能(运行时本就降级)。
//!
//! 用法:build 后运行 `node scripts/fix-mp-build.cjs`
const fs = require('fs')
const path = require('path')
const BUILD_DIR = path.join(__dirname, '..', 'dist', 'build', 'mp-weixin')
const VENDOR = path.join(BUILD_DIR, 'common', 'vendor.js')
function fixLookbehind() {
if (!fs.existsSync(VENDOR)) {
console.warn('[fix-mp-build] vendor.js 不存在,跳过:', VENDOR)
return
}
let src = fs.readFileSync(VENDOR, 'utf8')
const before = src
// marked v18 lookbehind 检测 `var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?<!1)"+l)}catch{return!1}})()`
// 替换为 `var Te=false`(强制降级,微信基础库不支持 lookbehind)
src = src.replace(
/var Te=\(\([^)]*\)=>\{try\{return!!new RegExp\("\(\?<=1\)\(\?<!1\)"[^}]*\}\}\)\)\(\),?/,
'var Te=false,'
)
if (src === before) {
// 未命中(压缩名可能不是 Te),退而按字符串替换 lookbehind 检测
src = src.replace(/new RegExp\("\(\?<=1\)\(\?<!1\)"[^)]*\)/g, 'false')
}
// 关键:marked 条件分支里还有 `(?<!`)` lookbehind 字面量(Te 为 true 时的分支)。
// 微信工具静态检测到任何 `(?<=` / `(?<!` 字符都会注入 swc helper 误判。
// 把所有 lookbehind 字面量替换为非 lookbehind 等价形式。
// (?:x) 合法不 throw;但该分支仅 Te=true 时用,运行时 Te=false 恒走降级分支,
// 替换后行为不变,同时消除微信工具的静态触发器。
src = src.replace(/\(\?<!`\)/g, '(?:x)')
src = src.replace(/\(\?<=1\)\(\?<!1\)/g, '(?:1)')
// 命名捕获组(ES2018)也触发 _wrap_reg_exp。marked v18 用 `(?<a>`+)[^`]+\k<a>` 匹配代码围栏。
// 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。
// 通用: (?<name>...) → (...), \k<name> → \N(按捕获组序编号,这里只有 a/b 两组)
src = src.replace(/\(\?<a>\`\+\)\[^\`\]\+\\k<a>/g, '(`+)\\1')
src = src.replace(/\(\?<b>\`\+\)\[^\`\]\+\\k<b>/g, '(`+)\\1')
// 兜底:任何残留命名捕获组 (?<name> 或 \k<name> 转为无命名
src = src.replace(/\(\?<[a-z][a-z0-9_]*>/g, '(')
src = src.replace(/\\k<[a-z]>/g, '\\1')
if (src !== before) {
fs.writeFileSync(VENDOR, src)
console.log('[fix-mp-build] vendor.js lookbehind 已全部替换(检测+条件分支)')
} else {
console.warn('[fix-mp-build] 未找到 lookbehind(可能已处理或格式不同)')
}
}
// 清理手动补的 @swc/helpers(不再需要,避免污染)
function cleanupSwcHelpers() {
const swcDir = path.join(BUILD_DIR, 'common', '@swc', 'helpers')
if (fs.existsSync(swcDir)) {
fs.rmSync(swcDir, { recursive: true, force: true })
console.log('[fix-mp-build] 已清理手动补的 @swc/helpers')
}
}
fixLookbehind()
cleanupSwcHelpers()
+14 -29
View File
@@ -1,11 +1,10 @@
/** /**
* u-relay WS 客户端(uni-app 跨端适配) * df-relay WS 客户端(uni-app 跨端适配)
* *
* 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection): * 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/commander` * 1. 建立 WS 连接到 `/ws/miniapp`
* 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时) * 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
* 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close * 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
* (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed)
* *
* 跨端适配: * 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket) * - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
@@ -34,9 +33,6 @@
import { getConfig } from '@/config' import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay' import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */
const DEBUG = import.meta.env.DEV
/** WS 连接状态 */ /** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting' export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
@@ -90,8 +86,6 @@ class WsClient {
private reconnectAttempts = 0 private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */ /** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false private manualClose = false
/** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */
private recoverable = false
/** /**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。 * 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪, * 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
@@ -118,8 +112,7 @@ class WsClient {
return return
} }
this.manualClose = false this.manualClose = false
this.recoverable = false // 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
// 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试)
this.reconnectAttempts = 0 this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连 // 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow() this.reconnectNow()
@@ -145,9 +138,7 @@ class WsClient {
*/ */
resumeIfDisconnected(): void { resumeIfDisconnected(): void {
if (this.manualClose) return if (this.manualClose) return
// 已达上限 disconnected(recoverable=true)也允许重置计数并重连 if (this.status === 'disconnected' || this.status === 'reconnecting') {
if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) {
this.recoverable = false
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复 // 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0 this.reconnectAttempts = 0
this.reconnectNow() this.reconnectNow()
@@ -177,13 +168,13 @@ class WsClient {
} }
/** /**
* 发送 Command(commander → agent) * 发送 Command(miniapp → device)
* *
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成 * 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(channel_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。 * BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/ */
send(cmd: MiniCommand): boolean { send(cmd: MiniCommand): boolean {
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) { if (!this.socket || !this.handshaked) {
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。 // 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队, // 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
@@ -315,8 +306,8 @@ class WsClient {
this.setStatus('handshaking') this.setStatus('handshaking')
const cfg = getConfig() const cfg = getConfig()
const hello: Hello = { const hello: Hello = {
kind: 'commander', kind: 'miniapp',
channel_id: cfg.channelId, device_id: cfg.deviceId,
token: cfg.token, token: cfg.token,
} }
try { try {
@@ -350,7 +341,7 @@ class WsClient {
} }
const text = res.data const text = res.data
if (!text) return if (!text) return
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测) // 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
this.lastInboundAt = Date.now() this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238) // 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
@@ -517,14 +508,8 @@ class WsClient {
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。 // 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`) console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
// 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer
// 让 connect()/resumeIfDisconnected() 可重置计数后恢复
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连') this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.recoverable = true this.cleanup()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
return return
} }
const cfg = getConfig() const cfg = getConfig()
File diff suppressed because it is too large Load Diff
+16 -98
View File
@@ -1,18 +1,18 @@
/** /**
* miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填) * miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
* *
* 这些值在真实部署前是占位: * 这些值在真实部署前是占位:
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址 * - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
* - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键) * - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
* - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3) * - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
*/ */
export interface MiniappConfig { export interface MiniappConfig {
/** relay WS 地址(含协议),如 'ws://192.168.1.100:9180/ws/commander' 或 'wss://relay.example.com/ws/commander' */ /** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
relayHost: string relayHost: string
/** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */ /** 配对绑定的设备 ID */
channelId: string deviceId: string
/** 配对 token(对齐 u-relay U_RELAY_TOKEN) */ /** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
token: string token: string
/** 心跳间隔(ms) */ /** 心跳间隔(ms) */
heartbeatInterval: number heartbeatInterval: number
@@ -20,117 +20,35 @@ export interface MiniappConfig {
reconnectBaseDelay: number reconnectBaseDelay: number
/** 重连最大间隔(ms) */ /** 重连最大间隔(ms) */
reconnectMaxDelay: number reconnectMaxDelay: number
/** file.1216.top 文件服务上传地址(不带路径,上传时拼 /upload) */
fileUploadBaseUrl: string
/** 上传 X-Source 头(项目隔离,文件存到 file.1216.top 对应目录) */
fileUploadSource: string
} }
/** /**
* 默认配置(占位值,需替换) * 默认配置(占位值,需替换)
* *
* 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。 * 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接, * MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
* 这符合预期,提示用户配置)。 * 这符合预期,提示用户配置)。
*/ */
export const defaultConfig: MiniappConfig = { export const defaultConfig: MiniappConfig = {
// u-relay 现行协议 commander 端点:wss://u-work.1216.top → nginx 终止 TLS → u-relay :9180 明文 ws // 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名) // 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/commander', relayHost: 'wss://u-work.1216.top/ws/miniapp',
// 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端) // 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
// 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程) // 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0', deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token', token: 'devflow-relay-default-token',
heartbeatInterval: 30000, heartbeatInterval: 30000,
reconnectBaseDelay: 1000, reconnectBaseDelay: 1000,
reconnectMaxDelay: 30000, reconnectMaxDelay: 30000,
fileUploadBaseUrl: 'https://file.1216.top',
fileUploadSource: 'devflow',
} }
/** storage key(持久化完整 MiniappConfig JSON) */ /** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
const STORAGE_KEY = 'df-miniapp-config' let _config: MiniappConfig = { ...defaultConfig }
/** 单例配置(null 表示尚未从 storage 加载) */
let _config: MiniappConfig | null = null
/**
* 旧版字段迁移(df-relay 一代 → u-relay):
* storage 里的旧 JSON 含 deviceId 字段,映射为 channelId,避免老用户升级后配置丢失。
*/
function migrateLegacyFields(parsed: Record<string, unknown>): Partial<MiniappConfig> {
const out = { ...parsed } as Record<string, unknown>
if (out.deviceId !== undefined && out.channelId === undefined) {
out.channelId = out.deviceId
}
delete out.deviceId
// 旧 relayHost 指向已下线的 /ws/miniapp 端点,升级后强制重置为新端点
if (typeof out.relayHost === 'string' && out.relayHost.includes('/ws/miniapp')) {
out.relayHost = defaultConfig.relayHost
}
return out as Partial<MiniappConfig>
}
/**
* 从 storage 读取并合并默认配置
*
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
*/
function loadFromStorage(): MiniappConfig {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (!raw) return { ...defaultConfig }
const parsed = JSON.parse(raw) as Record<string, unknown>
// 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined
const migrated = migrateLegacyFields(parsed)
return { ...defaultConfig, ...migrated }
} catch (e) {
console.warn('[df-miniapp] load config from storage failed:', e)
return { ...defaultConfig }
}
}
/**
* 同步写入 storage(JSON 序列化完整配置)
*
* 容错:写入失败仅 warn,不影响内存配置。
*/
function saveToStorage(config: MiniappConfig): void {
try {
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
} catch (e) {
console.warn('[df-miniapp] save config to storage failed:', e)
}
}
export function getConfig(): MiniappConfig { export function getConfig(): MiniappConfig {
// 首次调用时从 storage 加载并缓存
if (_config === null) {
_config = loadFromStorage()
}
return _config return _config
} }
export function setConfig(patch: Partial<MiniappConfig>): void { export function setConfig(patch: Partial<MiniappConfig>): void {
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
if (_config === null) {
_config = loadFromStorage()
}
_config = { ..._config, ...patch } _config = { ..._config, ...patch }
saveToStorage(_config)
}
/**
* 重置为默认配置并清除 storage 持久化数据
*
* 用于用户重新配对或恢复出厂占位值的场景。
*/
export function resetConfig(): void {
_config = { ...defaultConfig }
try {
uni.removeStorageSync(STORAGE_KEY)
} catch (e) {
console.warn('[df-miniapp] remove config from storage failed:', e)
}
} }
+53
View File
@@ -0,0 +1,53 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}
-10
View File
@@ -13,12 +13,6 @@
"navigationBarTitleText": "会话列表", "navigationBarTitleText": "会话列表",
"enablePullDownRefresh": true "enablePullDownRefresh": true
} }
},
{
"path": "pages/settings/index",
"style": {
"navigationBarTitleText": "设置"
}
} }
], ],
"globalStyle": { "globalStyle": {
@@ -41,10 +35,6 @@
{ {
"pagePath": "pages/conversations/index", "pagePath": "pages/conversations/index",
"text": "会话" "text": "会话"
},
{
"pagePath": "pages/settings/index",
"text": "设置"
} }
] ]
} }
File diff suppressed because it is too large Load Diff
@@ -1,28 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app' import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat' import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events' import type { Conversation } from '@/types/events'
const ai = useAiChat() const ai = useAiChat()
/**
* mp-weixin 模板对嵌套在对象中的 reactive/ref 响应性追踪有问题。
* 用本地 ref + watch 同步,确保模板直接拿到响应式数组。
*/
const conversations = ref<Conversation[]>([])
/**
* 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
*
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
*/
watch(ai.conversations, (list) => {
conversations.value = [...(list || [])]
}, { immediate: true })
/** 点击会话项切换 */ /** 点击会话项切换 */
function handleSelect(convId: string): void { function handleSelect(convId: string): void {
ai.switchConversation(convId) ai.switchConversation(convId)
@@ -84,20 +66,13 @@ function formatTime(ts?: number): string {
<!-- 会话列表 --> <!-- 会话列表 -->
<scroll-view class="conv-list" scroll-y> <scroll-view class="conv-list" scroll-y>
<!-- 加载中骨架屏 --> <view v-if="ai.conversations.length === 0" class="empty-hint">
<view v-if="conversations.length === 0 && ai.loading.value" class="conv-skeleton">
<view v-for="i in 5" :key="i" class="conv-sk-item">
<view class="conv-sk-line conv-sk-w70"></view>
<view class="conv-sk-line conv-sk-w40"></view>
</view>
</view>
<view v-else-if="conversations.length === 0" class="empty-hint">
<text>暂无历史会话</text> <text>暂无历史会话</text>
<text class="empty-sub">在桌面端创建后,此处同步</text> <text class="empty-sub">在桌面端创建后,此处同步</text>
</view> </view>
<view <view
v-for="conv in conversations" v-for="conv in ai.conversations"
:key="conv.id" :key="conv.id"
class="conv-item" class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }" :class="{ active: conv.id === ai.activeConversationId.value }"
@@ -110,7 +85,7 @@ function formatTime(ts?: number): string {
</view> </view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text> <text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view> </view>
<view v-if="conversations.length > 0" class="list-hint"> <view v-if="ai.conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text> <text>长按会话可重命名</text>
</view> </view>
</scroll-view> </scroll-view>
@@ -211,26 +186,4 @@ function formatTime(ts?: number): string {
color: $uni-text-color-disable; color: $uni-text-color-disable;
font-size: $uni-font-size-sm; font-size: $uni-font-size-sm;
} }
/* 骨架屏(下拉刷新/冷启动占位) */
.conv-skeleton {
padding: 24rpx;
}
.conv-sk-item {
margin-bottom: 32rpx;
padding: 24rpx;
}
.conv-sk-line {
height: 24rpx;
margin: 8rpx 0;
background: linear-gradient(90deg, $uni-bg-color-light 25%, #3a3a3a 50%, $uni-bg-color-light 75%);
background-size: 200% 100%;
animation: conv-sk-shimmer 1.5s infinite;
border-radius: 8rpx;
}
.conv-sk-w70 { width: 70%; }
.conv-sk-w40 { width: 40%; }
@keyframes conv-sk-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style> </style>
@@ -1,322 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { getConfig, setConfig } from '@/config'
import { useAiChat } from '@/composables/useAiChat'
/**
* 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。
*
* 默认查看模式(只读展示 relayHost/channelId/token,token 脱敏);点「编辑」进入编辑模式,
* 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」,
* 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。
*
* 数据源:
* - 连接配置:config.ts getConfig()(storage 持久化,含默认值合并)
* - 连接状态:useAiChat() 单例(wsStatus + deviceOnline,实时)
* - 版本号:manifest.json versionName(手动维护此处常量,对齐 0.1.0)
*/
// 连接配置(只读快照,页面 onShow 时刷新,切 tab 回来看最新值)
const cfg = computed(() => getConfig())
// 连接状态(实时,useAiChat 单例 ref 直接响应)
const ai = useAiChat()
const wsStatus = computed(() => ai.wsStatus.value)
const wsStatusDetail = computed(() => ai.wsStatusDetail.value)
const deviceOnline = computed(() => ai.deviceOnline.value)
const isWsConnected = computed(() => ai.isWsConnected.value)
/** 连接状态中文文案 + 颜色 class(对齐 chat 页 STATUS_TEXT 口径) */
const STATUS_META: Record<string, { label: string; cls: string }> = {
connected: { label: '已连接', cls: 'st-ok' },
connecting: { label: '连接中…', cls: 'st-wait' },
handshaking: { label: '握手中…', cls: 'st-wait' },
reconnecting: { label: '重连中…', cls: 'st-warn' },
disconnected: { label: '已断开', cls: 'st-err' },
}
const statusText = computed(() => {
if (isWsConnected.value) {
return deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_META[wsStatus.value]?.label || wsStatus.value
})
const statusCls = computed(() => {
if (isWsConnected.value) return deviceOnline.value ? 'st-ok' : 'st-warn'
return STATUS_META[wsStatus.value]?.cls || 'st-err'
})
/** token 脱敏(只显前 4 位 + 星号,查看页不暴露全量凭据) */
function maskToken(token: string): string {
if (!token) return '(未设置)'
if (token.length <= 8) return '*'.repeat(token.length)
return `${token.slice(0, 4)}${'*'.repeat(Math.min(token.length - 4, 12))}`
}
/** 毫秒转可读(30000 → 30s) */
function fmtMs(ms: number): string {
return ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`
}
/** 版本号(对齐 manifest.json versionName,改版时同步) */
const APP_VERSION = '0.1.0'
/** 手动重连(断开态可点)。用 ai.connect() 而非 resumeIfDisconnected:
* 用户先点「断开」(manualClose=true)后 resumeIfDisconnected 会因 manualClose 早退,
* connect() 复位 manualClose 强制开新连接(非连接态才生效,连接态自动幂等跳过)。 */
function onReconnect(): void {
ai.connect()
}
/** 手动断开(连接态可点,disconnect 置 manualClose 后不再自动重连) */
function onDisconnect(): void {
ai.disconnect()
uni.showToast({ title: '已断开连接', icon: 'none' })
}
/** 连接操作按钮入口(连接态→断开,断开态→重连) */
function onConnOp(): void {
if (isWsConnected.value) onDisconnect()
else onReconnect()
}
/** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */
const editing = ref(false)
const draftRelayHost = ref('')
const draftChannelId = ref('')
const draftToken = ref('')
/** 进入编辑模式:以当前配置填充草稿 */
function enterEdit(): void {
draftRelayHost.value = cfg.value.relayHost
draftChannelId.value = cfg.value.channelId
draftToken.value = cfg.value.token
editing.value = true
}
/** 取消编辑:丢弃草稿回查看模式(不保存) */
function cancelEdit(): void {
editing.value = false
}
/** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */
function saveEdit(): void {
const relayHost = draftRelayHost.value.trim()
const channelId = draftChannelId.value.trim()
if (!relayHost || !channelId) {
uni.showToast({ title: '中继地址和通道 ID 不能为空', icon: 'none' })
return
}
setConfig({ relayHost, channelId, token: draftToken.value.trim() })
editing.value = false
uni.showToast({ title: '已保存,正在重连…', icon: 'none' })
ai.resumeIfDisconnected()
}
</script>
<template>
<view class="settings-page">
<!-- 连接状态卡片(实时,断开可点重连) -->
<view class="card">
<view class="card-head">
<text class="card-title">连接状态</text>
</view>
<view class="row">
<text class="row-label">当前状态</text>
<text class="row-value status" :class="statusCls" @tap="onReconnect">{{ statusText }}</text>
</view>
<view class="row">
<text class="row-label">中继地址</text>
<text class="row-value mono">{{ cfg.relayHost }}</text>
</view>
<view class="row" v-if="wsStatusDetail">
<text class="row-label">最近状态</text>
<text class="row-value">{{ wsStatusDetail }}</text>
</view>
<view class="ops">
<view class="btn btn-block" :class="isWsConnected ? 'btn-danger' : 'btn-primary'" @tap="onConnOp">
{{ isWsConnected ? '断开' : '重连' }}
</view>
</view>
</view>
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/channelId/token) -->
<view class="card">
<view class="card-head">
<text class="card-title">连接配置</text>
<view v-if="editing" class="card-actions">
<text class="btn btn-sm btn-ghost" @tap="cancelEdit">取消</text>
<text class="btn btn-sm btn-primary" @tap="saveEdit">保存</text>
</view>
<text v-else class="btn btn-sm btn-ghost" @tap="enterEdit">编辑</text>
</view>
<view class="row">
<text class="row-label">中继地址</text>
<input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/commander" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.relayHost }}</text>
</view>
<view class="row">
<text class="row-label">通道 ID</text>
<input v-if="editing" v-model="draftChannelId" class="row-input" placeholder="u-relay 通道 ID" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.channelId }}</text>
</view>
<view class="row">
<text class="row-label">配对 Token</text>
<input v-if="editing" v-model="draftToken" class="row-input" placeholder="配对 token" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ maskToken(cfg.token) }}</text>
</view>
</view>
<!-- 高级参数 -->
<view class="card">
<view class="card-head">
<text class="card-title">高级参数</text>
</view>
<view class="row">
<text class="row-label">心跳间隔</text>
<text class="row-value">{{ fmtMs(cfg.heartbeatInterval) }}</text>
</view>
<view class="row">
<text class="row-label">重连初始间隔</text>
<text class="row-value">{{ fmtMs(cfg.reconnectBaseDelay) }}</text>
</view>
<view class="row">
<text class="row-label">重连最大间隔</text>
<text class="row-value">{{ fmtMs(cfg.reconnectMaxDelay) }}</text>
</view>
</view>
<!-- 关于 -->
<view class="card">
<view class="card-head">
<text class="card-title">关于</text>
</view>
<view class="row">
<text class="row-label">版本</text>
<text class="row-value">DevFlow Mini v{{ APP_VERSION }}</text>
</view>
<view class="row">
<text class="row-label">说明</text>
<text class="row-value">远程 AI Chat · 跨端操作桌面端开发助手</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.settings-page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background-color: $uni-bg-color;
}
.card {
margin-bottom: 24rpx;
padding: 24rpx;
background-color: $uni-bg-color-light;
border-radius: 16rpx;
}
.card-head {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16rpx;
}
.card-title {
font-size: $uni-font-size-base;
font-weight: bold;
color: $uni-text-color;
}
.card-sub {
font-size: $uni-font-size-sm;
color: $uni-text-color-disable;
}
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx solid $uni-border-color;
}
.row:last-child {
border-bottom: none;
}
.row-label {
font-size: $uni-font-size-sm;
color: $uni-text-color-grey;
}
.row-value {
flex: 1;
margin-left: 24rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color;
text-align: right;
word-break: break-all;
}
.row-value.mono {
font-family: monospace;
}
.row-value.status {
font-weight: bold;
}
.st-ok { color: #6bd99a; }
.st-wait { color: $uni-color-primary; }
.st-warn { color: #f0c75e; }
.st-err { color: #e85a4f; }
/* 连接操作按钮(连接状态卡:重连/断开) */
.ops {
margin-top: 8rpx;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8rpx 24rpx;
font-size: $uni-font-size-sm;
line-height: 1.6;
border-radius: 10rpx;
}
.btn-block {
display: block;
padding: 20rpx 32rpx;
text-align: center;
}
.btn-primary {
background-color: $uni-color-primary;
color: #fff;
}
.btn-danger {
background-color: #e85a4f;
color: #fff;
}
.btn-ghost {
color: $uni-color-primary;
border: 1rpx solid $uni-color-primary;
background-color: transparent;
}
/* 卡片头部按钮组(编辑/取消/保存) */
.card-actions {
display: flex;
flex-direction: row;
align-items: center;
}
.card-actions .btn {
margin-left: 16rpx;
}
.btn-sm {
padding: 4rpx 20rpx;
}
/* 编辑态输入(label 左 + input 右,横向一行对齐 .row-value) */
.row-input {
flex: 1;
margin-left: 24rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color;
text-align: right;
}
.row-ph {
color: $uni-text-color-disable;
}
</style>
-33
View File
@@ -49,12 +49,6 @@ export type AiChatEvent =
total_tokens: number total_tokens: number
prompt_tokens: number prompt_tokens: number
completion_tokens: number completion_tokens: number
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价真实,前端 in 显示用此)/思考 */
prompt_cache_hit_tokens: number
prompt_cache_miss_tokens: number
reasoning_tokens: number
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
is_estimated: boolean
incomplete?: boolean | null incomplete?: boolean | null
conversation_id?: string | null conversation_id?: string | null
} }
@@ -95,29 +89,10 @@ export type AiChatEvent =
tasks: TaskRecord[] tasks: TaskRecord[]
ideas: IdeaRecord[] ideas: IdeaRecord[]
} }
// 每会话独立模型:对齐后端 list_models(device 读活跃 provider enabled 模型,跨端回推)。
// 全局事件(无 conversation_id),miniapp 顶栏模型选择器消费 models + 默认 default_model。
| {
type: 'AiModelList'
provider_id: string
default_model: string
models: ModelInfo[]
}
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */ /** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
export type AiChatEventType = AiChatEvent['type'] export type AiChatEventType = AiChatEvent['type']
/**
* 模型信息(每会话独立模型,对齐后端 list_models 返回)。
* provider 当前 enabled 模型列表,label 为展示名(可选,缺省用 model_id)。
*/
export interface ModelInfo {
/** 模型 id(选中后透传 model_override) */
model_id: string
/** 展示名(可选) */
label?: string
}
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */ /** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
export interface Conversation { export interface Conversation {
id: string id: string
@@ -141,8 +116,6 @@ export interface ChatMessage {
toolCalls?: AiToolCallInfo[] toolCalls?: AiToolCallInfo[]
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */ /** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
incomplete?: boolean incomplete?: boolean
/** 图片输入:消息携带的图片 URL 列表(预览用,发送时透传) */
images?: string[]
} }
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */ /** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
@@ -168,12 +141,6 @@ export interface TokenUsage {
prompt: number prompt: number
completion: number completion: number
total: number total: number
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考,对齐桌面端 */
cache_hit?: number
cache_miss?: number
reasoning?: number
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
is_estimated?: boolean
} }
// ============================================================ // ============================================================
+35 -66
View File
@@ -1,47 +1,44 @@
/** /**
* u-relay 协议类型镜像(对齐 E:/wk-lab/u-relay/src/) * df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
* *
* 类型证据: * 类型证据:
* - ClientKind / MessageKind:u-relay/src/broadcast.rs * - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
* (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control') * (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
* - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts) * - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
* - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token) * - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
* kind 用 ClientKindWire,序列化为 'agent'/'commander' * kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
* - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端) * - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
* *
* 关键协议点(relay.rs handle_inbound_text): * 关键协议点(relay.rs:332-365 handle_inbound_text):
* relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。 * relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。 * 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
* 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage * 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
* 再从 payload 取 AiChatEvent。 * 再从 payload 取 AiChatEvent。
*
* 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线,
* 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。
*/ */
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */ /** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
export type ClientKind = 'agent' | 'commander' export type ClientKind = 'device' | 'miniapp'
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */ /** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
export type MessageKind = 'event' | 'command' | 'control' export type MessageKind = 'event' | 'command' | 'control'
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */ /** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
export type PresenceState = 'online' | 'offline' export type PresenceState = 'online' | 'offline'
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number;0 为 NIL 伪值 */ /** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
export type ConnId = number export type ConnId = number
/** /**
* 中继路由消息骨架(对齐 Rust BroadcastMessage) * 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
* *
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。 * miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
*/ */
export interface BroadcastMessage { export interface BroadcastMessage {
/** 配对绑定的通道 ID */ /** 配对绑定的设备 ID */
channel_id: string device_id: string
/** 消息语义 */ /** 消息语义 */
kind: MessageKind kind: MessageKind
/** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */ /** 消息来源连接 ID(relay 分配,u64) */
source: ConnId source: ConnId
/** 来源客户端类型 */ /** 来源客户端类型 */
from: ClientKind from: ClientKind
@@ -52,36 +49,36 @@ export interface BroadcastMessage {
} }
/** /**
* 客户端首消息:身份宣告(对齐 Rust Hello) * 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
* *
* 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。 * 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
* kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。 * kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
*/ */
export interface Hello { export interface Hello {
/** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */ /** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
kind: 'commander' kind: 'miniapp' | 'device'
/** 配对绑定的通道 ID(非空,≤128B,无控制字符) */ /** 配对绑定的设备 ID(用户首次配置时生成) */
channel_id: string device_id: string
/** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */ /** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
token: string token: string
} }
/** /**
* 控制面消息(对齐 Rust ControlMessage,tag = "control_kind") * 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
* *
* miniapp 主要发 Ping 心跳(relay 本地直回 pong,不经对端);Pair/Heartbeat/Presence 骨架保留。 * miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
*/ */
export type ControlMessage = export type ControlMessage =
| { control_kind: 'pair'; channel_id: string; token: string } | { control_kind: 'pair'; device_id: string; token: string }
| { control_kind: 'heartbeat'; channel_id: string } | { control_kind: 'heartbeat'; device_id: string }
| { control_kind: 'presence'; channel_id: string; state: PresenceState } | { control_kind: 'presence'; device_id: string; state: PresenceState }
| { control_kind: 'ping' } | { control_kind: 'ping' }
| { control_kind: 'pong' } | { control_kind: 'pong' }
/** /**
* 小程序(控制端)→ 桌面端(执行端)Command 协议 * 小程序 → 桌面端 Command 协议(miniapp 发起操作)
* *
* 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。 * 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
* 字段: * 字段:
* - cmd:命令名(对齐 src-tauri command 函数名) * - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数) * - args:命令参数(对齐各 command 的参数)
@@ -92,7 +89,6 @@ export type ControlMessage =
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取) * - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts) * - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23) * - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
* - list_models(每会话独立模型,模型选择器拉取 provider enabled 模型列表,见 useAiChat.ts)
*/ */
export interface MiniCommand { export interface MiniCommand {
/** Tauri command 名 */ /** Tauri command 名 */
@@ -106,12 +102,6 @@ export interface SendMessageArgs {
message: string message: string
conversation_id?: string | null conversation_id?: string | null
model_override?: string | null model_override?: string | null
/**
* 跨端新会话语义(2026-08-05):miniapp 新建会话(active=null)后首次发送置 true,
* device 端据此强制创建新 conv id(不走 active 兜底,防串旧会话)。
* 普通发送/已传 conversation_id 时不传(默认 false)。
*/
new_conversation?: boolean
/** /**
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。 * 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。 * null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
@@ -123,29 +113,8 @@ export interface SendMessageArgs {
* null/undefined/空=无 mention(纯文本消息)。 * null/undefined/空=无 mention(纯文本消息)。
*/ */
mention_spans?: MentionSpan[] | null mention_spans?: MentionSpan[] | null
/**
* 多模态片段(图片输入):miniapp 选图转 base64 构造 ContentPart Image 数组透传,
* 对齐后端 ai_chat_send 的 parts 参数(df-ai-core ContentPart,serde 内部标签 tag=type)。
* null/undefined/空=纯文本消息(零回归)。
*/
parts?: MiniContentPart[] | null
} }
/**
* 多模态内容片段(对齐后端 df-ai-core/src/types.rs ContentPart)。
* serde 内部标签 `#[serde(tag="type", rename_all="snake_case")]`:Image 片段 JSON 形如
* `{"type":"image","url":...,"media_type":...}`。
*/
export type MiniContentPart =
| { type: 'text'; text: string }
| {
type: 'image'
url?: string | null
base64?: string | null
media_type?: string | null
alt?: string | null
}
/** /**
* 用户消息内 mention 区间的元数据 * 用户消息内 mention 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。 * (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
@@ -230,6 +199,6 @@ export interface ContinueLoopArgs {
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */ /** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
export interface StopLoopArgs { export interface StopLoopArgs {
/** 需停止循环的会话 id */ /** 霷停止循环的会话 id */
conversation_id: string conversation_id: string
} }
-34
View File
@@ -1,34 +0,0 @@
/** file.1216.top 文件服务上传客户端。
* POST /upload multipart 'file' 字段 + 'X-Source' 头 → {"result":{"url":"https://..."},"retcode":0,"success":true}
*/
import { getConfig } from '@/config'
export interface UploadResult { url: string }
/** 上传图片到 file.1216.top,返回公开 URL。失败 reject(调用方回退 base64)。 */
export function uploadImage(tempFilePath: string): Promise<UploadResult> {
const cfg = getConfig()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${cfg.fileUploadBaseUrl}/upload`,
filePath: tempFilePath,
name: 'file',
header: { 'X-Source': cfg.fileUploadSource },
timeout: 15000,
success: (res) => {
try {
const data = JSON.parse(res.data as string)
const url = data?.result?.url as string | undefined
if (data?.success === true && data?.retcode === 0 && url) {
resolve({ url })
} else {
reject(new Error(`上传响应异常: ${res.data}`))
}
} catch (e) {
reject(e)
}
},
fail: (err) => reject(err),
})
})
}
+10 -171
View File
@@ -1,5 +1,5 @@
/** /**
* marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。 * marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
* *
* 为什么后处理而非 marked 自定义 renderer: * 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。 * - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
@@ -11,88 +11,8 @@
* 跳转需第三方 mp-html(本次不做,记后续)。 * 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠 * - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。 * white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
* - <img> 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示
* (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。
* - <input> 不在 rich-text 节点白名单 → GFM 任务列表勾选框被剥离成纯文本,见下替换。
*/ */
// 语法高亮:highlight.js core + 按需注册常用语言(包体积敏感,不引全量)。
// 无 DOM 不可用 highlightElement,hljs 产出的 token class 由 inlineHljs 转 inline style。
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import json from 'highlight.js/lib/languages/json'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import markdown from 'highlight.js/lib/languages/markdown'
import rust from 'highlight.js/lib/languages/rust'
import go from 'highlight.js/lib/languages/go'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import bash from 'highlight.js/lib/languages/bash'
import sql from 'highlight.js/lib/languages/sql'
import yaml from 'highlight.js/lib/languages/yaml'
import diff from 'highlight.js/lib/languages/diff'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('json', json)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('css', css)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('rust', rust)
hljs.registerLanguage('go', go)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('diff', diff)
// 围栏 info 常见别名 → 已注册语言(hljs.getLanguage 自带部分别名,这里补齐业务常用)
const LANG_ALIASES: Record<string, string> = {
js: 'javascript', ts: 'typescript', py: 'python',
sh: 'bash', shell: 'bash', yml: 'yaml', md: 'markdown',
html: 'xml', vue: 'xml', svg: 'xml',
}
/** hljs token class → inline style(rich-text 认 style 不认 class;色板对齐桌面 github-dark) */
const TOKEN_STYLE: Record<string, string> = {
'hljs-keyword': 'color:#ff7b72',
'hljs-doctag': 'color:#ff7b72',
'hljs-meta': 'color:#ff7b72',
'hljs-type': 'color:#ff7b72',
'hljs-template-tag': 'color:#ff7b72',
'hljs-template-variable': 'color:#ff7b72',
'hljs-title': 'color:#d2a8ff',
'hljs-attr': 'color:#79c0ff',
'hljs-attribute': 'color:#79c0ff',
'hljs-literal': 'color:#79c0ff',
'hljs-number': 'color:#79c0ff',
'hljs-operator': 'color:#79c0ff',
'hljs-variable': 'color:#79c0ff',
'hljs-selector-attr': 'color:#79c0ff',
'hljs-selector-class': 'color:#79c0ff',
'hljs-selector-id': 'color:#79c0ff',
'hljs-regexp': 'color:#a5d6ff',
'hljs-string': 'color:#a5d6ff',
'hljs-built_in': 'color:#ffa657',
'hljs-symbol': 'color:#ffa657',
'hljs-comment': 'color:#8b949e',
'hljs-code': 'color:#8b949e',
'hljs-formula': 'color:#8b949e',
'hljs-name': 'color:#7ee787',
'hljs-quote': 'color:#7ee787',
'hljs-selector-tag': 'color:#7ee787',
'hljs-selector-pseudo': 'color:#7ee787',
'hljs-section': 'color:#1f6feb;font-weight:bold',
'hljs-bullet': 'color:#f2cc60',
'hljs-addition': 'color:#aff5b4;background-color:#033a16',
'hljs-deletion': 'color:#ffdcd7;background-color:#67060c',
'hljs-emphasis': 'font-style:italic',
'hljs-strong': 'font-weight:bold',
}
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */ /** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string { function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g') const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
@@ -108,116 +28,35 @@ function styleTag(html: string, tag: string, style: string): string {
}) })
} }
/** 反转 marked 对代码块内容的 HTML 转义,还原原始代码文本供 hljs 高亮 */
function unescapeCode(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
/** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */
function inlineHljs(html: string): string {
return html.replace(/<span class="([^"]+)">/g, (_m, cls: string) => {
const style = cls
.split(/\s+/)
.map((c) => TOKEN_STYLE[c])
.filter(Boolean)
.join(';')
return style ? `<span style="${style}">` : '<span>'
})
}
/** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */
function codeLangBadge(lang: string): string {
// 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 <code> 前占据顶部一行
return `<div style="font-size:10px;color:#9e9e9e;background-color:#2e2e2e;padding:1px 8px;border-radius:4px;margin-bottom:6px">${lang}</div>`
}
/**
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
*/
function highlightCodeBlocks(html: string): string {
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
const langMatch = /class="language-([^"]+)"/i.exec(attrs)
const langRaw = langMatch ? langMatch[1] : ''
const lang = LANG_ALIASES[langRaw] ?? langRaw
const badge = langRaw ? codeLangBadge(langRaw) : ''
if (!lang || !hljs.getLanguage(lang)) {
// 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
let value: string
try {
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
} catch {
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
return `<pre><code${attrs}>${badge}${inlineHljs(value)}</code></pre>`
})
}
/** /**
* 对 marked.parse 产出的 HTML 注入 inline style。 * 对 marked.parse 产出的 HTML 注入 inline style。
* *
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code * 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读); * 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
* 块代码经 hljs 高亮后,关键字/字符串等 token 由内联色覆盖,未命中 token 的文本保留 code 基色。
*
* 正文颜色(真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text`
* (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th
* 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。
* strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。
*/ */
export function styleMarkdown(html: string): string { export function styleMarkdown(html: string): string {
let out = html let out = html
// 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选)
out = out.replace(/<input[^>]*type="checkbox"[^>]*>/gi, (m) => {
const checked = /checked/i.test(m)
const color = checked ? '#6bd99a' : '#666666'
return `<span style="color:${color};margin-right:4px">${checked ? '☑' : '☐'}</span>`
})
// 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式)
out = highlightCodeBlocks(out)
// 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本)
// word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的
// word-break,须 inline 注入;对齐文件头"rich-text 只认 inline style")。
const BODY_STYLE = 'color:#e0e0e0;word-break:break-all'
for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) {
out = styleTag(out, tag, BODY_STYLE)
}
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆 // 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
out = styleTag( out = styleTag(
out, out,
'pre', 'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0', 'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
) )
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖) // code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
out = styleTag( out = styleTag(
out, out,
'code', 'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px', 'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
) )
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开 // 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all') out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
// 图片:max-width 防超宽撑爆气泡;block 去基线间隙;圆角与气泡一致。 // 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
// 网络图显示依赖微信「downloadFile 合法域名」白名单(见文件头),非渲染问题。
out = styleTag(out, 'img', 'max-width:100%;display:block;border-radius:6px')
// 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
// 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
// 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
// border 提升可读。
out = styleTag( out = styleTag(
out, out,
'table', 'table',
'display:table;border-collapse:collapse;width:100%;max-width:100%;font-size:12px;word-break:break-all', 'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
) )
// th/td:边框 + 内边距保留,字号与 table 对齐(font-size:12px) out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;font-size:12px;background-color:#1a1a1a') out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px;font-size:12px')
return out return out
} }
+1 -1
View File
@@ -14,7 +14,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM"], "lib": ["ES2020", "DOM"],
"types": ["@dcloudio/types", "@types/node", "vite/client"], "types": ["@dcloudio/types", "@types/node"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
-47
View File
@@ -1,47 +0,0 @@
# Bug 修复模板
name: Bug 修复
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
nodes:
locate:
type: ai
label: 定位 Bug
config:
prompt: |
根据错误描述和堆栈信息,定位 bug 的根因。
使用 search_files 和 read_file 工具查找相关代码。
persona_id: analyst
fix:
type: ai
label: 修复 Bug
config:
prompt: |
基于定位结果,修复 bug。
使用 patch_file 工具进行最小化修改。
persona_id: coder
verify:
type: ai
label: 验证修复
config:
prompt: |
验证修复是否有效。
使用 run_command 工具运行相关测试。
persona_id: tester
notify:
type: notify
label: 通知结果
config:
type: desktop
title: Bug 修复完成
message: 修复已验证
edges:
- from: locate
to: fix
- from: fix
to: verify
- from: verify
to: notify
-36
View File
@@ -1,36 +0,0 @@
# 代码审查模板
name: 代码审查
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
nodes:
analyze:
type: ai
label: 分析代码变更
config:
prompt: |
分析以下代码变更,识别:
1. 潜在 bug 和逻辑错误
2. 安全漏洞
3. 性能问题
4. 代码规范
persona_id: reviewer
report:
type: ai
label: 生成审查报告
config:
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 代码审查完成
message: 审查报告已生成
edges:
- from: analyze
to: report
- from: report
to: notify
-62
View File
@@ -1,62 +0,0 @@
# 功能开发模板
name: 功能开发
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
nodes:
design:
type: ai
label: 方案设计
config:
prompt: |
分析需求,设计实现方案:
1. 模块划分和接口定义
2. 数据结构和核心算法
3. 影响范围评估
persona_id: architect
implement:
type: ai
label: 编码实现
config:
prompt: |
按照设计方案实现功能。
使用 write_file 和 patch_file 工具编写代码。
persona_id: coder
test:
type: ai
label: 编写测试
config:
prompt: |
为新功能编写单元测试和集成测试。
使用 run_command 工具运行测试验证。
persona_id: tester
review:
type: ai
label: 代码审查
config:
prompt: |
审查实现代码,关注:
1. 代码质量和可维护性
2. 潜在 bug
3. 安全性
persona_id: reviewer
notify:
type: notify
label: 通知完成
config:
type: desktop
title: 功能开发完成
message: 设计→编码→测试→审查 全流程完成
edges:
- from: design
to: implement
- from: implement
to: test
- from: test
to: review
- from: review
to: notify
+2 -80
View File
@@ -5,18 +5,10 @@
"": { "": {
"name": "devflow", "name": "devflow",
"dependencies": { "dependencies": {
"@antv/layout": "^2.0.0",
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0", "@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1", "dompurify": "^3.4.9",
"@types/dagre": "^0.7.54",
"dagre": "^0.8.5",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "9", "vue-i18n": "9",
"vue-router": "4", "vue-router": "4",
@@ -32,20 +24,6 @@
}, },
}, },
"packages": { "packages": {
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="], "@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="], "@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
@@ -194,10 +172,6 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], "@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -254,35 +228,15 @@
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], "color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="], "compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], "de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="], "dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -290,44 +244,20 @@
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], "is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], "marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], "muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -354,26 +284,18 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], "vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="], "vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="], "vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], "vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="], "vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
} }
} }
+5 -17
View File
@@ -1,4 +1,4 @@
//! 模型能力数据模型。 //! 模型能力数据模型 — F-01 阶段1
//! //!
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。 //! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。 //! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
@@ -45,9 +45,9 @@ pub enum Capability {
/// ///
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设: /// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High), /// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
/// Free 死档变体已删除。 /// B-260618-05 删除 Free 死档变体。
/// ///
/// 路由已解耦:provider /v1/models API 不返回 cost_tier, /// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由 /// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。 /// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -63,7 +63,7 @@ pub enum CostTier {
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。 /// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
/// 派生 Ord:Lite < Standard < Plus < Ultra。 /// 派生 Ord:Lite < Standard < Plus < Ultra。
/// ///
/// 路由已解耦:provider /v1/models API 不返回 intelligence, /// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由 /// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。 /// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -138,14 +138,6 @@ pub struct ModelConfig {
#[serde(default = "default_context_window")] #[serde(default = "default_context_window")]
pub context_window: usize, pub context_window: usize,
/// 是否支持 temperature 参数(默认 true)。
///
/// 部分模型仅接受固定温度值(如商汤 sensenova 系列仅允许 1.0),透传其它温度(标题 0.3/
/// 压缩 0.2 等)→ 上游 HTTP 400,实测 9 次。false 时协议转换层省略 temperature 字段
/// (交由上游默认),避免无效往返;true 保持原样透传(零回归)。
#[serde(default = "default_true")]
pub temperature_supported: bool,
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)── // ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub probe_source: Option<ProbeSource>, pub probe_source: Option<ProbeSource>,
@@ -181,9 +173,6 @@ impl ModelConfig {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: default_weight(), weight: default_weight(),
context_window: default_context_window(), context_window: default_context_window(),
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
@@ -313,7 +302,7 @@ mod tests {
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant); assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
} }
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。 // Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
// Free 变体已删除(预设/启发式从不赋 Free,死档)。 // Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
assert!(CostTier::Low < CostTier::Medium); assert!(CostTier::Low < CostTier::Medium);
assert!(CostTier::Medium < CostTier::High); assert!(CostTier::Medium < CostTier::High);
} }
@@ -350,7 +339,6 @@ mod tests {
intelligence: IntelligenceTier::Plus, intelligence: IntelligenceTier::Plus,
weight: 70, weight: 70,
context_window: 128_000, context_window: 128_000,
temperature_supported: true,
probe_source: Some(ProbeSource::PresetTable), probe_source: Some(ProbeSource::PresetTable),
}; };
let json = serde_json::to_string(&mc).unwrap(); let json = serde_json::to_string(&mc).unwrap();
+22 -138
View File
@@ -42,25 +42,25 @@ impl ContentPart {
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant(content: impl Into<String>) -> Self { pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), source: None, parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self { pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
/// 是否含图片片(供 provider 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
@@ -90,19 +90,19 @@ impl ChatMessage {
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。 /// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
/// ///
/// 正面白名单:仅认 None / "active",新状态 /// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
/// (如"archived_segment" / "compressed")自动落入不 active 分支, /// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated") /// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。 /// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
matches!(self.status, None | Some(MessageStatus::Active)) matches!(self.status.as_deref(), None | Some("active"))
} }
} }
impl ToolDefinition { impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self { pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self { Self {
tool_type: ToolType::new("function"), tool_type: "function".into(),
function: ToolFunction { name: name.into(), description: description.into(), parameters }, function: ToolFunction { name: name.into(), description: description.into(), parameters },
} }
} }
@@ -118,50 +118,6 @@ impl ToolCall {
} }
} }
/// 解析点统一兜底:tool_call.id 空 → 生成唯一 fallback,非空原样。
///
/// 根因(实证会话 01f05167 SenseNova flash-lite):某些 provider(SenseNova 兼容缺陷)
/// 返回空 `tool_call.id`("")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id
/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
///
/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{n}")`
/// (n 取自下方 `FALLBACK_ID_COUNTER` **全局递增计数器**,跨轮跨 assistant 唯一)。
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
///
/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、
/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。
///
/// # 为何用全局计数器而非单轮 index(实证 af2fab4e)
///
/// 旧实现 fallback 用 `format!("{prefix}_{index}")`,index 是**单轮** tool_call 数组
/// 位置。跨轮(不同 assistant)index 都从 0 起 → `gen_stream_0` 跨轮重复。agentic 的
/// `id_to_name`(`insert(id, name)`)后者覆盖前者 → run_command 的 exit=1 被误标
/// grep::exit=1 → L1 误熔断 grep(冤枉)→ loop 停 → 最后 assistant 空 content tool_calls
/// 没执行(空气泡)。更严重:id 重复 → tool 结果配错 tool_call(三元组配对错位)。
///
/// 全局 `AtomicU64`(SeqCst)跨轮跨 assistant 严格递增,fallback id 永不重复。`index`
/// 参数保留仅为签名兼容(4 处调用点 parse_tool_calls / 流式 chunk / push / agentic 都传),
/// fallback 内部不再使用 index。
///
/// 单测跨进程实例计数器从 0 起;并发场景下两线程拿到的 fallback id 也严格递增(SeqCst),
/// 保证全局唯一。
pub fn tool_call_id_or_fallback(raw: &str, _index: usize, prefix: &str) -> String {
if !raw.is_empty() {
raw.to_string()
} else {
let n = FALLBACK_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
format!("{prefix}_{n}")
}
}
/// fallback id 全局计数器:跨轮跨 assistant 严格递增,保证空 id fallback 永不重复。
///
/// 见 `tool_call_id_or_fallback` 文档说明(实证 af2fab4e 跨轮重复根因)。
static FALLBACK_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// LLM Provider trait /// LLM Provider trait
#[async_trait] #[async_trait]
pub trait LlmProvider: Send + Sync { pub trait LlmProvider: Send + Sync {
@@ -199,9 +155,10 @@ mod tests {
#[test] #[test]
fn is_active_whitelist() { fn is_active_whitelist() {
// 正面白名单:仅 None / "active" 为 true,其余一律 false。 // F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价; // 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。 // "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
// matches! 只认 None/active 自动落入 false,面向未来验证。
// None(构造默认值,向前兼容老 JSON) // None(构造默认值,向前兼容老 JSON)
let m = ChatMessage::user("hi"); let m = ChatMessage::user("hi");
@@ -209,26 +166,26 @@ mod tests {
// "active" // "active"
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Active); m.status = Some("active".to_string());
assert!(m.is_active(), "Some(active) 应 active"); assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值,与旧实现等价(false) // "truncated" — 当前取值,与旧实现等价(false)
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Truncated); m.status = Some("truncated".to_string());
assert!(!m.is_active(), "truncated 应不 active"); assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 白名单自动隔离 // "archived_segment" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::ArchivedSegment); m.status = Some("archived_segment".to_string());
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)"); assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
// "compressed" — 白名单自动隔离 // "compressed" — 阶段2 待引入,白名单自动隔离
let mut m = ChatMessage::user("hi"); let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Compressed); m.status = Some("compressed".to_string());
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)"); assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
} }
// ---------- ContentPart ---------- // ---------- F-260614-05 Phase 2a ContentPart ----------
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容) /// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
#[test] #[test]
@@ -310,18 +267,11 @@ mod tests {
role: MessageRole::User, role: MessageRole::User,
content: "字面量构造".into(), content: "字面量构造".into(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
status: None, status: None,
reasoning_content: None, reasoning_content: None,
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
assert_eq!(m.content, "字面量构造"); assert_eq!(m.content, "字面量构造");
@@ -369,18 +319,11 @@ mod tests {
role: MessageRole::Assistant, role: MessageRole::Assistant,
content: "answer".to_string(), content: "answer".to_string(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
status: None, status: None,
reasoning_content: Some("thinking process".to_string()), reasoning_content: Some("thinking process".to_string()),
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -390,7 +333,7 @@ mod tests {
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string())); assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
} }
// ---------- 消息级溯源:id 字段 ---------- // ---------- F-260619-04 消息级溯源:id 字段 ----------
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格) /// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
#[test] #[test]
@@ -431,18 +374,11 @@ mod tests {
role: MessageRole::Assistant, role: MessageRole::Assistant,
content: "answer".to_string(), content: "answer".to_string(),
parts: None, parts: None,
source: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
status: None, status: None,
reasoning_content: None, reasoning_content: None,
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -475,7 +411,6 @@ mod tests {
usage: None, usage: None,
error: None, error: None,
reasoning_content: Some("thought".to_string()), reasoning_content: Some("thought".to_string()),
truncated_by_max_tokens: false,
}; };
let json = serde_json::to_string(&chunk).unwrap(); let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("reasoning_content")); assert!(json.contains("reasoning_content"));
@@ -491,7 +426,7 @@ mod tests {
let resp = CompletionResponse { let resp = CompletionResponse {
text: "ok".to_string(), text: "ok".to_string(),
model: "r1".to_string(), model: "r1".to_string(),
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() }, usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
tool_calls: None, tool_calls: None,
reasoning_content: Some("r1 thought".to_string()), reasoning_content: Some("r1 thought".to_string()),
}; };
@@ -501,55 +436,4 @@ mod tests {
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap(); let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string())); assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
} }
/// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
#[test]
fn tool_call_id_or_fallback_non_empty_passthrough() {
// 非空 raw 原样透传(provider 真 id 如 call_xxx 保留),与 index/prefix 无关
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
}
#[test]
fn tool_call_id_or_fallback_empty_starts_with_prefix() {
// 空 raw → "{prefix}_{n}",n 取自全局计数器(跨进程实例从 0 起,单测不假设具体值)
let a = tool_call_id_or_fallback("", 0, "gen_tool");
assert!(a.starts_with("gen_tool_"), "空 fallback 应以 gen_tool_ 开头, got: {a}");
let b = tool_call_id_or_fallback("", 7, "gen_stream");
assert!(b.starts_with("gen_stream_"), "空 fallback 应以 gen_stream_ 开头, got: {b}");
}
#[test]
fn tool_call_id_or_fallback_empty_globally_unique() {
// 跨轮跨 assistant 唯一:连续两次空 fallback id 必不同(全局计数器递增)。
// 这是修复 af2fab4e 跨轮重复(旧单轮 index 跨轮都从 0 起 → 重复)的核心断言。
let a = tool_call_id_or_fallback("", 0, "gen_tool");
let b = tool_call_id_or_fallback("", 0, "gen_tool");
assert_ne!(a, b, "两次空 fallback 应不同(全局计数器跨轮唯一): {a} vs {b}");
// 即使同 index(模拟跨轮 index 都从 0 起),fallback 也必唯一
let c = tool_call_id_or_fallback("", 0, "gen_tool");
let mut set = std::collections::HashSet::new();
assert!(set.insert(a), "fallback a 应唯一");
assert!(set.insert(b), "fallback b 应唯一");
assert!(set.insert(c), "fallback c 应唯一");
}
#[test]
fn tool_call_id_or_fallback_index_unused() {
// index 参数仅为签名兼容保留(4 处调用点都传),fallback 不再使用 index。
// 同 prefix + 同 index 连续两次 → 不同 fallback(全局计数器递增,与 index 无关)。
let a = tool_call_id_or_fallback("", 3, "gen_tool");
let b = tool_call_id_or_fallback("", 3, "gen_tool");
assert_ne!(a, b, "同 index 两次空 fallback 应不同: {a} vs {b}");
}
#[test]
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic)
// 注意:两次空 fallback 因全局计数器递增 id 不同,故只比 prefix 前缀
let a = tool_call_id_or_fallback("", 0, "gen_tool");
let b = tool_call_id_or_fallback("", 0, "gen_stream");
assert!(a.starts_with("gen_tool_"));
assert!(b.starts_with("gen_stream_"));
}
} }
+7 -161
View File
@@ -43,7 +43,7 @@ pub struct CompletionRequest {
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。 /// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
/// ///
/// ContentPart 作为 `ChatMessage.parts` 的元素类型。 /// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`: /// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串) /// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给 /// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
@@ -68,22 +68,6 @@ pub enum ContentPart {
}, },
} }
/// 消息来源(#958 溯源):外部渠道进入会话的原始消息定位。序列化为 JSON 随消息
/// 落库(ai_messages.source 列);桌面本端消息恒 None 不落
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageSource {
/// 渠道标识:"feishu"(后续可扩 openapi/webhook 等)
pub channel: String,
/// 渠道侧原始消息 ID(飞书 om_/oc_ 前缀,可跳原消息追溯)
pub message_id: String,
/// 渠道侧会话/群 ID(可选,群聊定位)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<String>,
/// 发送者标识(飞书 open_id,可选)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender: Option<String>,
}
/// 聊天消息 /// 聊天消息
/// ///
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/ /// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
@@ -95,18 +79,13 @@ pub struct MessageSource {
pub struct ChatMessage { pub struct ChatMessage {
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。 /// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。 /// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
/// 消息拆分存储后,此 ID 即 `ai_messages.id` 列主键。 /// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。 /// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
pub role: MessageRole, pub role: MessageRole,
pub content: String, pub content: String,
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义, /// 多模态内容片(F-260614-05 Phase 2a)。
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
/// 多端混发可区分来源端)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<MessageSource>,
/// 多模态内容片。
/// ///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。 /// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts /// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
@@ -124,11 +103,11 @@ pub struct ChatMessage {
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯) /// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// 消息状态(UX-09 编辑重生成):None 正常可见; /// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤) /// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。 /// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<MessageStatus>, pub status: Option<String>,
/// DeepSeek thinking 模式的推理内容(多轮需回传) /// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
@@ -136,33 +115,6 @@ pub struct ChatMessage {
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。 /// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>, pub timestamp: Option<i64>,
/// 本轮 LLM 调用的输入 token 用量(仅 assistant 消息,消息级 token 持久化)。
/// 双轨:消息级(本字段,新)+ 会话级累计(ai_conversations.prompt_tokens,旧,保留)。
/// 用于 reload/压缩/切会话后历史 assistant 消息 token 显示;老 JSON 反序列化为 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens: Option<u32>,
/// 本轮 LLM 调用的输出 token 用量(仅 assistant 消息,消息级 token 持久化)。
/// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<u32>,
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
/// token 分项显示(2026-08-02):前端 in=miss(全价真实)+ cache=hit(命中) 分计费展示。
/// 老 JSON 反序列化为 None(向前兼容);非 cache provider 恒为 0。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_hit_tokens: Option<u32>,
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)。
/// 前端 in 显示用此字段(非 prompt_tokens 总,避免掩盖命中比例)。
/// 老 JSON 反序列化为 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_miss_tokens: Option<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// 供 reload 逐条回显「估算」标注,对齐 live 态 AiCompleted.is_estimated;老消息 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_estimated: Option<bool>,
} }
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
@@ -196,91 +148,11 @@ pub enum MessageRole {
Tool, Tool,
} }
/// 消息状态枚举(IPC 边界序列化为小写 snake_case 字符串)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MessageStatus {
/// 正常可见
Active,
/// 软删(编辑某条 user 消息后其后续消息标记)
Truncated,
/// 已压缩
Compressed,
/// 已归档段
ArchivedSegment,
}
impl MessageStatus {
/// DB 存储用的小写 snake_case 字符串
pub fn as_db_str(&self) -> &'static str {
match self {
MessageStatus::Active => "active",
MessageStatus::Truncated => "truncated",
MessageStatus::Compressed => "compressed",
MessageStatus::ArchivedSegment => "archived_segment",
}
}
/// 从 DB 字符串解析
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"active" => MessageStatus::Active,
"truncated" => MessageStatus::Truncated,
"compressed" => MessageStatus::Compressed,
"archived_segment" => MessageStatus::ArchivedSegment,
_ => return None,
})
}
}
/// ChatMessage 结构体
/// 工具类型(IPC 边界透明序列化为字符串,如 "function")
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct ToolType(String);
impl ToolType {
/// 构造新工具类型
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ToolType {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolType {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl PartialEq<&str> for ToolType {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ToolType {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
/// 工具定义 /// 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition { pub struct ToolDefinition {
#[serde(rename = "type")] #[serde(rename = "type")]
pub tool_type: ToolType, pub tool_type: String,
pub function: ToolFunction, pub function: ToolFunction,
} }
@@ -297,7 +169,7 @@ pub struct ToolFunction {
pub struct ToolCall { pub struct ToolCall {
pub id: String, pub id: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub call_type: ToolType, pub call_type: String,
pub function: ToolCallFunction, pub function: ToolCallFunction,
} }
@@ -326,32 +198,11 @@ pub struct CompletionResponse {
} }
/// Token 用量 /// Token 用量
///
/// 分项字段(token 分项显示 + 详情面板,2026-08-02):
/// - `prompt_tokens`:输入总(= cache_hit + cache_miss,兼容老链路保留;前端不再单独展示,
/// 改用 cache_miss 作 in 真实全价消耗)。
/// - `completion_tokens`:输出。
/// - `total_tokens`:总计(各 provider 计费不同,相加无意义,前端不显;保留供老链路/日志)。
/// - `prompt_cache_hit_tokens`:缓存命中(低价,deepseek/anthropic cache_read)。
/// - `prompt_cache_miss_tokens`:未命中(全价真实输入,deepseek/openai 扩展字段)。
/// - `reasoning_tokens`:思考(deepseek-reasoner/o1 隐藏输出 token)。
///
/// 不同 provider 字段名不同,serde default 兜底(无则 0),向前兼容老响应。
/// 构造点全用 `..Default::default()` 补缺,详见各 provider 解析点。
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage { pub struct TokenUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_tokens: u32, pub total_tokens: u32,
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read_input_tokens)
#[serde(default)]
pub prompt_cache_hit_tokens: u32,
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)
#[serde(default)]
pub prompt_cache_miss_tokens: u32,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)
#[serde(default)]
pub reasoning_tokens: u32,
} }
/// 流式输出的 chunk /// 流式输出的 chunk
@@ -371,11 +222,6 @@ pub struct StreamChunk {
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
#[serde(skip)] #[serde(skip)]
pub error: Option<String>, pub error: Option<String>,
/// 本流因 provider 输出上限截断收尾(openai finish_reason=length /
/// anthropic stop_reason=max_tokens)。finished 仍为 true(正常终止非断连),但文本/
/// tool_calls 可能不完整;消费方跨帧 OR 累积该标志,轮产出据此标记截断,不冒充正常完成。
#[serde(default)]
pub truncated_by_max_tokens: bool,
/// DeepSeek thinking 模式推理内容增量 /// DeepSeek thinking 模式推理内容增量
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
+2 -6
View File
@@ -12,13 +12,9 @@ tokio = { workspace = true, features = ["sync", "time"] }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
base64 = "0.22"
# HTTP + 流式 # HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] } reqwest = { version = "0.12", features = ["stream", "json"] }
bytes = "1"
futures = "0.3" futures = "0.3"
eventsource-stream = "0.2"
rand = "0.8" rand = "0.8"
[dev-dependencies]
tempfile = "3"
-76
View File
@@ -1,76 +0,0 @@
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
//!
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
//!
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
use df_ai::build_provider;
use df_ai_core::CompletionRequest;
use futures::StreamExt;
#[tokio::main]
async fn main() {
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
eprintln!("[glm-test] token len={}", token.len());
let provider = build_provider(
"anthropic",
"https://open.bigmodel.cn/api/anthropic",
&token,
"glm-5.2",
);
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
let req: CompletionRequest = serde_json::from_str(
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
)
.expect("parse CompletionRequest");
eprintln!("[glm-test] 调用 provider.stream() ...");
let t0 = std::time::Instant::now();
let mut s = match provider.stream(req).await {
Ok(s) => {
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
s
}
Err(e) => {
eprintln!("[glm-test] stream() Err: {:#}", e);
return;
}
};
let mut n = 0;
let mut got_text = false;
while let Some(chunk_result) = s.next().await {
n += 1;
match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
got_text = true;
}
eprintln!(
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
n, chunk.delta,
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
chunk.finished, chunk.usage, chunk.error
);
}
Err(e) => {
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
}
}
if n > 60 {
eprintln!("[glm-test] 超 60 chunk 截断");
break;
}
}
eprintln!(
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
n,
got_text,
t0.elapsed()
);
}
-35
View File
@@ -127,41 +127,6 @@
"weight": 65, "weight": 65,
"context_window": 128000 "context_window": 128000
}, },
{
"model_id": "sensenova-6.7-flash-lite",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 262144,
"temperature_supported": false
},
{
"model_id": "sensenova-6.8-flash-lite",
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"weight": 85,
"context_window": 262144,
"temperature_supported": false
},
{
"model_id": "sn2-kimi",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 131072,
"temperature_supported": false
},
{
"model_id": "deepseek-v4-flash",
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"weight": 80,
"context_window": 1048576
},
{ {
"model_id": "embedding-3", "model_id": "embedding-3",
"enabled": true, "enabled": true,
+1 -527
View File
@@ -8,7 +8,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{LazyLock, RwLock};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -41,328 +40,10 @@ pub struct AiTool {
pub definition: ToolDefinition, pub definition: ToolDefinition,
/// 风险级别 /// 风险级别
pub risk_level: RiskLevel, pub risk_level: RiskLevel,
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
pub meta: ToolMeta,
/// 执行处理器 /// 执行处理器
pub handler: AiToolHandler, pub handler: AiToolHandler,
} }
// ============================================================
// 工具元数据(DRY 债 #5)
//
// 此前工具元数据以 5 处手写 match 表散布在调用侧:
// path_auth.rs(路径参数键) / agentic/helpers.rs 两处(目标描述 + 漂移签名) /
// audit/data_change.rs(entity-action) / audit/cache.rs(只读缓存白名单 + 目录级 key) /
// tool_registry.rs(审批文案模板 + 轻量动词标签),新增工具需人肉同步 5-6 处且已漂移。
// 收敛后元数据仅在 declare_tool! 注册点声明一次,上述消费方全部改查本表;
// 未注册工具走各消费方原保守 default(路径不校验/不缓存/不联动)。
//
// 维度按 5 处旧表的实际消费盘点,不为用不上的维度加字段:
// - path_args:路径授权预校验提取哪些参数键(rename_file 为 from+to,其余文件工具为 path)
// - readonly:只读幂等工具(结果可缓存)
// - cache_path_key:结果缓存 key 只取 path(目录级去重,同目录换递归参数也判重复)
// - deny_unauthorized:路径未授权时硬拒而非弹窗(search_files 盲搜语义应拒不应询)
// - entity_action:数据变更联动刷新的 (entity, action)
// - approval_hint:审批卡文案模板 + 关键参数键
// - display_verb:轻量中文动作标签
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化;
// 注册点未覆盖但换值即换操作的键由 SIG_EXTRA_GROUPS 补参,见 tool_signature)
// ============================================================
/// 取值风格(goal 维度):从参数值到展示文本的取法
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalStyle {
/// 取路径末段文件名(与旧实现一致按 / 切分)
FileName,
/// 原样
Raw,
/// 超 25 字节截断(字符边界回退,防多字节字符中间切 panic)
Truncate,
}
/// 目标描述元数据:infer_goal_from_tool_calls 消费
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GoalMeta {
/// 动词前缀(如 "修改"/"查看"/"搜索")
pub verb: &'static str,
/// 参数键,按序取第一个存在的键
pub keys: &'static [&'static str],
/// 取值风格
pub style: GoalStyle,
/// 键缺失/解析失败时的兜底文案(如 "搜索文件");None 表示返回 None
pub fallback: Option<&'static str>,
}
/// 签名取值类型:字符串键取原始串,数值键按 JSON 序列化形态
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigValKind {
/// 字符串取值(as_str,缺失为空串)
Str,
/// Value::to_string 形态(数字 50 -> "50",布尔 true -> "true");组内全键参与归一化
/// (排序拼接,缺失键空占位)
Json,
}
/// 漂移检测签名元数据:tool_call_signature 消费
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SigMeta {
/// 有序参数组;组内多键:Str 组为取值回退序(如 pattern 缺失落 query),Json 组为
/// 全键参与(排序拼接,防同组其他键换值被压成同签名)
pub groups: &'static [(&'static [&'static str], SigValKind)],
}
/// 工具元数据(全部为编译期静态值,Copy 廉价传递)
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ToolMeta {
/// 路径授权预校验的参数键;空 = 非文件工具(不预校验,保守 default)
pub path_args: &'static [&'static str],
/// 只读幂等工具(结果可安全缓存)
pub readonly: bool,
/// 结果缓存 key 只取 path(目录级去重)
pub cache_path_key: bool,
/// 路径未授权时硬拒(不弹授权窗)
pub deny_unauthorized: bool,
/// 数据变更联动 (entity, action)
pub entity_action: Option<(&'static str, &'static str)>,
/// 审批文案模板 + 关键参数键(模板 {} 按位置填充)
pub approval_hint: Option<(&'static str, &'static [&'static str])>,
/// 轻量中文动作标签
pub display_verb: Option<&'static str>,
/// 目标描述元数据
pub goal: Option<GoalMeta>,
/// 漂移检测签名元数据
pub sig: Option<SigMeta>,
}
/// 进程级工具元数据表。register_meta 写入(注册点 = 唯一写入口),
/// path_auth / cache / agentic helpers 等无 registry 句柄的自由函数按名查询。
static GLOBAL_TOOL_META: LazyLock<RwLock<HashMap<String, ToolMeta>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// 按工具名查元数据(查全局表,未注册返回 None)
pub fn tool_meta(name: &str) -> Option<ToolMeta> {
GLOBAL_TOOL_META
.read()
.expect("全局工具元数据表锁")
.get(name)
.copied()
}
/// 全局元数据表是否为空(测试种子判定用;生产注册先于任何查询,表恒非空)
#[doc(hidden)]
pub fn meta_table_is_empty() -> bool {
GLOBAL_TOOL_META.read().expect("全局工具元数据表锁").is_empty()
}
/// 路径授权预校验参数键(未注册工具返回空切片,即非文件工具)
pub fn path_arg_keys(name: &str) -> &'static [&'static str] {
tool_meta(name).map(|m| m.path_args).unwrap_or(&[])
}
/// 是否只读幂等工具(未注册返回 false,即不缓存)
pub fn is_readonly(name: &str) -> bool {
tool_meta(name).map(|m| m.readonly).unwrap_or(false)
}
/// 结果缓存 key 是否只取 path(未注册返回 false)
pub fn cache_path_key_only(name: &str) -> bool {
tool_meta(name).map(|m| m.cache_path_key).unwrap_or(false)
}
/// 路径未授权是否硬拒(未注册返回 false,走弹窗)
pub fn denies_unauthorized(name: &str) -> bool {
tool_meta(name).map(|m| m.deny_unauthorized).unwrap_or(false)
}
/// 数据变更联动 (entity, action)(未注册返回 None,不 emit)
pub fn entity_action(name: &str) -> Option<(&'static str, &'static str)> {
tool_meta(name).and_then(|m| m.entity_action)
}
/// 审批文案模板(未注册返回 None,调用方走 fallback)
pub fn approval_hint(name: &str) -> Option<(&'static str, &'static [&'static str])> {
tool_meta(name).and_then(|m| m.approval_hint)
}
/// 轻量中文动作标签(未注册返回 None)
pub fn display_verb(name: &str) -> Option<&'static str> {
tool_meta(name).and_then(|m| m.display_verb)
}
/// 目标描述元数据(未注册返回 None)
pub fn goal_of(name: &str) -> Option<GoalMeta> {
tool_meta(name).and_then(|m| m.goal)
}
/// 漂移检测签名元数据(未注册返回 None,调用方走全参兜底)
pub fn sig_of(name: &str) -> Option<SigMeta> {
tool_meta(name).and_then(|m| m.sig)
}
/// 按路径参数键从 args 提取路径列表(键序即提取序,缺失键跳过)
pub fn extract_paths(keys: &'static [&'static str], args: &Value) -> Vec<String> {
keys.iter()
.filter_map(|k| args.get(*k).and_then(|x| x.as_str()).map(|s| s.to_string()))
.collect()
}
/// 由目标元数据渲染目标描述。args 为 None(调用参数 JSON 解析失败)时仅兜底文案可产出。
pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option<String> {
let value = args
.and_then(|a| goal.keys.iter().find_map(|k| a.get(k)))
.and_then(|v| v.as_str());
let rendered = value.map(|v| match goal.style {
GoalStyle::FileName => {
// 按旧实现仅按 / 取末段;rsplit 恒有首项,反斜杠路径在此保持原样(行为对齐)
let f = v.rsplit('/').next().unwrap_or(v);
format!("{} {}", goal.verb, f)
}
GoalStyle::Raw => format!("{} {}", goal.verb, v),
GoalStyle::Truncate => {
let mut end = 25.min(v.len());
while end > 0 && !v.is_char_boundary(end) {
end -= 1;
}
format!("{} {}", goal.verb, &v[..end])
}
});
rendered.or_else(|| goal.fallback.map(|f| f.to_string()))
}
/// 组渲染口径:Always = 基础组逐组渲染,缺键空值占位(消费方按固定串断言,形态不可漂移);
/// Present = 补参口径,只渲染 args 中实际出现的键,键未出现视同未换参,不渲染空占位。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupPresence {
Always,
Present,
}
/// 组渲染:每组产出 k=v 片段,组间逗号连接。Str 组标签恒为首键(与旧实现
/// pair("pattern", ...) 一致,取值来自回退键时标签不变),取组内首个字符串值键的值,
/// 全非字符串落末键。Json 组全键参与归一化:数值/布尔键换值即换操作,只取 keys[0] 会把
/// 同组其他键换值的不同调用压成同一签名(G2 熔断以签名为重复判据,压缩即误判重复),
/// 排序后拼接保证与键序无关的确定性。
fn render_groups(
groups: &'static [(&'static [&'static str], SigValKind)],
args: &Value,
presence: GroupPresence,
) -> String {
groups
.iter()
.filter_map(|(keys, kind)| match kind {
SigValKind::Json => {
let mut parts: Vec<String> = keys
.iter()
.copied()
.filter(|k| presence == GroupPresence::Always || args.get(*k).is_some())
.map(|k| match args.get(k) {
Some(v) => format!("{}={}", k, v.to_string()),
None => format!("{}=", k),
})
.collect();
parts.sort_unstable();
if parts.is_empty() {
None
} else {
Some(parts.join("|"))
}
}
SigValKind::Str => {
if presence == GroupPresence::Present {
// 补参组恒单键:键未出现整组跳过,值非字符串视同缺失
return args
.get(keys[0])
.and_then(|v| v.as_str())
.map(|s| format!("{}={}", keys[0], s));
}
let key = keys
.iter()
.find(|k| args.get(**k).and_then(|v| v.as_str()).is_some())
.unwrap_or(&keys[keys.len() - 1]);
Some(format!(
"{}={}",
keys[0],
args.get(*key).and_then(|v| v.as_str()).unwrap_or("")
))
}
})
.collect::<Vec<_>>()
.join(",")
}
/// 签名体:基础组按 Always 口径渲染。
pub fn sig_body(sig: SigMeta, args: &Value) -> String {
render_groups(sig.groups, args, GroupPresence::Always)
}
/// 签名补参表:注册点 SigMeta 组未覆盖、但「换值即换操作」的键,按工具名追加进签名。
///
/// 为什么在 df-ai 签名层:G2 熔断以签名为重复判据,签名过粗会把合法换参误判成
/// 「同一操作反复」(同工具换参是不同操作)。注册点 SigMeta 声明主参数组,换参敏感
/// 但未覆盖的键由本表统一补参;若日后注册点把这些键并入 groups,须同步从本表移除
/// (双源会重复渲染同键,不改变同异判定但产生冗余片段)。
///
/// 渲染走 Present 口径(键出现在 args 才进签名):补参键缺省 = 未换参,既有签名逐字
/// 不变。纯格式/开关类键(-n/-C/context_chars/timeout_secs)不补,变体不构成意图变化,
/// 补进签名只会稀释重复判据。
static SIG_EXTRA_GROUPS: &[(&str, &'static [(&'static [&'static str], SigValKind)])] = &[
// read_file:同 path 换 search 是换词定位,非重复读
("read_file", &[(&["search"], SigValKind::Str)]),
// search_files:换 recursive 换搜索范围,换 offset/limit 是翻页续搜
// (schema 无 -i/output_mode,大小写不敏感是内建语义非参数)
(
"search_files",
&[
(&["recursive"], SigValKind::Json),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
),
// grep:换 -i 是敏感/不敏感两种匹配,换 output_mode 是行内容/文件名/计数三种形态,
// 换 max_results 是扩容重取(schema 无 offset/limit,数量参数实为 max_results)
(
"grep",
&[
(&["-i"], SigValKind::Json),
(&["output_mode"], SigValKind::Str),
(&["max_results"], SigValKind::Json),
],
),
// run_command:同命令不同 working_dir 是在不同目录执行
("run_command", &[(&["working_dir"], SigValKind::Str)]),
];
/// 按工具名取补参组(未列名返回 None)
fn sig_extra_groups(name: &str) -> Option<&'static [(&'static [&'static str], SigValKind)]> {
SIG_EXTRA_GROUPS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, g)| *g)
}
/// 工具调用签名:有 SigMeta 元数据按组渲染并追加补参组,未注册回退「工具名 + 全参 JSON」
/// 保守兜底(全参已含补参键,无需追加)。
///
/// G2 判定语义:同工具同参连续 = 同签名,计重复;同工具换参 = 补参键进签名,签名不同
/// 不累计(正常探索)。
pub fn tool_signature(name: &str, args: &Value) -> String {
match sig_of(name) {
Some(sig) => {
let mut body = render_groups(sig.groups, args, GroupPresence::Always);
let extra = sig_extra_groups(name)
.map(|g| render_groups(g, args, GroupPresence::Present))
.unwrap_or_default();
if !extra.is_empty() {
body.push(',');
body.push_str(&extra);
}
format!("{}:{}", name, body)
}
None => format!("{}:name={},args={}", name, name, args.to_string()),
}
}
/// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果 /// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果
pub type AiToolHandler = pub type AiToolHandler =
Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>; Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>;
@@ -384,7 +65,7 @@ impl AiToolRegistry {
} }
} }
/// 注册一个工具(无元数据,ToolMeta::default) /// 注册一个工具
pub fn register( pub fn register(
&mut self, &mut self,
name: impl Into<String>, name: impl Into<String>,
@@ -392,47 +73,19 @@ impl AiToolRegistry {
parameters: Value, parameters: Value,
risk_level: RiskLevel, risk_level: RiskLevel,
handler: AiToolHandler, handler: AiToolHandler,
) {
self.register_meta(name, description, parameters, risk_level, ToolMeta::default(), handler);
}
/// 注册一个工具并写入元数据。
///
/// 元数据在同一调用点写入两处,无双源漂移:
/// - 注册表条目 AiTool.meta(持有 registry 实例的消费方查询)
/// - 进程级 GLOBAL_TOOL_META 表(path_auth/cache/agentic helpers 等无
/// registry 句柄的自由函数按工具名查询)
pub fn register_meta(
&mut self,
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
risk_level: RiskLevel,
meta: ToolMeta,
handler: AiToolHandler,
) { ) {
let name_str = name.into(); let name_str = name.into();
let definition = ToolDefinition::function(&name_str, description, parameters); let definition = ToolDefinition::function(&name_str, description, parameters);
GLOBAL_TOOL_META
.write()
.expect("全局工具元数据表锁")
.insert(name_str.clone(), meta);
self.tools.insert( self.tools.insert(
name_str, name_str,
AiTool { AiTool {
definition, definition,
risk_level, risk_level,
meta,
handler, handler,
}, },
); );
} }
/// 查询工具元数据条目(持有 registry 实例的消费方用)
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
self.tools.get(name).map(|t| t.meta)
}
/// 获取所有工具定义(发送给 LLM 的 tools 参数) /// 获取所有工具定义(发送给 LLM 的 tools 参数)
pub fn tool_definitions(&self) -> Vec<ToolDefinition> { pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools.values().map(|t| t.definition.clone()).collect() self.tools.values().map(|t| t.definition.clone()).collect()
@@ -583,183 +236,4 @@ mod tests {
assert_eq!(required.len(), 1); assert_eq!(required.len(), 1);
assert_eq!(required[0], "name"); assert_eq!(required[0], "name");
} }
/// register_meta:条目与全局表同步可查,未注册名走保守 default。
#[test]
fn register_meta_stores_entry_and_global() {
let mut reg = AiToolRegistry::new();
let meta = ToolMeta {
path_args: &["from", "to"],
readonly: true,
entity_action: Some(("task", "update")),
..Default::default()
};
reg.register_meta("meta_probe", "测试", object_schema(vec![]), RiskLevel::Low, meta, dummy_handler());
assert_eq!(reg.meta("meta_probe"), Some(meta));
assert_eq!(tool_meta("meta_probe"), Some(meta));
assert_eq!(path_arg_keys("meta_probe"), &["from", "to"]);
assert!(is_readonly("meta_probe"));
assert_eq!(entity_action("meta_probe"), Some(("task", "update")));
// 未注册名保守 default:无路径键/非只读/无联动
assert_eq!(path_arg_keys("never_registered"), &[] as &[&str]);
assert!(!is_readonly("never_registered"));
assert_eq!(entity_action("never_registered"), None);
// 老签名 register 等价 default 元数据
reg.register("plain_probe", "测试", object_schema(vec![]), RiskLevel::Low, dummy_handler());
assert_eq!(reg.meta("plain_probe"), Some(ToolMeta::default()));
}
/// goal_text 三种取值风格 + 键回退 + 兜底文案。
#[test]
fn goal_text_styles_and_fallback() {
use serde_json::json;
let file_name = GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None };
assert_eq!(goal_text(file_name, Some(&json!({"path": "a/b/c.rs"}))), Some("查看 c.rs".to_string()));
let raw = GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None };
assert_eq!(goal_text(raw, Some(&json!({"path": "x/y"}))), Some("浏览 x/y".to_string()));
// Truncate:ASCII 超 25 字节截 25;多字节字符回退到字符边界不 panic
let trunc = GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") };
let long = json!({"command": "a".repeat(40)});
assert_eq!(goal_text(trunc, Some(&long)), Some(format!("执行 {}", "a".repeat(25))));
let multibyte = json!({"command": "构建数据库连接池"});
let out = goal_text(trunc, Some(&multibyte)).expect("多字节应正常产出");
assert!(out.starts_with("执行 构建数据库"));
// 键缺失走兜底;无兜底返 None
assert_eq!(goal_text(trunc, Some(&json!({}))), Some("执行命令".to_string()));
assert_eq!(goal_text(file_name, Some(&json!({}))), None);
assert_eq!(goal_text(file_name, None), None);
// 多键回退序:首键缺失取次键
let multi = GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Raw, fallback: Some("搜索文件") };
assert_eq!(goal_text(multi, Some(&json!({"query": "todo"}))), Some("搜索 todo".to_string()));
}
/// sig_body:组内字符串键回退 + Json 数值键形态 + 缺省空串。
#[test]
fn sig_body_groups_and_kinds() {
use serde_json::json;
let grep_sig = SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] };
assert_eq!(sig_body(grep_sig, &json!({"pattern": "MAX", "path": "src"})), "pattern=MAX,path=src");
assert_eq!(sig_body(grep_sig, &json!({"query": "TODO", "glob": "*.rs"})), "pattern=TODO,path=*.rs");
assert_eq!(sig_body(grep_sig, &json!({})), "pattern=,path=");
let read_sig = SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] };
assert_eq!(sig_body(read_sig, &json!({"path": "a", "offset": 50})), "path=a,offset=50,limit=");
// 未注册工具签名回退全参 JSON
let sig = tool_signature("custom_tool", &json!({"x": 1}));
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
}
/// Json 组全键参与:同组多键排序拼接(与 args 键序无关),任一键换值即换片段。
#[test]
fn sig_body_json_group_includes_all_keys() {
use serde_json::json;
let page = SigMeta { groups: &[(&["offset", "limit"], SigValKind::Json)] };
// 排序拼接:limit < offset 字典序,与 args 内键书写顺序无关
assert_eq!(sig_body(page, &json!({"offset": 50, "limit": 100})), "limit=100|offset=50");
assert_eq!(sig_body(page, &json!({"limit": 100, "offset": 50})), "limit=100|offset=50");
// 任一键换值 → 不同片段
assert_ne!(
sig_body(page, &json!({"offset": 50, "limit": 100})),
sig_body(page, &json!({"offset": 60, "limit": 100}))
);
// 全缺失逐键空占位(既有缺省口径);布尔键按 JSON 形态
assert_eq!(sig_body(page, &json!({})), "limit=|offset=");
let flag = SigMeta { groups: &[(&["-i"], SigValKind::Json)] };
assert_eq!(sig_body(flag, &json!({"-i": true})), "-i=true");
}
/// 注册与生产同形态的探针工具。df-ai 单测进程 GLOBAL_TOOL_META 无生产注册,
/// 不种入则 tool_signature 走未注册兜底,测不到「注册点组 + 补参」合流分支。
/// 进程级表跨测试共享,各处注册值恒相同,幂等无顺序依赖。
fn seed_signature_probe_tools() {
let read_sig = SigMeta {
groups: &[
(&["path"], SigValKind::Str),
(&["offset"], SigValKind::Json),
(&["limit"], SigValKind::Json),
],
};
let search_sig = SigMeta {
groups: &[
(&["pattern", "query"], SigValKind::Str),
(&["path", "glob"], SigValKind::Str),
],
};
let cmd_sig = SigMeta { groups: &[(&["command"], SigValKind::Str)] };
let mut reg = AiToolRegistry::new();
reg.register_meta("read_file", "读取", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(read_sig), ..Default::default() }, dummy_handler());
reg.register_meta("search_files", "搜索文件", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("grep", "跨文件内容搜索", object_schema(vec![]), RiskLevel::Low,
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
reg.register_meta("run_command", "执行命令", object_schema(vec![]), RiskLevel::High,
ToolMeta { sig: Some(cmd_sig), ..Default::default() }, dummy_handler());
}
/// 补参:合法换参 → 签名不同(G2 目标语义:换参不共享签名,不再误判重复);
/// 同参 → 签名相同(重复判据正例仍成立)。
#[test]
fn tool_signature_extra_params_distinguish_calls() {
use serde_json::json;
seed_signature_probe_tools();
// read_file 同 path 换 search:不同签名
let s1 = tool_signature("read_file", &json!({"path": "a.rs", "search": "fn sig"}));
let s2 = tool_signature("read_file", &json!({"path": "a.rs", "search": "struct cfg"}));
assert_ne!(s1, s2, "read_file 换 search 应不同签名: {} vs {}", s1, s2);
assert!(s1.contains("search=fn sig"), "read_file 签名应含 search: {}", s1);
// search_files 只差 offset(翻页续搜):不同签名
let s1 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 0}));
let s2 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 50}));
assert_ne!(s1, s2, "search_files 换 offset 应不同签名: {} vs {}", s1, s2);
// grep 只差 -i / output_mode / max_results(schema 无 offset/limit,数量参数实为
// max_results):换匹配方式/输出形态/扩容重取都是不同操作
let base = tool_signature("grep", &json!({"pattern": "err", "path": "src"}));
let ci = tool_signature("grep", &json!({"pattern": "err", "path": "src", "-i": true}));
let mode = tool_signature("grep", &json!({"pattern": "err", "path": "src", "output_mode": "files_with_matches"}));
let capped = tool_signature("grep", &json!({"pattern": "err", "path": "src", "max_results": 200}));
assert_ne!(base, ci, "grep 换 -i 应不同签名: {} vs {}", base, ci);
assert_ne!(base, mode, "grep 换 output_mode 应不同签名: {} vs {}", base, mode);
assert_ne!(base, capped, "grep 换 max_results 应不同签名: {} vs {}", base, capped);
// run_command 同命令换 working_dir:不同签名
let s1 = tool_signature("run_command", &json!({"command": "cargo test"}));
let s2 = tool_signature("run_command", &json!({"command": "cargo test", "working_dir": "crates/df-ai"}));
assert_ne!(s1, s2, "run_command 换 working_dir 应不同签名: {} vs {}", s1, s2);
// 同工具同参:签名相同(同签名连续才算 G2 重复)
assert_eq!(
tool_signature("grep", &json!({"pattern": "err", "path": "src"})),
base
);
}
/// 补参键缺席时签名与既有格式逐字一致(向后兼容:消费方按固定串断言,空补参不占位)。
#[test]
fn tool_signature_without_extra_keys_unchanged() {
use serde_json::json;
seed_signature_probe_tools();
assert_eq!(
tool_signature("read_file", &json!({"path": "big.log", "offset": 50, "limit": 100})),
"read_file:path=big.log,offset=50,limit=100"
);
assert_eq!(
tool_signature("read_file", &json!({})),
"read_file:path=,offset=,limit="
);
assert_eq!(
tool_signature("grep", &json!({"pattern": "MAX", "path": "src", "case_sensitive": true})),
"grep:pattern=MAX,path=src"
);
assert_eq!(
tool_signature("search_files", &json!({"query": "TODO", "glob": "**/*.rs"})),
"search_files:pattern=TODO,path=**/*.rs"
);
assert_eq!(
tool_signature("run_command", &json!({"command": "ls -la", "timeout": 5000})),
"run_command:command=ls -la"
);
}
} }
-219
View File
@@ -1,219 +0,0 @@
//! 声明式工具注册基础设施(tool_registry 拆分第一步)
//!
//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段
//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`,
//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`,
//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。
//!
//! 设计取舍(三选一权衡):
//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反
//! 「不引新依赖」。否决。
//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义,
//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。
//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`),
//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**,
//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量
//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。
//!
//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 +
//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。
//!
//! 宏展开示例(输入):
//! ```ignore
//! declare_tool!(registry, db: Arc<Database>, "list_projects",
//! "列出所有项目...", RiskLevel::Low,
//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
//! args => {
//! let repo = ProjectRepo::new(&db);
//! let items = repo.list_active().await?;
//! // ...
//! Ok(json!({ "items": items, ... }))
//! });
//! ```
//! 展开后等价于现有手写的:
//! ```ignore
//! registry.register(
//! "list_projects", "...",
//! object_schema(vec![...]), RiskLevel::Low,
//! { let db = db.clone(); Box::new(move |args: serde_json::Value| {
//! let db = db.clone();
//! Box::pin(async move { /* handler body */ })
//! })},
//! );
//! ```
/// 声明式注册一个 AI 工具到 `$registry`。
///
/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价,
/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。
///
/// 形参:
/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。
/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc<Database>`)。宏自动 clone 进闭包,
/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。
/// - `$name`: 工具名 `&str`。
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落
/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
///
/// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是
/// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。
#[macro_export]
macro_rules! declare_tool {
(
$registry:expr,
$capture:ident : $cap_ty:ty,
$name:expr,
$desc:expr,
$risk:expr,
meta: $meta:expr,
schema: $schema:expr,
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register_meta(
$name, $desc, $schema, $risk, $meta,
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
let $capture = $capture.clone();
::std::boxed::Box::pin(async move {
let $capture: $cap_ty = $capture;
$handler
})
as ::std::pin::Pin<::std::boxed::Box<
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
(
$registry:expr,
$capture:ident : $cap_ty:ty,
$name:expr,
$desc:expr,
$risk:expr,
schema: $schema:expr,
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register_meta(
$name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(),
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
let $capture = $capture.clone();
::std::boxed::Box::pin(async move {
let $capture: $cap_ty = $capture;
$handler
})
as ::std::pin::Pin<::std::boxed::Box<
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
}
#[cfg(test)]
mod tests {
use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
/// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。
/// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。
#[tokio::test]
async fn declare_tool_equivalent_to_register() {
let mut reg = AiToolRegistry::new();
// 声明式注册(新基础设施)
let counter: std::sync::Arc<std::sync::atomic::AtomicUsize> =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
declare_tool!(
reg,
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
"echo_decl",
"声明式 echo 工具",
RiskLevel::Low,
schema: object_schema(vec![("msg", "string", true)]),
args => {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let msg = args["msg"].as_str().unwrap_or("");
Ok(serde_json::json!({ "echo": msg }))
}
);
// 等价断言:定义层(name 存在 / risk 正确 / schema 透传)
assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具");
let tool = reg.get("echo_decl").expect("echo_decl 应已注册");
assert_eq!(tool.risk_level, RiskLevel::Low);
assert_eq!(tool.definition.function.name, "echo_decl");
assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string");
// 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value
let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap();
assert_eq!(out["echo"], "hi");
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增");
}
/// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。
#[tokio::test]
async fn declare_tool_no_capture() {
let mut reg = AiToolRegistry::new();
let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"const_tool",
"无捕获常量工具",
RiskLevel::Medium,
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
assert_eq!(out["ok"], true);
}
/// 带 meta 段的宏形态:元数据落条目 + 全局表;不带 meta 段落 default。
#[tokio::test]
async fn declare_tool_with_and_without_meta() {
use crate::ai_tools::ToolMeta;
let mut reg = AiToolRegistry::new();
let dummy: std::sync::Arc<()> = std::sync::Arc::new(());
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"meta_variant_tool",
"带元数据声明的工具",
RiskLevel::Low,
meta: ToolMeta { path_args: &["path"], readonly: true, ..Default::default() },
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"plain_variant_tool",
"不带元数据声明的工具",
RiskLevel::Low,
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
let with = reg.meta("meta_variant_tool").expect("meta 变体应已注册");
assert_eq!(with.path_args, &["path"]);
assert!(with.readonly);
assert_eq!(crate::ai_tools::path_arg_keys("meta_variant_tool"), &["path"]);
let plain = reg.meta("plain_variant_tool").expect("无 meta 变体应已注册");
assert_eq!(plain, ToolMeta::default());
assert_eq!(crate::ai_tools::path_arg_keys("plain_variant_tool"), &[] as &[&str]);
}
}
File diff suppressed because it is too large Load Diff
+26 -89
View File
@@ -12,26 +12,12 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, warn}; use tracing::{error, warn};
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta}; use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================ // ============================================================
// Anthropic API 请求/响应结构体 // Anthropic API 请求/响应结构体
// ============================================================ // ============================================================
/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制):
/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API
/// 代理——部分网关会拒收 cache_control 字段)。
/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用
/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum SystemBlock {
/// 纯字符串形态(兼容网关,开关关)
Plain(String),
/// blocks 数组形态(含 cache_control,开关开)
Cached(Vec<serde_json::Value>),
}
/// Anthropic 请求体 /// Anthropic 请求体
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicRequest { pub(crate) struct AnthropicRequest {
@@ -39,7 +25,7 @@ pub(crate) struct AnthropicRequest {
pub messages: Vec<serde_json::Value>, pub messages: Vec<serde_json::Value>,
pub max_tokens: u32, pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<SystemBlock>, pub system: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>, pub temperature: Option<f32>,
pub stream: bool, pub stream: bool,
@@ -61,27 +47,24 @@ pub(crate) struct AnthropicToolDef {
/// Anthropic 同步响应 /// Anthropic 同步响应
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AnthropicResponse { pub(crate) struct AnthropicResponse {
// Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning // SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
id: String, id: String,
pub model: String, pub model: String,
pub content: Vec<AnthropicContentBlock>, pub content: Vec<AnthropicContentBlock>,
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning // SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
stop_reason: Option<String>, stop_reason: Option<String>,
pub usage: AnthropicUsage, pub usage: AnthropicUsage,
} }
/// 响应 content 块(text / tool_use / thinking) /// 响应 content 块(text 或 tool_use)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AnthropicContentBlock { pub(crate) struct AnthropicContentBlock {
#[serde(rename = "type")] #[serde(rename = "type")]
pub block_type: String, pub block_type: String,
#[serde(default)] #[serde(default)]
pub text: Option<String>, pub text: Option<String>,
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
#[serde(default)]
pub thinking: Option<String>,
/// tool_use 块字段 /// tool_use 块字段
pub id: Option<String>, pub id: Option<String>,
pub name: Option<String>, pub name: Option<String>,
@@ -92,16 +75,6 @@ pub(crate) struct AnthropicContentBlock {
pub(crate) struct AnthropicUsage { pub(crate) struct AnthropicUsage {
pub input_tokens: u32, pub input_tokens: u32,
pub output_tokens: u32, pub output_tokens: u32,
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
/// 非 cache 场景无此字段 → serde default 0。
#[serde(default)]
pub cache_creation_input_tokens: u32,
/// Anthropic prompt caching 扩展:cache 命中读取 token(低价)。
/// 映射到 TokenUsage.prompt_cache_hit_tokens。
/// 非 cache 场景无此字段 → serde default 0。
#[serde(default)]
pub cache_read_input_tokens: u32,
} }
// ============================================================ // ============================================================
@@ -122,8 +95,7 @@ pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
/// 按 `type` 字段分发: /// 按 `type` 字段分发:
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。 /// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。 /// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
/// - `content_block_delta` (text_delta/input_json_delta/thinking_delta) → 文本/工具入参/思考增量, /// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。
/// signature_delta 安静忽略。
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。 /// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。 /// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
/// - `error` → 返回 `finished=true` 终态空 chunk。 /// - `error` → 返回 `finished=true` 终态空 chunk。
@@ -137,13 +109,12 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let v: serde_json::Value = match serde_json::from_str(data) { let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v, Ok(v) => v,
Err(_) => { Err(_) => {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false } return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
} }
}; };
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty { match ty {
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)。 // 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
"message_start" => { "message_start" => {
if let Some(inp) = v if let Some(inp) = v
.get("message") .get("message")
@@ -151,58 +122,30 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
.and_then(|u| u.get("input_tokens")) .and_then(|u| u.get("input_tokens"))
.and_then(|t| t.as_u64()) .and_then(|t| t.as_u64())
{ {
let u_obj = v.get("message").and_then(|m| m.get("usage"));
let cache_read = u_obj
.and_then(|u| u.get("cache_read_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
let cache_creation = u_obj
.and_then(|u| u.get("cache_creation_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
*usage_accum = Some(TokenUsage { *usage_accum = Some(TokenUsage {
prompt_tokens: inp as u32, prompt_tokens: inp as u32,
completion_tokens: 0, completion_tokens: 0,
total_tokens: inp as u32, total_tokens: inp as u32,
prompt_cache_hit_tokens: cache_read,
prompt_cache_miss_tokens: cache_creation,
reasoning_tokens: 0,
}); });
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
} }
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => { "message_delta" => {
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) { if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
let acc = usage_accum let acc = usage_accum
.get_or_insert(TokenUsage::default()); .get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
acc.completion_tokens = out as u32; acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens); acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
} }
// stop_reason=max_tokens(message_delta 帧携带):输出被 max_tokens 截断。 StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
// 终态 finished 在后续 message_stop 帧,截断标志挂本帧,消费方跨帧 OR 累积。
let truncated_by_max_tokens = v.get("delta").and_then(|d| d.get("stop_reason")).and_then(|s| s.as_str()) == Some("max_tokens");
// 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens }
} }
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
if let Some(delta) = v.get("delta") { if let Some(delta) = v.get("delta") {
// thinking 增量:推理正文走 reasoning_content 通道(与 DeepSeek 同路入库展示),
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text), truncated_by_max_tokens: false };
}
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
}
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }; return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None };
} }
// 工具入参增量 // 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
@@ -220,30 +163,26 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
} }
// 工具块开始:带 id + name // 工具块开始:带 id + name
"content_block_start" => { "content_block_start" => {
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
if let Some(cb) = v.get("content_block") { if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") { if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32; let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string()); let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
// CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加, // id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加,
// 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY), // 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。
// prefix=`gen_anthropic_stream` 区分来源。非空原样。 let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) {
let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or(""); Some(id) if !id.is_empty() => Some(id),
let id = if raw_id.is_empty() { _ => {
let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream"); let placeholder = format!("tool_missing_{}", idx);
warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)"); warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)");
Some(fallback) Some(placeholder)
} else { }
Some(raw_id.to_string())
}; };
return StreamChunk { return StreamChunk {
delta: String::new(), delta: String::new(),
@@ -257,11 +196,10 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
} }
// 消息结束:带出累积 usage // 消息结束:带出累积 usage
"message_stop" => StreamChunk { "message_stop" => StreamChunk {
@@ -271,16 +209,15 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}, },
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库), // 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。 // 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => { "error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string(); let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
error!(%msg, "Anthropic 流式错误事件"); error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None, truncated_by_max_tokens: false } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None }
} }
// content_block_stop / ping 等不产出 chunk // content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }, _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None },
} }
} }
File diff suppressed because it is too large Load Diff
-180
View File
@@ -1,180 +0,0 @@
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
//!
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::provider::ChatMessage;
/// 存档目录名(data_dir 下)
const ARCHIVE_DIR: &str = "context-archive";
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
pub(super) struct RecallTarget {
data_dir: PathBuf,
conv_id: String,
}
impl RecallTarget {
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
Self {
data_dir: data_dir.to_path_buf(),
conv_id: conv_id.to_string(),
}
}
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
fn file(&self) -> PathBuf {
self.data_dir
.join(ARCHIVE_DIR)
.join(format!("{}-archive.jsonl", self.conv_id))
}
}
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
if msgs.is_empty() {
return None;
}
let path = target.file();
let dir = path.parent()?;
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
return None;
}
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
return None;
}
};
for m in msgs {
let line = serde_json::json!({ "role": &m.role, "content": m.content });
if let Err(e) = writeln!(file, "{line}") {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
return None;
}
}
Some(path)
}
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
pub fn recall_line(path: &Path) -> String {
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
}
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
pub fn with_recall_line(summary: &str, path: &Path) -> String {
format!("{}\n{}", summary, recall_line(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
use tempfile::TempDir;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
let mut m = mgr();
m.push(ChatMessage::user("早期问题"));
m.push(ChatMessage::assistant("早期回复"));
m.push(ChatMessage::user("近期问题"));
m.set_compress_recall(tmp.path(), conv_id);
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
m.take_archive_path().expect("存档应落盘")
}
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
#[test]
fn compress_with_recall_archives_original_text() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-a");
assert_eq!(
path,
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["role"], "user");
assert_eq!(first["content"], "早期问题");
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["role"], "assistant");
assert_eq!(second["content"], "早期回复");
assert!(lines.next().is_none(), "不应有多余行");
}
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
#[test]
fn recall_enabled_summary_gets_handle_line() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-b");
let summary = "## 意图\n- 测试主题";
let with_handle = with_recall_line(summary, &path);
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
let handle = recall_line(&path);
assert!(with_handle.ends_with(&handle));
assert!(handle.contains(&path.display().to_string()));
assert!(handle.contains("read_file"));
}
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
#[test]
fn recall_off_no_archive_no_handle() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("问题"));
m.push(ChatMessage::assistant("回复"));
m.push(ChatMessage::user("近期"));
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
assert!(
!tmp.path().join("context-archive").exists(),
"开关关不应创建存档目录"
);
}
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
#[test]
fn second_compress_appends_not_overwrites() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("第一批"));
m.push(ChatMessage::assistant("第一批回复"));
m.push(ChatMessage::user("第二批"));
m.push(ChatMessage::assistant("第二批回复"));
m.push(ChatMessage::user("近期"));
m.set_compress_recall(tmp.path(), "conv-c");
let _ = m.compress_old_messages(2);
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
m.set_compress_recall(tmp.path(), "conv-c");
let second = m.compress_old_messages(4);
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
let path = m.take_archive_path().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
assert!(text.contains("第一批回复"), "第一批原文仍在");
assert!(text.contains("第二批回复"), "第二批原文已追加");
}
}
File diff suppressed because it is too large Load Diff
-909
View File
@@ -1,909 +0,0 @@
//! 上下文管理器 — 管理对话上下文和 token 预算
//!
//! 职责:
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
//!
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
//!
//! # 子模块
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize;
pub mod archive;
use crate::context_helpers::{
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
TRIM_TARGET_RATIO,
};
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
pub use crate::context_helpers::{
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
};
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
// ============================================================
// 上下文管理器
// ============================================================
/// 上下文管理器
///
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
pub struct ContextManager {
messages: Vec<TrackedMessage>,
/// 当前历史总 token 数(不含 system prompt)
history_tokens: u32,
config: ContextConfig,
estimator: TokenEstimator,
/// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
is_compressing: bool,
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
pending_topic_marker: Option<String>,
/// msg-split-phase1:已落库(ai_messages 表)的消息条数。
///
/// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save
/// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的
/// `replace_conversation`(DELETE 整对话 + 全量重插)。
///
/// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩:
/// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round
/// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。
///
/// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。
/// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。
persisted_msg_count: usize,
/// msg-split-phase1:全量重写需求标志。
///
/// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩,
/// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。
/// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。
///
/// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round /
/// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content /
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
needs_full_rewrite: bool,
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
compress_recall: Option<archive::RecallTarget>,
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
archive_path: Option<std::path::PathBuf>,
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
cache_friendly: bool,
}
impl ContextManager {
pub fn new(config: ContextConfig) -> Self {
Self {
messages: Vec::new(),
history_tokens: 0,
config,
estimator: TokenEstimator::default(),
is_compressing: false,
pending_topic_marker: None,
persisted_msg_count: 0,
needs_full_rewrite: false,
compress_recall: None,
archive_path: None,
cache_friendly: false,
}
}
/// 追加消息(自动计算 token 并更新缓存)
///
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
pub fn push(&mut self, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
// 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment /
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
if message.is_active() {
self.history_tokens += tokens;
}
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
Some(intent.as_str().to_string())
} else {
None
}
} else {
None
};
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
if matches!(message.role, MessageRole::User) {
if let Some(prev_topic) = self.last_user_topic() {
if let Some(this_topic) = &topic {
if prev_topic != *this_topic {
self.pending_topic_marker =
Some(format!("{}|{}", prev_topic, this_topic));
}
}
}
}
self.messages.push(TrackedMessage {
message,
token_count: tokens,
group,
topic,
});
}
/// 清空所有消息
pub fn clear(&mut self) {
self.messages.clear();
self.history_tokens = 0;
self.is_compressing = false;
self.pending_topic_marker = None;
// msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range),
// 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发,
// 全量重写空列表也会清 DB)。
self.persisted_msg_count = 0;
self.needs_full_rewrite = true;
self.compress_recall = None;
self.archive_path = None;
}
/// 消息数量
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
/// 当前历史占用的 token 数(不含 system prompt)
pub fn history_tokens(&self) -> u32 {
self.history_tokens
}
/// 预算上限
pub fn budget_limit(&self) -> u32 {
self.config.budget_limit()
}
// ── 核心:构建请求消息(受控裁剪版本)──
/// 构建发送给 LLM 的消息列表
///
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条),并在
/// 被淘汰区头部插入关键词补偿摘要(role=system,纯视图易变区,不落库不改存储)。
/// 返回 (消息列表, 是否发生了裁剪)。
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
let budget = self.budget_limit();
let available = budget.saturating_sub(sys_tokens);
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
if sys_tokens > budget {
tracing::warn!(
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
sys_tokens, budget
);
}
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start);
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
let (trim_end, removed) = if self.cache_friendly {
self.block_trim_target(&units, available)
} else {
self.greedy_trim_target(&units, available)
};
if trim_end == 0 {
tracing::warn!(
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT
);
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
return (
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
let evicted: Vec<ChatMessage> = self.messages[..trim_end]
.iter()
.map(|t| t.message.clone())
.collect();
let mut msgs: Vec<ChatMessage> = self.messages[trim_end..]
.iter()
.map(|t| t.message.clone())
.collect();
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
// **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
// DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
// 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
// (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
// 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
// cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
// 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
if let Some(text) = build_eviction_summary_text(&evicted) {
msgs.push(ChatMessage::user(text));
}
tracing::info!(
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
true,
)
}
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
///
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
(trim_end, removed)
}
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
///
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
/// 尽力裁到末单元,对齐贪心路径。
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
let mut removed: u64 = 0;
let mut hard = (0usize, 0u64);
let mut last = (0usize, 0u64);
for unit in units {
removed += unit.token_sum as u64;
last = (unit.end, removed);
let remaining = self.history_tokens.saturating_sub(removed as u32);
if remaining <= available {
hard = (unit.end, removed);
}
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
if remaining <= target
&& self
.messages
.get(unit.end)
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
{
return (unit.end, removed);
}
}
if hard.0 > 0 {
hard
} else {
last
}
}
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
///
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
if !self.cache_friendly {
return view;
}
view.into_iter()
.map(|mut m| {
if matches!(m.role, MessageRole::System) {
m.role = MessageRole::User;
}
m
})
.collect()
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
///
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
/// `ContextManager::sanitize_messages`),实现见子模块文档。
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
sanitize::sanitize_messages(messages)
}
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
pub fn assert_placeholder_pairing(
messages: Vec<ChatMessage>,
enabled: bool,
) -> Vec<ChatMessage> {
sanitize::assert_placeholder_pairing(messages, enabled)
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
self.messages.iter().map(|t| t.message.clone()).collect()
}
/// 只读最近 N 条消息(尾部切片 clone)。BE2 断路器/探索熔断检查等只关心
/// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。
/// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。
pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> {
let skip = self.messages.len().saturating_sub(n);
self.messages[skip..]
.iter()
.map(|t| t.message.clone())
.collect()
}
/// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
// 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len,
// 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。
let len = messages.len();
self.clear();
for msg in messages {
self.push(msg);
}
self.persisted_msg_count = len;
self.needs_full_rewrite = false;
// 重放历史时 push 会照常跑主题切换检测,历史里"当时已发生"的切换会残留进 marker;
// 留着它,加载后首轮 take_topic_marker 就拿到假话题锚点、误插一次切换标记。
// 恢复即重置:切换检测只对 restore 之后的实时 push 生效。
self.pending_topic_marker = None;
}
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
/// 返回 true 如果找到并替换了
///
/// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::Tool)
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
});
let Some(i) = pos else { return false };
// 先更新 content,再重估 token 并校正总量
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
// msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。
self.needs_full_rewrite = true;
true
}
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
///
/// 用于「重新生成」删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
///
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
pub fn pop_last_assistant_round(&mut self) -> bool {
if self.messages.is_empty() {
return false;
}
let mut popped_any = false;
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
while let Some(last) = self.messages.last() {
if matches!(last.message.role, MessageRole::User) {
break;
}
let removed = self.messages.pop().expect("just checked non-empty");
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
if matches!(removed.message.role, MessageRole::Assistant) {
popped_any = true;
}
}
// msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count)
// 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。
if popped_any {
self.needs_full_rewrite = true;
}
popped_any
}
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
///
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
///
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
/// 找不到返回 Err(()),调用方据此报错。
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
// 反向找末条 active user 消息且 content 匹配
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User)
&& t.message.content == target_content
&& t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
let mut count = 0usize;
for t in self.messages[i + 1..].iter_mut() {
if t.message.is_active() {
t.message.status = Some(MessageStatus::Truncated);
count += 1;
}
}
// msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。
if count > 0 {
self.needs_full_rewrite = true;
}
Ok(count)
}
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
///
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
let pos = self.messages.iter().rposition(|t| {
matches!(t.message.role, MessageRole::User) && t.message.is_active()
});
let Some(i) = pos else { return Err(()) };
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
// msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。
self.needs_full_rewrite = true;
Ok(())
}
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
self.messages.iter().map(|t| &t.message)
}
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
///
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
///
/// 用途:
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| {
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
})
.and_then(|t| t.message.id.clone())
}
/// 末条 assistant 消息的 id(消息级溯源用)。
pub fn last_assistant_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::Assistant)
}
/// 末条 user 消息的 id(消息级溯源用)。
pub fn last_user_message_id(&self) -> Option<String> {
self.last_message_id_by_role(MessageRole::User)
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
///
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
fn last_user_topic(&self) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| matches!(t.message.role, MessageRole::User))
.and_then(|t| t.topic.clone())
}
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
///
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
pub fn take_topic_marker(&mut self) -> Option<String> {
self.pending_topic_marker.take()
}
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
//
// 已全量接入压缩链路:
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
// insert_at(摘要/续接锚点 system 消息插入首位)
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
pub fn config(&self) -> &ContextConfig {
&self.config
}
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token)
///
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
///
/// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调
/// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行,
/// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut
/// 切片时不能再持有 &mut self 标志)。
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
&mut self.messages
}
/// msg-split-phase1:显式标记"下次 save 须全量重写"。
///
/// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时
/// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。
pub fn mark_needs_full_rewrite(&mut self) {
self.needs_full_rewrite = true;
}
/// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation
/// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。
pub fn persisted_msg_count(&self) -> usize {
self.persisted_msg_count
}
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
/// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。
/// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty
/// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。
pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) {
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
self.persisted_msg_count = self.persisted_msg_count.max(new_count);
if full_rewrite_applied {
self.needs_full_rewrite = false;
}
}
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
pub fn needs_full_rewrite(&self) -> bool {
self.needs_full_rewrite
}
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
///
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
if message.is_active() {
self.history_tokens += tokens;
}
self.messages.insert(index, TrackedMessage {
message,
token_count: tokens,
group,
topic: None,
});
// msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线
// persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。
self.needs_full_rewrite = true;
}
/// 追加一条 system 锚点消息(警示文本/压缩摘要),自动避开**悬空 tool_call 头**。
///
/// **源头守静(909)**:OpenAI/内部协议不变式是「assistant(tool_calls) 之后必须紧随
/// 其 tool 响应」。主 loop 的顺序是 push 头(`finalize_round_output`)→ 释放锁 →
/// 执行工具 → 再取锁 push 结果;若在「头已入、结果未回」的窗口内从尾部追加 system
/// (后台压缩摘要/警示/nudge/主题标记),system 就楔进 head 与其响应之间 → 上游报
/// 400「insufficient tool messages following tool_calls message」。
///
/// 本出口统一处置:末条非悬空头 → 常规尾部追加(不位移前缀,保自动前缀缓存,行为与
/// 原 `push` 一致);末条是悬空头 → 改插在该头**之前**(index = len-1),不劈三元组,
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
if self.tail_is_dangling_head() {
let idx = self.messages.len() - 1;
self.insert_at(idx, message);
tracing::debug!(
idx,
"system 锚点避让悬空 tool_call 头插入(防楔入三元组致 400)"
);
true
} else {
self.push(message);
false
}
}
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
fn tail_is_dangling_head(&self) -> bool {
self.messages
.last()
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
///
/// 返回每个单元的右开区间 end + token 总和,保证:
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
///
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
while i < protect_start {
let mut token_sum = 0u32;
if self.messages[i].group == MessageGroup::ToolCallHead {
token_sum += self.messages[i].token_count;
i += 1;
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
token_sum += self.messages[i].token_count;
i += 1;
}
if i < protect_start
&& self.messages[i].group == MessageGroup::Standalone
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
{
token_sum += self.messages[i].token_count;
i += 1;
}
} else {
token_sum += self.messages[i].token_count;
i += 1;
}
units.push(EvictionUnit { end: i, token_sum });
}
units
}
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
///
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len());
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
self.messages[..end]
.iter()
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
}
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
///
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
///
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`,且实际
/// 压缩边界会回退到最近的对话单元边界(三元组不劈开,见方法内注释)。
///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
let recall = self.compress_recall.take();
self.archive_path = None;
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
// 不超过 raw_end 的最大者」回退(只少压不多压,不侵占保护区);三元组要么整段压缩
// 要么整段保留。空范围/空历史无单元,取 0。
let raw_end = compress_end.min(self.messages.len());
let end = self
.build_eviction_units(self.messages.len())
.iter()
.map(|u| u.end)
.take_while(|&e| e <= raw_end)
.last()
.unwrap_or(0);
let mut newly_compressed = Vec::new();
for t in self.messages[..end].iter_mut() {
if t.message.is_active() {
newly_compressed.push(t.message.clone());
t.message.status = Some(MessageStatus::Compressed);
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
}
}
// msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛
// (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时
// newly_compressed 为空,状态未变,无需全量重写)。
if !newly_compressed.is_empty() {
self.needs_full_rewrite = true;
if let Some(target) = recall {
match archive::append_archive(&target, &newly_compressed) {
Some(p) => self.archive_path = Some(p),
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
}
}
}
newly_compressed
}
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
/// 路径经 [`Self::take_archive_path`] 取走。
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
}
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
self.archive_path.take()
}
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
pub fn set_cache_friendly(&mut self, enabled: bool) {
self.cache_friendly = enabled;
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool {
self.is_compressing
}
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
pub fn set_compressing(&mut self, v: bool) {
self.is_compressing = v;
}
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
}
#[cfg(test)]
mod manager_tests;
#[cfg(test)]
mod compress_boundary_tests {
use super::*;
use crate::provider::ToolCall;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 构造 4 个周期轮次,每轮 [user, assistant(tool_calls), tool_result, assistant]:
/// user 独占一个单元,后三条(头+尾+紧随独立 assistant)合并为一个单元,
/// 单元边界落在 4/8/12/16。
fn periodic_rounds() -> ContextManager {
let mut m = mgr();
for r in 0..4 {
m.push(ChatMessage::user(&format!("问题 {}", r)));
m.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new(format!("c{}", r), "run_command", "{}")],
));
m.push(ChatMessage::tool_result(format!("c{}", r), "ok"));
m.push(ChatMessage::assistant("小结"));
}
m
}
#[test]
fn compress_end_mid_unit_retreats_to_unit_boundary() {
let mut m = periodic_rounds();
// len=16,调用方口径 len - PROTECT_COUNT(6) = 10,落在第 3 轮三元组中间
// (9 头 / 10 尾 / 11 独立)。旧行为会把头压掉却留下孤立的 tool 结果。
// 回退到完整单元边界 9:只多压前面的 user,不劈开三元组(只少压不多压)。
let compressed = m.compress_old_messages(10);
assert_eq!(compressed.len(), 9, "边界回退到完整单元边界 9");
let msgs = m.messages_mut();
for i in 9..16 {
assert!(msgs[i].message.is_active(), "第 {} 条应保持 active", i);
}
// 关键回归点:第 3 轮三元组(9 头 / 10 尾 / 11 独立)整体保持 active,头未被劈开
assert!(msgs[9].message.is_active(), "三元组头不应被单独压缩");
assert!(msgs[10].message.is_active(), "三元组尾不应残留 active 而头被压");
assert!(msgs[11].message.is_active());
assert_eq!(msgs[9].group, MessageGroup::ToolCallHead);
}
#[test]
fn compress_end_on_boundary_unchanged() {
let mut m = periodic_rounds();
// end=8 恰为单元边界,行为与对齐前一致(整 8 条压缩)。
let compressed = m.compress_old_messages(8);
assert_eq!(compressed.len(), 8);
let msgs = m.messages_mut();
assert!(msgs[..8].iter().all(|t| !t.message.is_active()));
assert!(msgs[8..].iter().all(|t| t.message.is_active()));
}
#[test]
fn compress_end_oversized_still_compresses_all() {
let mut m = periodic_rounds();
// 越界 clamp 到 len:全量单元分组后末单元 end=len,语义与改动前一致。
let compressed = m.compress_old_messages(999);
assert_eq!(compressed.len(), 16);
assert_eq!(m.history_tokens(), 0);
}
}
File diff suppressed because it is too large Load Diff
+45 -720
View File
@@ -1,4 +1,4 @@
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分 //! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
//! //!
//! 职责: //! 职责:
//! - Token 粗估器(字符级近似,无 tokenizer 依赖) //! - Token 粗估器(字符级近似,无 tokenizer 依赖)
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。 /// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TokenEstimator { pub struct TokenEstimator {
/// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token) /// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
pub chars_ratio: f32, pub chars_ratio: f32,
/// 每条消息固定开销(role 标记 + 格式) /// 每条消息固定开销(role 标记 + 格式)
pub per_message_overhead: u32, pub per_message_overhead: u32,
@@ -44,83 +44,34 @@ impl Default for TokenEstimator {
} }
} }
/// CJK(中日韩)字符 token 比率:1 字约 1~1.5 token,取 1.2 略保守。
/// 此前统一按 0.35 计,中文低估 2~3 倍,长中文会话预算门形同虚设
///(估 3 万实 9 万,裁剪不触发直顶 provider 上限)。
const CJK_TOKEN_RATIO: f32 = 1.2;
// 图片 token 地板(base64 与 url 两模式同用,Anthropic 保守 ~1600/图,OpenAI 按 tile)。
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
// 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
// 会被 0.35 稀释成 560,保底名存实亡。
// base64 同用地板(此前按字符 0.35 计):provider 端图片按分辨率/张数计费,与 base64
// 字符串长度无关,25 万字符按 0.35 计 8.7 万 token 是纯高估,致含图消息过度裁剪;
// 两模式语义等价(都是一张图),成本口径单一(DRY)。
const IMAGE_TOKEN_FLOOR: usize = 1600;
/// CJK 字符判定:中日韩文字/假名/谚文/CJK 标点/全角形式。
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F // CJK 标点
| 0x3040..=0x30FF // 日文假名
| 0x3400..=0x4DBF // 表意文字扩展 A
| 0x4E00..=0x9FFF // CJK 基本区
| 0xAC00..=0xD7AF // 韩文谚文
| 0xF900..=0xFAFF // 兼容表意文字
| 0xFF00..=0xFFEF // 全角形式
)
}
/// 分档字符 token 计数:CJK 按 [`CJK_TOKEN_RATIO`],其余按调用方给定比率。
fn weighted_char_tokens(s: &str, plain_ratio: f32) -> u32 {
let (mut cjk, mut plain) = (0usize, 0usize);
for ch in s.chars() {
if is_cjk(ch) { cjk += 1 } else { plain += 1 }
}
(cjk as f32 * CJK_TOKEN_RATIO + plain as f32 * plain_ratio).ceil() as u32
}
impl TokenEstimator { impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计) /// 估算单条消息的 token 数(保守估计)
/// ///
/// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计, /// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 不随 base64 字符串长度膨胀。 /// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 { pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
// namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符 let mut char_count = msg.content.chars().count();
// 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
// 本就由 namespace 上限闸门控,此处入账只为预算可见性。
let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
match crate::namespace_store::parse_ref_content_len(&msg.content) {
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
None => weighted_char_tokens(&msg.content, self.chars_ratio),
}
} else {
weighted_char_tokens(&msg.content, self.chars_ratio)
};
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
let mut image_floor_tokens: u32 = 0;
if let Some(parts) = &msg.parts { if let Some(parts) = &msg.parts {
for p in parts { for p in parts {
match p { match p {
crate::provider::ContentPart::Text { text } => { crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
content_tokens = content_tokens
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
}
crate::provider::ContentPart::Image { base64, url, .. } => { crate::provider::ContentPart::Image { base64, url, .. } => {
// base64 与 url 语义等价(都是一张图),同用地板;有 base64 时 // CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// url 为空(构造端二选一),不重复计 // 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
if base64.is_some() || url.is_some() { // base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
image_floor_tokens = if let Some(b) = base64 {
image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32); char_count += b.chars().count();
} else if let Some(u) = url {
char_count += u.chars().count();
} }
} }
} }
} }
} }
let mut total = content_tokens + image_floor_tokens + self.per_message_overhead; let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有) // tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls { if let Some(ref calls) = msg.tool_calls {
@@ -139,9 +90,9 @@ impl TokenEstimator {
total total
} }
/// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`] /// 估算纯文本字符串的 token 数(用于 system prompt)
pub fn estimate_text(&self, text: &str) -> u32 { pub fn estimate_text(&self, text: &str) -> u32 {
weighted_char_tokens(text, self.chars_ratio) (text.chars().count() as f32 * self.chars_ratio).ceil() as u32
} }
} }
@@ -194,7 +145,7 @@ pub enum MessageGroup {
/// 带有 token 缓存和分组信息的消息条目 /// 带有 token 缓存和分组信息的消息条目
/// ///
/// 字段 `pub`:供 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后, /// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问, /// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。 /// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
pub struct TrackedMessage { pub struct TrackedMessage {
@@ -227,15 +178,20 @@ pub fn classify_group(msg: &ChatMessage) -> MessageGroup {
// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM) // 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM)
// ============================================================ // ============================================================
/// 从消息提取高频关键词(提炼核心,纯函数无 LLM)— 关键词摘要/淘汰补偿摘要共用。 /// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
///
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
/// 塞回首条,作为续接锚点。
/// ///
/// 策略(纯启发式,无 tokenizer 依赖): /// 策略(纯启发式,无 tokenizer 依赖):
/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。 /// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。
/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。 /// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。
/// - 去停用词(常见虚词/助词,中英混合)。 /// - 去停用词(常见虚词/助词,中英混合)。
/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。 /// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。
/// - 空消息/无可提取词 → 空 Vec,调用方判空跳过。 /// - 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
pub fn extract_top_keywords(msgs: &[ChatMessage], top_n: usize) -> Vec<String> { pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
const TOP_N: usize = 10;
const MIN_WORD_LEN: usize = 2; const MIN_WORD_LEN: usize = 2;
// 中英混合停用词表(高频虚词/助词/常见无主题意义词)。 // 中英混合停用词表(高频虚词/助词/常见无主题意义词)。
@@ -276,7 +232,7 @@ pub fn extract_top_keywords(msgs: &[ChatMessage], top_n: usize) -> Vec<String> {
} }
if freq.is_empty() { if freq.is_empty() {
return Vec::new(); return String::new();
} }
let mut entries: Vec<(String, u32)> = freq.into_iter().collect(); let mut entries: Vec<(String, u32)> = freq.into_iter().collect();
@@ -287,82 +243,17 @@ pub fn extract_top_keywords(msgs: &[ChatMessage], top_n: usize) -> Vec<String> {
.then_with(|| a.0.cmp(&b.0)) .then_with(|| a.0.cmp(&b.0))
}); });
entries let keywords: Vec<&str> = entries.iter().take(TOP_N).map(|(w, _)| w.as_str()).collect();
.into_iter()
.take(top_n)
.map(|(w, _)| w)
.collect()
}
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
///
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
/// 塞回首条,作为续接锚点。提炼核心见 [`extract_top_keywords`]。
/// 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
const TOP_N: usize = 10;
let keywords = extract_top_keywords(msgs, TOP_N);
if keywords.is_empty() { if keywords.is_empty() {
return String::new(); return String::new();
} }
format!( format!(
"[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}", "[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}",
keywords.join("、") keywords.join("、")
) )
} }
/// 视图裁剪淘汰补偿摘要 — 从被淘汰消息提炼一短段主题概要(纯函数)。
///
/// 背景:build_for_request 超预算按淘汰单元裁剪发送视图时,被淘汰内容从此对 LLM 不可见
/// 且无任何补偿,与压缩路径(有摘要补偿)行为不一致。本函数把被淘汰文本提炼成
/// 「[早期对话概要] 高频主题: …」一短段,调用方将其作为 role=user 消息插回发送视图
/// 被淘汰区头部(role=system 会被 anthropic_compat 折叠进带 cache_control 的顶层
/// system 段,而本摘要每轮再生成,会持续改写缓存指纹)。
///
/// **缓存铁律**:产出只进 messages 列表(易变区),严禁拼进 system_prompt 或任何
/// 稳定段/缓存指纹相关结构。build_for_request 为纯视图:每次请求即时生成,不落库、
/// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。
///
/// 策略:
/// - user/assistant 取全文,tool 结果仅取首行(大段 JSON/日志无主题价值);
/// - 被淘汰文本合计过短(< 100 字符)不值得产摘要 → None;
/// - 经 [`extract_top_keywords`] 提炼,空/无有效文本 → None;
/// - 硬截断至 [`EVICTION_SUMMARY_MAX_CHARS`] 字符(按 char 边界,防汉字切半)。
pub fn build_eviction_summary_text(msgs: &[ChatMessage]) -> Option<String> {
/// 被淘汰内容合计低于此字符数则不值得产摘要(仅淘汰一两条寒暄无需补偿)。
const EVICTION_SUMMARY_MIN_CHARS: usize = 100;
const KEYWORD_TOP_N: usize = 10;
// 收集被淘汰文本:user/assistant 取全文,tool 取首行;合计字符数做"值得与否"闸门。
let feed: Vec<ChatMessage> = msgs
.iter()
.map(|m| {
let mut m = m.clone();
if matches!(m.role, MessageRole::Tool) {
m.content = m.content.lines().next().unwrap_or("").to_string();
}
m
})
.collect();
let total_chars: usize = feed.iter().map(|m| m.content.chars().count()).sum();
if total_chars < EVICTION_SUMMARY_MIN_CHARS {
return None;
}
let keywords = extract_top_keywords(&feed, KEYWORD_TOP_N);
if keywords.is_empty() {
return None;
}
let mut text = format!("[早期对话概要] 高频主题: {}", keywords.join("、"));
// 硬上限截断(按 char 计数,多字节汉字不会切出非法边界)。
if text.chars().count() > EVICTION_SUMMARY_MAX_CHARS {
text = text.chars().take(EVICTION_SUMMARY_MAX_CHARS).collect();
}
Some(text)
}
/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。 /// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。
/// ///
/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request); /// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request);
@@ -458,17 +349,6 @@ pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断, /// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。 /// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512; pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
/// read_file 豁免上限(P1 工具结果瘦身 2026-10-10):≤此值原样返回,超过则走压缩+落盘。
///
/// 依据实测:全库工具结果体量前二为 patch_file 34.6% / read_file 29.6%(单条 max 150KB),
/// 原「read_file 一律豁免」让 29.6% 体量完全裸奔(65K 均值 prompt 压不下来的直接原因之一)。
/// 保留小文件豁免(AI 定向读代码需完整语义——历史有「限读 28KB 被折叠致能力受损」实证),
/// 阈值取 8KB 与 D2a 审计侧同构;超过则摘要+落盘句柄(可无损读回)。
pub const READ_FILE_OFFLOAD_BYTES: usize = 8 * 1024;
/// 追加落盘句柄的最小原文字节数(与 D2a 审计侧 8KB 阈值对齐)。
/// 小于此值的压缩结果**不加句柄**:有损摘要的丢失量小,而句柄行(约 150 字符)会净增 token;
/// ≥此值才值得(丢失量大、句柄占比可忽略),兼顾「无损可读回」与「不反噬瘦身目标」。
pub const TOOL_OFFLOAD_HANDLE_MIN_BYTES: usize = 8 * 1024;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 /// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
/// ///
@@ -500,32 +380,8 @@ pub fn should_summarize_tool_result(
/// - 错误行(在头尾区间外的)额外插入,标注位置。 /// - 错误行(在头尾区间外的)额外插入,标注位置。
/// - 中间大段省略为 `... (省略 N 行) ...`。 /// - 中间大段省略为 `... (省略 N 行) ...`。
/// ///
/// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力, /// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
/// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘
/// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。
/// 空内容一律返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String { pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let compressed = extract_key_info_inner(content, tool_name);
// P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 read_file 读回。
// 句柄**只对 ≥ TOOL_OFFLOAD_HANDLE_MIN_BYTES 的大结果**追加——小结果有损摘要丢得少,
// 加句柄行反而净增 token(反噬瘦身目标);大结果丢失量大,句柄占比可忽略,才值得。
// base 未注入(单测/未初始化)→ 不加句柄,输出与改造前逐字节一致(零行为变化)。
if content.len() >= TOOL_OFFLOAD_HANDLE_MIN_BYTES && compressed.len() < content.len() {
if let Some(handle) = crate::tool_offload::offload_with_handle(content, tool_name) {
return format!("{compressed}\n{handle}");
}
}
compressed
}
/// extract_key_info 的纯压缩实现(零 IO,便于单测与复用)。
fn extract_key_info_inner(content: &str, tool_name: &str) -> String {
// read_file 高阈值豁免(P1 2026-10-10):实测任务结果体量 read_file 占 29.6%(单条 max 150KB),
// 原「一律豁免」致最大头裸奔(65K 均值 prompt 压不下来的直接原因之一)。
// 改为「小文件豁免 + 超阈值压缩并落盘」:既保 AI 定向读代码语义,又让超大读取可瘦身。
if tool_name == "read_file" && content.len() <= READ_FILE_OFFLOAD_BYTES {
return content.to_string();
}
// JSON 感知压缩:识别对象中的大数组/大字符串并截断 // JSON 感知压缩:识别对象中的大数组/大字符串并截断
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) { if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(obj) = val.as_object_mut() { if let Some(obj) = val.as_object_mut() {
@@ -552,13 +408,12 @@ fn extract_key_info_inner(content: &str, tool_name: &str) -> String {
*field = serde_json::Value::String(out.join("\n")); *field = serde_json::Value::String(out.join("\n"));
truncated = true; truncated = true;
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX { } else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。 // BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 按字符数截断保留头尾,保证压缩至少生效。 // 按字符数截断保留头尾,保证压缩至少生效。
// 标记格式与纯文本单行分支对齐:含 `(截断)` 连续子串,便于上层断言/解析。
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect(); let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect(); let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
*field = serde_json::Value::String(format!( *field = serde_json::Value::String(format!(
"{}...(截断) 原始 {} 字符...{}", "{}...(截断,原始 {} 字符)...{}",
head, s.chars().count(), tail head, s.chars().count(), tail
)); ));
truncated = true; truncated = true;
@@ -584,7 +439,7 @@ fn extract_key_info_inner(content: &str, tool_name: &str) -> String {
let total = lines.len(); let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES; let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary { if total <= kept_boundary {
// 行数少但内容超大的情况(单行 50KB),行级截断无效。 // BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
// 按字符数截断保证压缩至少生效。 // 按字符数截断保证压缩至少生效。
let char_count = content.chars().count(); let char_count = content.chars().count();
if char_count > TOOL_RESULT_CHAR_LIMIT { if char_count > TOOL_RESULT_CHAR_LIMIT {
@@ -669,7 +524,7 @@ fn is_error_line(line: &str) -> bool {
/// 淘汰单元:连续消息范围 [..end) + token 总和 /// 淘汰单元:连续消息范围 [..end) + token 总和
/// ///
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(调用方读 `end` / `token_sum`)。 /// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
pub struct EvictionUnit { pub struct EvictionUnit {
pub end: usize, pub end: usize,
pub token_sum: u32, pub token_sum: u32,
@@ -682,28 +537,11 @@ pub struct EvictionUnit {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次) /// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
pub const PROTECT_COUNT: usize = 6; pub const PROTECT_COUNT: usize = 6;
/// 块状裁剪目标水位(× available):超预算时一次裁到该水位以下,而非贪心的刚好塞下。
///
/// 块大小 = (1 − T) × available,随模型窗口自适应缩放。两次裁剪之间裁剪边界不动 →
/// 被淘汰集不变 → 淘汰摘要内容不变(关键词提炼确定性)→ 发送视图前缀逐字节稳定,
/// 前缀缓存每轮命中。默认 0.8:块 ≈ 19K tokens,稳定窗口 2–9 轮,估算误差 ±15%
/// 被水位差吸收,不会刚裁完就再触。
pub const TRIM_TARGET_RATIO: f32 = 0.8;
/// 淘汰补偿摘要的硬上限(字符数)。
///
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉
/// 本次裁剪腾出的预算,故硬截断到此上限。原 300 字(≈百 token)对几十轮历史的
/// 摘要形同虚设,放大到 1000(≈300 token)——相对被裁掉的数千~数万 token 仍是零头,
/// 不侵蚀裁剪收益;PROTECT_COUNT 保持 6 不动(无实证症状,避免无据行为变更)。
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 1000;
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。 /// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。 /// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_"; pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
/// 占位配对完整性(解 400 orphan)常量开关。 /// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
/// ///
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER) /// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→ /// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
@@ -715,9 +553,6 @@ pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
/// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。 /// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。
pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true; pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true;
/// namespace 引用路径前缀(转发至 namespace_store,供 context.rs 统一路径引用)
pub const NAMESPACE_REF_PREFIX: &str = crate::namespace_store::NAMESPACE_REF_PREFIX;
/// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。 /// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。
/// ///
/// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`, /// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`,
@@ -769,244 +604,6 @@ pub fn extract_pending_tc_id(content: &str) -> Option<&str> {
.filter(|id| !id.is_empty()) .filter(|id| !id.is_empty())
} }
// ============================================================
// Wave 共享契约类型(由 上下文管理演进-执行计划 Agent 3 定义)
// ============================================================
/// 压缩摘要双格式(T3 输出, T5 消费)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressedSummary {
/// JSON 卡片序列化(主题/决策/待办/token节省)
pub json_card: String,
/// 自然语言摘要(向前兼容关键词兜底降级)
pub nl_summary: String,
}
/// 工作流 DAG 上下文块(T4 输出 → 注入 system prompt)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowContextBlock {
pub workflow_id: String,
pub workflow_name: String,
pub total_nodes: usize,
pub completed_nodes: usize,
pub current_node: Option<String>,
pub next_nodes: Vec<String>,
}
impl WorkflowContextBlock {
/// 格式化为 system prompt 可读文本
pub fn to_system_text(&self) -> String {
let mut s = format!("工作流: {} (ID: {})", self.workflow_name, self.workflow_id);
s.push_str(&format!("\n进度: {}/{}", self.completed_nodes, self.total_nodes));
if let Some(ref cur) = self.current_node {
s.push_str(&format!("\n当前步骤: {}", cur));
}
if !self.next_nodes.is_empty() {
s.push_str(&format!("\n后续: {}", self.next_nodes.join(" → ")));
}
s
}
}
/// 目标状态(T5 WorkingContext)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GoalStatus {
Active,
Completed,
Cancelled,
}
/// 步骤状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StepStatus {
Pending,
InProgress,
Done,
Blocked,
}
/// 目标条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalItem {
pub id: String,
pub text: String,
pub status: GoalStatus,
}
/// 决策条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionItem {
pub question: String,
pub decision: String,
pub reason: String,
}
/// 步骤信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepInfo {
pub step_name: String,
pub status: StepStatus,
}
/// WorkingContext 版本号(脏标记追踪)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingContextVersion {
pub goals_changed_at: u32,
pub decisions_changed_at: u32,
pub unresolved_changed_at: u32,
pub step_changed_at: u32,
pub current_turn: u32,
}
impl WorkingContextVersion {
pub fn new() -> Self {
Self {
goals_changed_at: 0,
decisions_changed_at: 0,
unresolved_changed_at: 0,
step_changed_at: 0,
current_turn: 0,
}
}
/// 压缩后重锚定:将所有脏标记置为 current_turn,未来 recent_turns 轮内各段必注入一次。
/// 不解析 summary 内容,仅为签名兼容保留参数。
pub fn reset_all(&mut self, summary: &str, current_turn: u32) {
let _ = summary;
self.goals_changed_at = current_turn;
self.decisions_changed_at = current_turn;
self.unresolved_changed_at = current_turn;
self.step_changed_at = current_turn;
self.current_turn = current_turn;
}
}
/// WorkingContext 常驻上下文(T5 实现)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingContext {
pub goals: Vec<GoalItem>,
pub decisions: Vec<DecisionItem>,
pub unresolved: Vec<String>,
pub current_step: Option<StepInfo>,
pub version: WorkingContextVersion,
}
impl WorkingContext {
pub fn new() -> Self {
Self {
goals: Vec::new(),
decisions: Vec::new(),
unresolved: Vec::new(),
current_step: None,
version: WorkingContextVersion::new(),
}
}
/// 构建本轮需要注入的 L1 内容(L1a 必带 + L1b 条件注入)
pub fn build_injection(&self, recent_turns: u32) -> String {
let mut parts: Vec<String> = Vec::new();
// L1a: 当前步骤(每轮必带)
if let Some(ref step) = self.current_step {
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
}
// L1b: 过去 N 轮内有变更才注入。窗口语义 = 最近 N 轮 [current_turn-N+1, current_turn]:
// 变更落在第 current_turn-N 轮(即 N+1 轮前)已出窗。原实现下界取 current_turn-N 且
// 比较 >=,把 N+1 轮前的变更也划进窗口(off-by-one 多注一轮,脏段晚一轮才过期)。
let threshold = self
.version
.current_turn
.saturating_sub(recent_turns)
.saturating_add(1);
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
if has_active && self.version.goals_changed_at >= threshold {
let active: Vec<&str> = self.goals.iter()
.filter(|g| g.status == GoalStatus::Active)
.map(|g| g.text.as_str())
.collect();
parts.push(format!("目标({}): {}", active.len(), active.join("; ")));
}
if self.version.decisions_changed_at >= threshold && !self.decisions.is_empty() {
let recent: Vec<&str> = self.decisions.iter().map(|d| d.decision.as_str()).collect();
parts.push(format!("近期决策: {}", recent.join("; ")));
}
if self.version.unresolved_changed_at >= threshold && !self.unresolved.is_empty() {
parts.push(format!("待解: {}", self.unresolved.join("; ")));
}
parts.join("\n")
}
/// 新用户轮:上一批 Active 目标落 Completed,本条用户请求截断后记为唯一 Active 目标;
/// current_turn 自增。自动目标与 G1 pinned_goals(用户手动钉,另链路)互不覆盖。
/// 已 Completed 的自动目标无读取方,直接丢弃(只保留 Active),防长会话无限膨胀。
pub fn begin_turn(&mut self, user_request: &str, max_chars: usize) {
self.version.current_turn += 1;
// 自动目标单活跃语义:新请求即替换,旧目标不保留(Cancelled 同理无读取方,一并丢弃)
self.goals.clear();
let trimmed = user_request.trim();
if !trimmed.is_empty() {
self.goals.push(GoalItem {
id: format!("t{}", self.version.current_turn),
text: truncate_chars(trimmed, max_chars),
status: GoalStatus::Active,
});
}
self.version.goals_changed_at = self.version.current_turn;
}
/// 压缩后重锚定:历史消息已被摘要替换,从压缩摘要 JSON 卡片(字段 topics/decisions/key_files,
/// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符,
/// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。
/// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。
pub fn sync_after_compress(&mut self, json_card: &str) {
self.version.current_turn += 1;
if let Ok(card) = serde_json::from_str::<serde_json::Value>(json_card) {
let items: Vec<DecisionItem> = card
.get("decisions")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|d| d.get("summary").and_then(|s| s.as_str()))
.filter(|s| !s.trim().is_empty())
.take(5)
.map(|s| DecisionItem {
question: String::new(),
decision: truncate_chars(s, 120),
reason: String::new(),
})
.collect()
})
.unwrap_or_default();
// 仅当解析出非空决策才覆盖;脏标记由 reset_all 统一置位
if !items.is_empty() {
self.decisions = items;
}
} else {
// 解析失败不吞:压缩后决策回填静默丢失会让"决策段凭空消失"无从排查,warn 留痕
tracing::warn!(
"sync_after_compress: 摘要 JSON 卡片解析失败,decisions 未恢复(卡片 {} 字节)",
json_card.len()
);
}
self.version.reset_all(json_card, self.version.current_turn);
}
}
/// 按 chars 计数截断文本,越界补省略号(begin_turn / sync_after_compress 共用)。
/// 语义与正典(src-tauri audit/utils.rs truncate_chars_ellipsis,DRY #11)一致;
/// 跨 crate 不引入依赖,保持本地同语义实现(有意保留原名,勿误认遗漏)。
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let head: String = s.chars().take(max).collect();
format!("{}…", head)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1060,66 +657,6 @@ mod tests {
// token 频次 1 也应入榜(去停用词后剩余的有意义词)。 // token 频次 1 也应入榜(去停用词后剩余的有意义词)。
} }
// ── 视图裁剪淘汰补偿摘要纯函数(build_eviction_summary_text)测试 ──
#[test]
fn eviction_summary_short_text_returns_none() {
// 合计 < 100 字符 → 不值得产摘要。
let msgs = vec![ChatMessage::user("短文本"), ChatMessage::assistant("另一条")];
assert_eq!(build_eviction_summary_text(&msgs), None);
}
#[test]
fn eviction_summary_no_user_returns_none() {
// 无 user 消息 → 提炼核心不认 assistant → 无关键词 → None(即使合计超 100 字符)。
let long = "这是一段没有用户主题的 assistant 长文本,重复三次以超过一百字符的最短闸门验证。".repeat(3);
assert!(long.chars().count() > 100);
let msgs = vec![ChatMessage::assistant(&long), ChatMessage::assistant(&long)];
assert_eq!(build_eviction_summary_text(&msgs), None);
}
#[test]
fn eviction_summary_formats_prefix_and_keyword() {
// 合计须过 100 字符闸门:user 文本重复 10 次(~70 字符)+ assistant(~37 字符)。
let user_text = "审批流程配置 ".repeat(10);
let msgs = vec![
ChatMessage::user(user_text.trim()),
ChatMessage::assistant("好的,已了解审批流程诉求,这里是一段足够长的回复用于通过最短字符闸门检查。"),
];
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
assert!(text.starts_with("[早期对话概要] 高频主题: "), "实际 {}", text);
assert!(text.contains("审批"), "应含高频词, 实际 {}", text);
}
#[test]
fn eviction_summary_tool_result_takes_first_line() {
// tool 结果取首行:多行 JSON 不整体进提炼,首行计入闸门字符数,摘要仍可产出。
// user 文本重复 3 次(~110 字符)+ tool 首行(~24 字符)合计过 100 闸门。
let tool = ChatMessage::tool_result("tc1", "{\"result\":\"第一行头部\"}\n第二行\n第三行");
let user_text = "预算闸门验证消息,这条 user 消息携带足够字符数确保超过下限触发提炼。".repeat(3);
let text = build_eviction_summary_text(&[ChatMessage::user(&user_text), tool])
.expect("应产出摘要");
assert!(text.starts_with("[早期对话概要] 高频主题: "));
}
#[test]
fn eviction_summary_hard_capped_at_max_chars() {
// 40 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词,
// 总长超上限(上限放大到 1000 后需更多数据才触发截断) → 硬截断。
let msgs: Vec<ChatMessage> = (0..40)
.map(|i| {
ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i))
})
.collect();
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
// 上限是防御性天花板(结构性产出 top-10 词表通常远低于它),断言「不超」而非「精确等于」
assert!(
text.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
"摘要不得超硬上限, 实际 {}",
text.chars().count()
);
}
// ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ── // ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ──
#[test] #[test]
@@ -1415,25 +952,23 @@ mod tests {
#[test] #[test]
fn extract_key_info_single_huge_line_no_newline_compressed() { fn extract_key_info_single_huge_line_no_newline_compressed() {
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。 // BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let content = "x".repeat(50_000); let content = "x".repeat(50_000);
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "read_file");
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len()); assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("已压缩"), "应含压缩标记"); assert!(result.contains("已压缩"), "应含压缩标记");
assert!(result.starts_with("[工具 run_command"), "应以工具名开头"); assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
assert!(result.contains("原始 50000 字符"), "应报告原始字符数"); assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
assert!(result.contains("(截断)"), "应含截断标记"); assert!(result.contains("(截断)"), "应含截断标记");
} }
#[test] #[test]
fn extract_key_info_json_huge_string_field_truncated() { fn extract_key_info_json_huge_string_field_truncated() {
// JSON 对象中大字符串字段(单行少行)逃逸压缩。 // BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。 // 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let large = "z".repeat(10_000); let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large); let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "read_file");
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len()); assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("_truncated"), "应含 _truncated 标记"); assert!(result.contains("_truncated"), "应含 _truncated 标记");
assert!(result.contains("src/main.rs"), "应保留 path 字段"); assert!(result.contains("src/main.rs"), "应保留 path 字段");
@@ -1452,10 +987,9 @@ mod tests {
#[test] #[test]
fn extract_key_info_eleven_lines_triggers_compression() { fn extract_key_info_eleven_lines_triggers_compression() {
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记 // 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect(); let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n"); let content = lines.join("\n");
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "read_file");
assert!(result.contains("已压缩"), "11 行应触发压缩"); assert!(result.contains("已压缩"), "11 行应触发压缩");
assert!(result.contains("line 1"), "保留首行"); assert!(result.contains("line 1"), "保留首行");
assert!(result.contains("line 11"), "保留末行"); assert!(result.contains("line 11"), "保留末行");
@@ -1466,7 +1000,6 @@ mod tests {
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含) // 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含) // 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
// 错误行在中间区间 → 标注 [行 N] 插入 // 错误行在中间区间 → 标注 [行 N] 插入
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect(); let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail) // idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
lines[2] = "error in head zone".to_string(); lines[2] = "error in head zone".to_string();
@@ -1475,7 +1008,7 @@ mod tests {
// idx=10(中间)错误行 → 标注插入 // idx=10(中间)错误行 → 标注插入
lines[10] = "error in middle".to_string(); lines[10] = "error in middle".to_string();
let content = lines.join("\n"); let content = lines.join("\n");
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "read_file");
// 中间错误行被标注插入(原始行号 11) // 中间错误行被标注插入(原始行号 11)
assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result); assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result);
// 头/尾错误行原样保留(无 [行 N] 标注) // 头/尾错误行原样保留(无 [行 N] 标注)
@@ -1544,7 +1077,7 @@ mod tests {
assert!(toks.contains(&"now".to_string())); assert!(toks.contains(&"now".to_string()));
} }
// ── 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ── // ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
#[test] #[test]
fn is_pending_placeholder_new_with_marker() { fn is_pending_placeholder_new_with_marker() {
@@ -1627,212 +1160,4 @@ mod tests {
assert_eq!(extract_pending_tc_id("文件内容"), None); assert_eq!(extract_pending_tc_id("文件内容"), None);
assert_eq!(extract_pending_tc_id(""), None); assert_eq!(extract_pending_tc_id(""), None);
} }
// ── read_file 豁免压缩AI 定向读代码不应被折叠) ──
//
// 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为
// "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行,
// 代码分析被阉割。修法:tool_name=="read_file" 一律原样返回(豁免)。
// read_file handler 自带 limit 硬上限 2000 行(file.rs),无爆 prompt 风险。
#[test]
fn extract_key_info_read_file_exempt_from_compression() {
// read_file 返回 JSON,content 字段 100 行(>10 行阈值)→ 其他工具会折叠,
// read_file 应豁免:原样返回,无 "(压缩中间内容)" / "_truncated"。
let content_str: String = (1..=100).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
// 模拟 read_file 真实返回结构
let content = serde_json::json!({
"path": "/some/file.rs",
"content": content_str,
"lines": 100,
"returned_lines": 100
})
.to_string();
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "read_file 大结果应原样返回(豁免压缩)");
assert!(!result.contains("压缩中间内容"), "read_file 不应折叠中间");
assert!(!result.contains("_truncated"), "read_file 不应打 _truncated 标记");
// content 字段 100 行完整保留
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["content"].as_str().unwrap().lines().count(), 100);
}
#[test]
fn extract_key_info_other_tools_still_compressed_when_large() {
// 对照:同样 100 行 content,run_command 应仍被压缩(中间折叠)。
let content_str: Vec<String> = (1..=100).map(|i| format!("line {}", i)).collect();
let content = serde_json::json!({
"stdout": content_str.join("\n"),
"exit_code": 0
})
.to_string();
let result = extract_key_info(&content, "run_command");
assert!(
result.contains("压缩中间内容"),
"run_command 大结果仍应压缩: {}",
result
);
assert!(result.contains("_truncated"), "run_command 应打 _truncated 标记");
}
#[test]
fn extract_key_info_read_file_short_content_unchanged() {
// read_file 小结果同样原样(豁免对大小一致生效)。
let content = serde_json::json!({
"path": "/x.rs",
"content": "only one line",
"lines": 1
})
.to_string();
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "read_file 小结果原样返回");
}
#[test]
fn extract_key_info_read_file_non_json_plaintext_exempt() {
// read_file 纯文本大结果(理论上 read_file 总返回 JSON,但兜底:非 JSON 也豁免)。
let lines: Vec<String> = (1..=50).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "read_file 纯文本应豁免压缩");
assert!(!result.contains("输出已压缩"), "read_file 纯文本不应压缩");
}
// ── WorkingContext 写入侧(begin_turn / sync_after_compress)──
#[test]
fn working_context_begin_turn_replaces_previous_goal() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("第一个请求", 100);
ctx.begin_turn("第二个请求", 100);
assert_eq!(ctx.version.current_turn, 2, "current_turn 应自增两次");
assert_eq!(ctx.goals.len(), 1, "替换语义:仅剩本条 Active 目标");
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
assert_eq!(ctx.goals[0].text, "第二个请求");
assert_eq!(ctx.goals[0].id, "t2");
}
#[test]
fn working_context_begin_turn_empty_request_only_bumps_turn() {
let mut ctx = WorkingContext::new();
ctx.begin_turn(" ", 100);
assert_eq!(ctx.version.current_turn, 1, "空文本仍应 bump turn");
assert!(ctx.goals.is_empty(), "空文本不 push 目标");
assert_eq!(ctx.version.goals_changed_at, 1);
}
#[test]
fn working_context_begin_turn_truncates_long_request() {
let mut ctx = WorkingContext::new();
let long = "长".repeat(50);
ctx.begin_turn(&long, 10);
assert_eq!(ctx.goals[0].text.chars().count(), 11, "10 字符 + 省略号");
assert!(ctx.goals[0].text.ends_with('…'));
}
#[test]
fn working_context_sync_after_compress_restores_decisions() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("压缩前目标", 100);
let card = r#"{"topics":["a"],"decisions":[{"summary":"采用块级 memo 渲染"},{"summary":"降级兜底"}],"key_files":[]}"#;
ctx.sync_after_compress(card);
assert_eq!(ctx.decisions.len(), 2);
assert_eq!(ctx.decisions[0].decision, "采用块级 memo 渲染");
assert!(ctx.decisions[0].question.is_empty(), "question 留空");
assert!(ctx.decisions[0].reason.is_empty(), "reason 留空");
assert_eq!(ctx.goals.len(), 1, "Active 目标保留不丢");
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
let out = ctx.build_injection(3);
assert!(out.contains("目标"), "应含目标段: {}", out);
assert!(out.contains("近期决策"), "应含近期决策段: {}", out);
}
#[test]
fn working_context_sync_after_compress_invalid_json_still_reanchors() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("压缩前目标", 100);
ctx.sync_after_compress("不是 JSON");
assert!(ctx.decisions.is_empty(), "非法 JSON 不恢复决策且不 panic");
let out = ctx.build_injection(3);
assert!(out.contains("目标"), "重锚定后仍应注入目标段: {}", out);
}
#[test]
fn working_context_injection_decays_after_window() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("会过期的目标", 100);
assert!(ctx.build_injection(3).contains("会过期的目标"), "窗口内应注入");
// 模拟多轮未再变更:直接推进 current_turn,脏窗口(3)过期后不再注入
ctx.version.current_turn = 10;
let out = ctx.build_injection(3);
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
}
#[test]
fn working_context_injection_window_is_exactly_n_turns() {
// 边界:脏窗口 = 最近 N 轮。turn=1 的变更、窗口 3:
// current_turn=3 → 第 1 轮是第 3 轮前,恰在窗内(1,2,3 共 3 轮),注入;
// current_turn=4 → 第 1 轮是第 4 轮前,出窗不注入(off-by-one bug 下会误注)。
let mut ctx = WorkingContext::new();
ctx.begin_turn("窗口边界目标", 100); // current_turn=1, goals_changed_at=1
ctx.version.current_turn = 3;
assert!(
ctx.build_injection(3).contains("窗口边界目标"),
"第 N 轮前的变更恰在窗内,应注入"
);
ctx.version.current_turn = 4;
assert!(
ctx.build_injection(3).is_empty(),
"第 N+1 轮前的变更应出窗,不注入"
);
}
#[test]
fn url_image_token_floor_survives_ratio_discount() {
// 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
// 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default();
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let tokens = est.estimate_message(&msg);
assert!(
tokens >= IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens,
IMAGE_TOKEN_FLOOR
);
}
#[test]
fn base64_image_counts_as_floor_not_chars() {
// base64 不按字符计:provider 端图片按张数/分辨率计费,25 万字符按 0.35
// 计 8.7 万 token 是纯高估致过度裁剪。改地板后与图的真实成本同量级。
let est = TokenEstimator::default();
let big_base64 = "iVBORw0KGgoAAAANS".repeat(15000); // ~25.5 万字符
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64)],
);
let tokens = est.estimate_message(&msg);
// 期望构成:地板 1600 + "t"(1 plain 字符 ceil 后 1) + 每消息开销 4
assert_eq!(
tokens,
IMAGE_TOKEN_FLOOR as u32 + 1 + est.per_message_overhead,
"base64 图消息应恰为地板+文本+开销,实际 {}",
tokens
);
}
#[test]
fn cjk_chars_cost_more_than_latin() {
// 中文按 1.2 token/字计:同字符数下中文估值须显著高于拉丁,预算门不再低估 2~3 倍
let est = TokenEstimator::default();
let zh = est.estimate_text("预算控制测试"); // 6 CJK 字符
let en = est.estimate_text("abcdef"); // 6 拉丁字符
assert_eq!(zh, 8, "6 CJK 字应计 8 token(6×1.2=7.2 ceil)");
assert_eq!(en, 3, "6 拉丁字符应计 3 token(6×0.35=2.1 ceil)");
assert!(zh > en * 2, "CJK 估值({zh})须显著高于同长度拉丁({en})");
}
} }
File diff suppressed because it is too large Load Diff
-640
View File
@@ -1,640 +0,0 @@
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
//!
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
//!
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
use std::path::{Path, PathBuf};
use std::process::Command;
/// worktree 管理错误
#[derive(Debug)]
pub enum WorktreeError {
/// Git 命令执行失败(stdout/stderr 含错误信息)
GitFailed(String),
/// 工程根目录无 .git(非 Git 工程,应降级串行)
NotGitRepo,
/// worktree 目录已存在(可能上次未清理)
AlreadyExists(PathBuf),
}
impl std::fmt::Display for WorktreeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
}
}
}
impl std::error::Error for WorktreeError {}
/// worktree 创建结果
#[derive(Debug, Clone)]
pub struct WorktreeHandle {
/// worktree 物理路径(SubTask 工具操作的 cwd)
pub path: PathBuf,
/// worktree 对应的分支名(merge 时用)
pub branch: String,
/// 所属 Plan id(清理时按 plan 批量)
pub plan_id: String,
/// SubTask id
pub subtask_id: String,
}
/// 在指定工程根目录下创建 SubTask 专属 worktree。
///
/// - `project_root`: 工程根目录(含 .git)
/// - `plan_id`: 所属 Plan id
/// - `subtask_id`: SubTask id
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
///
/// 返回 WorktreeHandle(path + branch)。
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
/// 分支名:`subtask/{plan_id}/{subtask_id}`
pub fn create_worktree(
project_root: &Path,
plan_id: &str,
subtask_id: &str,
base_branch: &str,
) -> Result<WorktreeHandle, WorktreeError> {
if !project_root.join(".git").exists() {
return Err(WorktreeError::NotGitRepo);
}
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
if wt_path.exists() {
return Err(WorktreeError::AlreadyExists(wt_path));
}
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
// git worktree add <path> -b <branch> <base_branch>
let output = Command::new("git")
.args([
"worktree",
"add",
wt_path.to_str().unwrap_or(""),
"-b",
&branch,
base_branch,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(format!(
"git worktree add 失败: {}",
stderr.trim()
)));
}
Ok(WorktreeHandle {
path: wt_path,
branch,
plan_id: plan_id.to_string(),
subtask_id: subtask_id.to_string(),
})
}
/// 在 worktree 内提交所有改动(git add -A + git commit)。
///
/// - `wt_path`: worktree 物理路径
/// - `message`: commit 消息
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
// git add -A
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
if !add.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git add 失败: {}",
String::from_utf8_lossy(&add.stderr).trim()
)));
}
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
let commit = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
if !commit.status.success() {
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
let stderr = String::from_utf8_lossy(&commit.stderr);
if stderr.contains("user.email") || stderr.contains("user.name") {
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
Command::new("git")
.args(["config", "--local", "user.email", "devflow@ai.local"])
.current_dir(wt_path)
.output()
.ok();
Command::new("git")
.args(["config", "--local", "user.name", "DevFlow Agent"])
.current_dir(wt_path)
.output()
.ok();
let retry = Command::new("git")
.args(["commit", "--allow-empty", "-m", message])
.current_dir(wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
if !retry.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git commit 重试仍失败: {}",
String::from_utf8_lossy(&retry.stderr).trim()
)));
}
return Ok(());
}
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
}
Ok(())
}
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
///
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
/// 返回 Err 表示 merge 冲突。
pub fn merge_branch(
plan_wt_path: &Path,
subtask_branch: &str,
) -> Result<MergeResult, WorktreeError> {
// 预检用旧版三参形态 merge-tree <共同祖先> <ours> <theirs>
// (--write-tree 需 Git 2.38+,为兼容旧 git 不采用)
// 第 1 参必须是 merge-base:此前误传 HEAD,branch2(HEAD) 相对 base 零差异,
// trivial merge 恒判无冲突(实测真冲突场景输出为空 exit 0),预检形同虚设
let mb = Command::new("git")
.args(["merge-base", "HEAD", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-base 失败: {}", e)))?;
// 旧式形态冲突时 exit 仍为 0,只能看 stdout 判定;只认 <<<<<<< 标记,
// 不查 =======:干净合并的 diff 上下文会带出文件内容,文档里的等号分隔行会误撞
if mb.status.success() {
let merge_base = String::from_utf8_lossy(&mb.stdout).trim().to_string();
let precheck = Command::new("git")
.args(["merge-tree", merge_base.as_str(), "HEAD", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
if precheck_stdout.contains("<<<<<<<") {
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: precheck_stdout.to_string(),
});
}
}
// merge-base 失败 = 两分支无共同祖先,无法预检,交由下方实际 merge 裁决
// 无冲突,执行实际 merge
let merge = Command::new("git")
.args(["merge", "--no-edit", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
if !merge.status.success() {
// merge 冲突(git merge 返回非0)
let stderr = String::from_utf8_lossy(&merge.stderr);
let stdout = String::from_utf8_lossy(&merge.stdout);
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
let _ = Command::new("git")
.args(["merge", "--abort"])
.current_dir(plan_wt_path)
.output();
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: format!("{}\n{}", stdout, stderr),
});
}
Ok(MergeResult::Clean)
}
/// merge 结果
#[derive(Debug, Clone)]
pub enum MergeResult {
/// 干净合并(无冲突)
Clean,
/// 合并冲突(details 含 conflict 信息)
Conflict { branch: String, details: String },
}
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
// git worktree remove --force <path>
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
handle.path.to_str().unwrap_or(""),
])
.current_dir(project_root)
.output();
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
let _ = Command::new("git")
.args(["branch", "-D", &handle.branch])
.current_dir(project_root)
.output();
Ok(())
}
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
if !plan_wt_dir.exists() {
return Ok(());
}
// git worktree list 获取该 plan 下所有 worktree
let list = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
let list_str = String::from_utf8_lossy(&list.stdout);
for line in list_str.lines() {
if let Some(path_str) = line.strip_prefix("worktree ") {
let path = PathBuf::from(path_str);
// 仅清理属于本 plan 的 worktree
if path.starts_with(&plan_wt_dir) {
let _ = Command::new("git")
.args(["worktree", "remove", "--force", path_str])
.current_dir(project_root)
.output();
}
}
}
// 删除该 plan 的所有子任务分支
let branches = Command::new("git")
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
let branches_str = String::from_utf8_lossy(&branches.stdout);
for line in branches_str.lines() {
let branch = line.trim();
if !branch.is_empty() {
let _ = Command::new("git")
.args(["branch", "-D", branch])
.current_dir(project_root)
.output();
}
}
// 清理空目录
let _ = std::fs::remove_dir_all(&plan_wt_dir);
Ok(())
}
/// 检测工程是否为 Git 仓库(有 .git 目录)。
pub fn is_git_repo(project_root: &Path) -> bool {
project_root.join(".git").exists()
}
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
///
/// 返回 (plan_worktree_path, plan_branch)。
pub fn create_plan_worktree(
project_root: &Path,
plan_id: &str,
) -> Result<(PathBuf, String), WorktreeError> {
if !is_git_repo(project_root) {
return Err(WorktreeError::NotGitRepo);
}
let base = current_branch(project_root)?;
let plan_branch = format!("plan/{}", plan_id);
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
if plan_wt_path.exists() {
return Err(WorktreeError::AlreadyExists(plan_wt_path));
}
let output = Command::new("git")
.args([
"worktree",
"add",
plan_wt_path.to_str().unwrap_or(""),
"-b",
&plan_branch,
&base,
])
.current_dir(project_root)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(format!(
"git worktree add(plan) 失败: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok((plan_wt_path, plan_branch))
}
#[cfg(test)]
mod tests {
use super::*;
/// 创建临时 Git 仓库 + 初始 commit
fn setup_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
dir
}
#[test]
fn wt_01_create_and_isolate() {
let repo = setup_repo();
let root = repo.path();
let base = current_branch(root).unwrap();
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
assert!(handle.path.exists(), "worktree 目录应存在");
assert_eq!(handle.branch, "subtask/plan-test/A");
// 在 worktree 内写文件
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
// 主目录不应有此文件
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
// 清理
remove_worktree(root, &handle).unwrap();
}
#[test]
fn wt_02_parallel_isolation() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
let plan_branch = plan_wt.1;
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
// A 写 new_a.rs
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
// B 写 new_b.rs
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
// A 的 worktree 不应有 B 的文件
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
// B 的 worktree 不应有 A 的文件
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
remove_plan_worktrees(root, "plan-iso").unwrap();
}
#[test]
fn wt_03_commit_in_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
// 写文件 + commit
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
commit_worktree(&a.path, "Add feature").unwrap();
// 验证分支有新 commit
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(&a.path)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
remove_plan_worktrees(root, "plan-commit").unwrap();
}
#[test]
fn wt_04_merge_no_conflict() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
commit_worktree(&a.path, "A changes").unwrap();
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
commit_worktree(&b.path, "B changes").unwrap();
// merge A → plan
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
// merge B → plan
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
// plan worktree 应同时有 file_a.rs 和 file_b.rs
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
remove_plan_worktrees(root, "plan-merge").unwrap();
}
#[test]
fn wt_05_merge_conflict_same_file() {
let repo = setup_repo();
let root = repo.path();
// 写一个共享文件
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 和 B 改同一文件的同一行
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
commit_worktree(&a.path, "A change shared").unwrap();
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
commit_worktree(&b.path, "B change shared").unwrap();
// merge A → 无冲突
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
// merge B → 冲突
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
assert!(
matches!(result_b, MergeResult::Conflict { .. }),
"同文件同行改应产生冲突"
);
remove_plan_worktrees(root, "plan-conf").unwrap();
}
#[test]
fn wt_06_non_git_returns_error() {
let dir = tempfile::tempdir().unwrap();
// 不 init git
let result = create_plan_worktree(dir.path(), "plan-ng");
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
}
#[test]
fn wt_07_cleanup_removes_worktree() {
let repo = setup_repo();
let root = repo.path();
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
assert!(a.path.exists());
remove_plan_worktrees(root, "plan-clean").unwrap();
// worktree 目录应被清理
assert!(!a.path.exists(), "worktree 目录应已清理");
// plan wt 目录也应被清理
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
}
#[test]
fn wt_08_precheck_hits_conflict_before_real_merge() {
let repo = setup_repo();
let root = repo.path();
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-prechk").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 和 B 改同一文件同一行
let a = create_worktree(root, "plan-prechk", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
commit_worktree(&a.path, "A change shared").unwrap();
let b = create_worktree(root, "plan-prechk", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
commit_worktree(&b.path, "B change shared").unwrap();
assert!(matches!(
merge_branch(&plan_wt_path, &a.branch).unwrap(),
MergeResult::Clean
));
// 冲突必须由 merge-tree 预检命中(details 含标记),而非实际 merge 失败兜底
// (兜底 details 来自 merge stderr,只有 CONFLICT 文案无标记)
match merge_branch(&plan_wt_path, &b.branch).unwrap() {
MergeResult::Conflict { details, .. } => {
assert!(
details.contains("<<<<<<<"),
"冲突详情应含 merge-tree 预检输出的标记,而非 merge 兜底文案: {}",
details
);
}
_ => panic!("同文件同行改应冲突"),
}
// 预检命中即返回,不应留下合并中状态
let merging = Command::new("git")
.args(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
.current_dir(&plan_wt_path)
.output()
.unwrap();
assert!(!merging.status.success(), "预检命中不应留下 MERGE_HEAD");
remove_plan_worktrees(root, "plan-prechk").unwrap();
}
#[test]
fn wt_09_clean_merge_with_eq_marker_content() {
let repo = setup_repo();
let root = repo.path();
// 文件内容含等号分隔行(md 标题下划线常见),A/B 隔行修改,实际可干净合并
std::fs::write(root.join("doc.md"), "t0\n=======\nl3\nl4\nl5\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add doc"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-eqmark").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
let a = create_worktree(root, "plan-eqmark", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("doc.md"), "A0\n=======\nl3\nl4\nl5\n").unwrap();
commit_worktree(&a.path, "A chg line1").unwrap();
let b = create_worktree(root, "plan-eqmark", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("doc.md"), "t0\n=======\nl3\nl4\nB5\n").unwrap();
commit_worktree(&b.path, "B chg line5").unwrap();
assert!(matches!(
merge_branch(&plan_wt_path, &a.branch).unwrap(),
MergeResult::Clean
));
// B 隔行修改应 Clean:预检不得因 diff 上下文中的等号行误判冲突
assert!(matches!(
merge_branch(&plan_wt_path, &b.branch).unwrap(),
MergeResult::Clean
));
remove_plan_worktrees(root, "plan-eqmark").unwrap();
}
}
-122
View File
@@ -1,122 +0,0 @@
//! 本地图片引用展开(#958 图片入站引用化)
//!
//! 消息持久化只存引用(ContentPart::Image.url = `file://<绝对路径>`,base64 空),
//! 发送请求前在此统一展开为 base64 内嵌——存储面瘦 referencing(一张 5MB 图不再以
//! 6.7MB base64 字符串进 SQLite 随历史全量重放),转换面按需读盘(SSD 毫秒级,
//! 对比 base64 内嵌的每请求全量携带,磁盘读便宜得多)。
//!
//! 展开点是 provider 转换层的入口:主会话/子代理/标题/压缩等全部请求路径在此
//! 单点覆盖,调用方(飞书入站/桌面粘贴图)只管落盘产引用,不关心 provider 差异。
//!
//! 失败语义:引用文件丢失/不可读时降级为 Text 片「[图片文件缺失: path]」——
//! 诚实告知模型图不可用,不让请求 400(空 base64 会被两个协议端点拒绝)。
use crate::provider::ContentPart;
use base64::Engine as _;
/// `file://` 引用前缀。内部引用标识(两端自洽即可,不进端点):落盘方拼
/// `file://<原生绝对路径>`,展开方 strip 后按原生路径读(Windows 盘符路径直接可用)。
pub const LOCAL_FILE_SCHEME: &str = "file://";
/// 判定 Image 片是否为本地引用(base64 空 + url 带本地 scheme)
fn is_local_ref(url: Option<&str>, base64: Option<&str>) -> bool {
base64.is_none() && url.is_some_and(|u| u.starts_with(LOCAL_FILE_SCHEME))
}
/// 把消息内的本地图片引用展开为 base64 内嵌片;非本地片原样透传。
/// 文件缺失/不可读时该 Image 片降级为 Text 片(请求不炸,模型得到可读的缺失说明)。
pub fn inline_local_image_parts(parts: Vec<ContentPart>) -> Vec<ContentPart> {
parts.into_iter().map(inline_local_image_part).collect()
}
/// 单片版(provider 转换臂逐片 map 用)
pub fn inline_local_image_part(p: ContentPart) -> ContentPart {
match p {
ContentPart::Image { url, base64, media_type, alt }
if is_local_ref(url.as_deref(), base64.as_deref()) =>
{
let path = url.unwrap();
match std::fs::read(&path[LOCAL_FILE_SCHEME.len()..]) {
Ok(bytes) => ContentPart::Image {
url: None,
base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
media_type,
alt,
},
Err(_) => ContentPart::Text {
text: format!("[图片文件缺失: {}]", &path[LOCAL_FILE_SCHEME.len()..]),
},
}
}
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_ref_expands_to_base64() {
let dir = std::env::temp_dir().join("df-ai-image-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("expands.png");
std::fs::write(&path, b"\x89PNG fake-bytes").unwrap();
let out = inline_local_image_parts(vec![ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}]);
match &out[0] {
ContentPart::Image { url, base64, media_type, .. } => {
assert!(url.is_none());
let b = base64.as_deref().expect("base64 应已填充");
assert_eq!(b, base64::engine::general_purpose::STANDARD.encode(b"\x89PNG fake-bytes"));
assert_eq!(media_type.as_deref(), Some("image/png"));
}
other => panic!("应保持 Image 片, 实际 {other:?}"),
}
std::fs::remove_file(&path).ok();
}
#[test]
fn missing_file_degrades_to_text_not_empty_base64() {
let out = inline_local_image_parts(vec![ContentPart::Image {
url: Some("file:///nonexistent/df-ai-no-such.png".into()),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}]);
match &out[0] {
ContentPart::Text { text } => {
assert!(text.contains("[图片文件缺失"), "应降级为缺失说明, 实际 {text}");
assert!(text.contains("df-ai-no-such.png"));
}
other => panic!("缺失文件应降级 Text 片, 实际 {other:?}"),
}
}
#[test]
fn non_local_parts_pass_through() {
let passthrough = vec![
ContentPart::text("hello"),
ContentPart::image_base64("image/png", "aGVsbG8="),
ContentPart::image_url("https://x/a.png"),
// base64 非空的 file:// 不是引用(优先内嵌),原样透传
ContentPart::Image {
url: Some("file:///x.png".into()),
base64: Some("aGVsbG8=".into()),
media_type: Some("image/png".into()),
alt: None,
},
];
let out = inline_local_image_parts(passthrough);
assert_eq!(out.len(), 4);
assert!(matches!(&out[0], ContentPart::Text { .. }));
assert!(matches!(&out[1], ContentPart::Image { base64: Some(_), .. }));
assert!(matches!(&out[2], ContentPart::Image { url: Some(u), .. } if u.starts_with("https://")));
assert!(matches!(&out[3], ContentPart::Image { base64: Some(_), .. }));
}
}
+78 -262
View File
@@ -8,9 +8,8 @@
//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本) //! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本)
//! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。 //! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。
//! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。 //! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。
//! 3. `suggested_model_tier(intent)` —— 模型模态档位建议(意图 → `ModelTier` 映射): //! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None`
//! Code/Debug/Http → Heavy,File/Search/Task/Idea/Project/Conversation → Standard, //! (待模型模态管理 Phase 落地后补充实际逻辑)。
//! Chat → Fast,Unknown → None(不强加偏好)。router 同 weight tiebreak + 模型路由用。
//! //!
//! ## 设计原则 //! ## 设计原则
//! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。 //! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。
@@ -71,12 +70,12 @@ impl Intent {
} }
} }
// ---- ModelTier 档位 --------------------------------------------------------- // ---- ModelTier 预留 ---------------------------------------------------------
/// 模型模态档位。 /// 模型模态档位(**预留**)。
/// ///
/// `suggested_model_tier` 据 Intent 映射到此档位,供 router 同 weight tiebreak /// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。
/// (重档位优先)或后续 provider/model 路由(待模型模态管理 Phase 接入)使用。 /// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelTier { pub enum ModelTier {
/// 轻量快速(简单意图/闲聊) /// 轻量快速(简单意图/闲聊)
@@ -120,9 +119,8 @@ const DEBUG_RULES: &[Rule] = &[
Rule { kw: "栈", weight: 0.5 }, Rule { kw: "栈", weight: 0.5 },
Rule { kw: "panic", weight: 0.9 }, Rule { kw: "panic", weight: 0.9 },
Rule { kw: "traceback", weight: 0.9 }, Rule { kw: "traceback", weight: 0.9 },
// 命令执行场景关键词:用户明确"运行/执行/构建/测试/跑"时归 Debug 意图(影响档位路由与场景增量)。 // 命令执行场景关键词(run_command 入口引导):用户明确"运行/执行/构建/测试/跑"
// run_command 已入基本盘全工作意图可见,这里不再承担工具暴露职责。 // 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
// 中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
Rule { kw: "运行", weight: 0.7 }, Rule { kw: "运行", weight: 0.7 },
Rule { kw: "执行", weight: 0.7 }, Rule { kw: "执行", weight: 0.7 },
Rule { kw: "构建", weight: 0.7 }, Rule { kw: "构建", weight: 0.7 },
@@ -232,22 +230,12 @@ const GENERIC_GROUP: &[IntentGroup] = &[
/// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。 /// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolDomain { pub enum ToolDomain {
/// 数据/业务:项目/任务/灵感/工作流/回收站(全量,含写工具) /// 数据/业务:项目/任务/灵感/工作流/回收站
Data, Data,
/// 数据只读子集:list_*/get_*_count(不含 create/update/delete/advance/run_workflow/restore/purge/bind_directory) /// 文件:读写/patch/列目录/搜索(不含命令执行)
///
/// 用途:`Intent::Code` 的场景增量:保留编码场景用户常需要的"看一下项目/任务结构"只读
/// 探索工具(list_projects/list_tasks/list_ideas/list_trash),不放大写工具暴露面
/// (写工具属 Data 域,留给 Project/Task/Idea 的场景增量)。
DataReadOnly,
/// 文件:读写/patch/列目录/搜索。基本盘成员,所有工作意图可见
File, File,
/// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力 /// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出)
/// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底
Exec, Exec,
/// 编排:spawn_agent 子代理派发。基本盘成员:委派是编排类基本能力,
/// 高置信意图收敛下不登记则工具对 LLM 不可见,安全由子代理侧白名单钳制兜底
Agent,
/// 网络:HTTP /// 网络:HTTP
Http, Http,
} }
@@ -287,27 +275,11 @@ impl ToolDomain {
"delete_file", "delete_file",
"rename_file", "rename_file",
], ],
// Exec domain 独立于 File:run_command 单列,供基本盘/场景增量按意图组合。 // Exec domain 独立:run_command 从 File 移出。
// 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露; // 意图侧仅 Debug 默认带 Exec(用户明确"运行/测试/构建"),
// 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。 // Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。
ToolDomain::Exec => &["run_command"], ToolDomain::Exec => &["run_command"],
// Agent domain:spawn_agent 单列,编排委派基本盘成员(子代理侧白名单钳制兜底安全)。
ToolDomain::Agent => &["spawn_agent"],
ToolDomain::Http => &["http_request"], ToolDomain::Http => &["http_request"],
// DataReadOnly:Data domain 的只读子集。
// 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
// "create_task 记一下"时,意图识别可能命中 Code(SPECIFIC > ENTITY),原 Code subset
// 不含 Data domain → list_projects/create_task 对 LLM 不可见,agent 被迫反复 read_file。
// 加 Data 全 domain 会暴露 create/update/delete 等写工具(放大暴露面),
// 故取只读子集:list_*/get_*_count(纯查询,无副作用)。
ToolDomain::DataReadOnly => &[
"list_projects",
"list_tasks",
"list_ideas",
"list_trash",
"get_project_count",
"get_task_count",
],
} }
} }
} }
@@ -420,7 +392,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
for rule in cand.rules.iter() { for rule in cand.rules.iter() {
// 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。 // 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。
let needle = rule.kw.to_lowercase(); let needle = rule.kw.to_lowercase();
if kw_matches(&lower, &needle) { if lower.contains(&needle) {
score += rule.weight; score += rule.weight;
matched = true; matched = true;
} }
@@ -437,62 +409,37 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
best best
} }
/// 关键词命中判定:ASCII 关键词要求两侧词边界(词字符 = ASCII 字母数字 + 下划线,与正则
/// `\b` 语义一致),防子串误判——"history" 不命中 "hi"(误判 Chat)、"rapid" 不命中
/// "api"(误判 Http)。非 ASCII(中文)关键词保持子串语义:中文无词边界概念,直接 contains。
fn kw_matches(haystack: &str, needle: &str) -> bool {
if !needle.is_ascii() {
return haystack.contains(needle);
}
let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
haystack.match_indices(needle).any(|(pos, m)| {
haystack[..pos]
.chars()
.next_back()
.map_or(true, |c| !is_word_char(c))
&& haystack[pos + m.len()..]
.chars()
.next()
.map_or(true, |c| !is_word_char(c))
})
}
// ---- Intent → 工具子集映射 -------------------------------------------------- // ---- Intent → 工具子集映射 --------------------------------------------------
/// 按 Intent 返回工具名子集。 /// 按 Intent 返回工具名子集。
/// ///
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat/Conversation)或**未识别**(Unknown), /// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
/// ///
/// 设计(2026-09-30 起):基本盘 + 场景增量。 /// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1);
/// 基本盘 = File + Exec + Agent:读、写、执行命令、委派子代理是 agent 基本能力, /// Http → [http];Search → [file](含 search_files);Conversation → [];
/// 不受意图收敛限制(用户拍板), /// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
/// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。 /// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
/// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全; /// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;Unknown → [](全量)。
/// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致
/// "查看服务器状态"被回"未挂载",均属此类,本次根治)。
/// 场景增量 = 业务域收敛,减少 LLM 分心:Code 加 Http+DataReadOnly;Debug 加 Http+Data
/// (调试常需查 API+查任务/工作流状态,CR-25 加 data 防"调试任务"丢 Data 工具);
/// Project/Task/Idea 加 Data;File/Search 仅基本盘;Http 保留自身 Http 域
/// (发请求是该意图本名能力,砍掉即复现断手)。
/// 各意图新子集均为旧子集超集,只增不减。
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
// 基本盘:读写执行 + 编排委派(RiskLevel/审批/路径授权/子代理白名单钳制兜底,藏工具≠安全); let domains: &[ToolDomain] = match intent {
// 场景增量=业务域收敛减少分心 Intent::Code => &[ToolDomain::File, ToolDomain::Http],
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Agent]; Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
let extra: &[ToolDomain] = match intent { Intent::File => &[ToolDomain::File],
Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly], // Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
Intent::Debug => &[ToolDomain::Http, ToolDomain::Data], // (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
// 提项目/任务/想法常伴编码排查,业务域全量保留 // 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
// (写工具有 RiskLevel/审批兜底,"分心"远好于"断手")。 Intent::Project => &[ToolDomain::Data, ToolDomain::File],
Intent::Project | Intent::Task | Intent::Idea => &[ToolDomain::Data], Intent::Task => &[ToolDomain::Data, ToolDomain::File],
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
Intent::Conversation => &[],
Intent::Search => &[ToolDomain::File],
Intent::Http => &[ToolDomain::Http], Intent::Http => &[ToolDomain::Http],
Intent::File | Intent::Search => &[], Intent::Chat => &[],
// 空子集语义 = 上游全量 fallback,不受基本盘改造影响,行为不变 Intent::Unknown => &[],
Intent::Conversation | Intent::Chat | Intent::Unknown => return Vec::new(),
}; };
let mut out: Vec<&'static str> = Vec::new(); let mut out: Vec<&'static str> = Vec::new();
for d in BASE_DOMAINS.iter().chain(extra.iter()) { for d in domains {
for name in d.tools() { for name in d.tools() {
if !out.contains(name) { if !out.contains(name) {
out.push(name); out.push(name);
@@ -502,36 +449,19 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
out out
} }
// ---- 模态建议(意图 → 模型 tier 映射) ---------------------------------------- // ---- 模态建议(接口预留) ---------------------------------------------------
/// 按 Intent 建议模型模态档位(意图 → 模型 tier 的语义映射)。 /// 按 Intent 建议模型模态档位。
/// ///
/// **映射分组**(对齐 router 同 weight tiebreak 缺语义的根因修复): /// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充:
/// - `Code`/`Debug` → `Heavy`(复杂推理/重构/排查,需重模型) /// - Chat/Conversation → `Fast`
/// - `Http` → `Heavy`(多跳外部调用,链长易错,需重模型把关) /// - Code/File/Task/Idea/Search → `Standard`
/// - `File`/`Search`/`Task`/`Idea`/`Project`/`Conversation` → `Standard`(默认复杂度) /// - Debug/Http(复杂排查/多跳调用)→ `Heavy`
/// - `Chat` → `Fast`(简单闲聊,轻量即可)
/// - `Unknown` → `None`(未识别,fallback 全量工具时不强加 tier 偏好,
/// 上游走默认档位)
/// ///
/// 返回 `Some(ModelTier)` 时上游可作为同 weight 候选间的 tiebreak 依据 /// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。
/// (重档位优先),或据此路由到不同 provider/model(待模型模态管理 Phase 接入)。 pub fn suggested_model_tier(_intent: &Intent) -> Option<ModelTier> {
pub fn suggested_model_tier(intent: &Intent) -> Option<ModelTier> { // TODO(model-tier-phase): 待模型模态管理落地后填实映射。
match intent { None
// 重型:复杂推理/重构/排查/多跳调用
Intent::Code | Intent::Debug | Intent::Http => Some(ModelTier::Heavy),
// 标准:默认复杂度
Intent::File
| Intent::Search
| Intent::Task
| Intent::Idea
| Intent::Project
| Intent::Conversation => Some(ModelTier::Standard),
// 轻量:简单闲聊
Intent::Chat => Some(ModelTier::Fast),
// 未识别:不强加 tier 偏好,fallback 上游默认
Intent::Unknown => None,
}
} }
// ---- 工具子集过滤(agentic loop 接入用,改进2 A) ----------------------------- // ---- 工具子集过滤(agentic loop 接入用,改进2 A) -----------------------------
@@ -874,38 +804,6 @@ mod tests {
assert_eq!(i, Intent::Http); assert_eq!(i, Intent::Http);
} }
// --- ASCII 关键词词边界(防子串误判) ---
#[test]
fn word_boundary_history_not_chat() {
// "history" 内嵌 "hi" 不再判 Chat(无其他关键词 → Unknown)
let (i, c) = IntentRecognizer::recognize("show me the history");
assert_eq!(i, Intent::Unknown, "history 不应命中 hi, 实际 conf={}", c);
}
#[test]
fn word_boundary_rapid_not_http() {
// "rapid" 内嵌 "api" 不再判 Http
let (i, c) = IntentRecognizer::recognize("rapid growth");
assert_eq!(i, Intent::Unknown, "rapid 不应命中 api, 实际 conf={}", c);
}
#[test]
fn standalone_english_keywords_still_hit() {
// 词边界不误伤独立词命中
let (i, _) = IntentRecognizer::recognize("call the api");
assert_eq!(i, Intent::Http);
let (i, _) = IntentRecognizer::recognize("hi");
assert_eq!(i, Intent::Chat);
}
#[test]
fn cjk_adjacent_ascii_keyword_still_hits() {
// 中文紧邻 ASCII 关键词:非 ASCII 字符算边界,不破坏中文场景命中
let (i, _) = IntentRecognizer::recognize("调用这个api");
assert_eq!(i, Intent::Http);
}
// --- tool_subset_for --- // --- tool_subset_for ---
#[test] #[test]
@@ -915,64 +813,14 @@ mod tests {
assert!(s.contains(&"write_file")); assert!(s.contains(&"write_file"));
assert!(s.contains(&"patch_file")); assert!(s.contains(&"patch_file"));
assert!(s.contains(&"http_request")); assert!(s.contains(&"http_request"));
// Code 现含 DataReadOnly → list_projects 等只读工具保留(不再断言"不含") assert!(!s.contains(&"list_projects"));
assert!(s.contains(&"list_projects"));
} }
#[test] #[test]
fn subset_code_keeps_data_readonly_tools() { fn subset_file_only_file_domain() {
// 根因修复(Code 命中砍 Data domain 致 list_*/get_*_count 不可见):
// Code subset 应含 DataReadOnly 全部 6 个只读工具,让"先 list_projects 看下结构"
// "list_tasks 记一下"这类编码场景的口语不被 Code 意图砍工具。
let s = tool_subset_for(&Intent::Code);
for read_only in [
"list_projects",
"list_tasks",
"list_ideas",
"list_trash",
"get_project_count",
"get_task_count",
] {
assert!(
s.contains(&read_only),
"Code subset 应含只读工具 {}(DataReadOnly domain)",
read_only
);
}
}
#[test]
fn subset_code_no_data_write_tools() {
// 防回归:Code subset 只加 Data 的"只读子集",不得暴露写工具。
// create_task 不应在 Code subset(只读 list_* 在,写 create_* 不在)。
// create_project/update_project/delete_project/advance_task/run_workflow 同理。
let s = tool_subset_for(&Intent::Code);
for write_tool in [
"create_task",
"update_task",
"delete_task",
"create_project",
"update_project",
"delete_project",
"advance_task",
"run_workflow",
] {
assert!(
!s.contains(&write_tool),
"Code subset 不应含写工具 {}(只读子集,防放大暴露面), 实际 subset: {:?}",
write_tool,
s
);
}
}
#[test]
fn subset_file_is_base_without_business_domains() {
// File 仅基本盘:读写+命令执行可见,Http/Data 业务域不暴露
let s = tool_subset_for(&Intent::File); let s = tool_subset_for(&Intent::File);
assert!(s.contains(&"read_file")); assert!(s.contains(&"read_file"));
assert!(s.contains(&"search_files")); assert!(s.contains(&"search_files"));
assert!(s.contains(&"run_command"), "File 意图应含 run_command(基本盘)");
assert!(!s.contains(&"http_request")); assert!(!s.contains(&"http_request"));
assert!(!s.contains(&"list_projects")); assert!(!s.contains(&"list_projects"));
} }
@@ -1002,13 +850,9 @@ mod tests {
} }
#[test] #[test]
fn subset_http_keeps_http_plus_base() { fn subset_http_only_http() {
// Http 意图:基本盘(读写执行)+ 自身 Http 域,较旧版(仅 http_request)只增不减
let s = tool_subset_for(&Intent::Http); let s = tool_subset_for(&Intent::Http);
assert!(s.contains(&"http_request")); assert_eq!(s, vec!["http_request"]);
assert!(s.contains(&"read_file"));
assert!(s.contains(&"run_command"), "Http 意图应含 run_command(基本盘)");
assert!(!s.contains(&"list_projects"), "Http 意图不加 Data 场景增量");
} }
#[test] #[test]
@@ -1054,7 +898,7 @@ mod tests {
fn tool_domain_names_match_registry() { fn tool_domain_names_match_registry() {
// 抽样核对与 src-tauri tool_registry 实际注册名一致 // 抽样核对与 src-tauri tool_registry 实际注册名一致
assert!(ToolDomain::File.tools().contains(&"patch_file")); assert!(ToolDomain::File.tools().contains(&"patch_file"));
// run_command 归 Exec domain(域归属未变;子集组合已改基本盘,全工作意图可见) // run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command)
assert!(!ToolDomain::File.tools().contains(&"run_command")); assert!(!ToolDomain::File.tools().contains(&"run_command"));
assert_eq!(ToolDomain::Exec.tools(), &["run_command"]); assert_eq!(ToolDomain::Exec.tools(), &["run_command"]);
assert!(ToolDomain::Data.tools().contains(&"advance_task")); assert!(ToolDomain::Data.tools().contains(&"advance_task"));
@@ -1063,39 +907,26 @@ mod tests {
assert_eq!(ToolDomain::Http.tools(), &["http_request"]); assert_eq!(ToolDomain::Http.tools(), &["http_request"]);
} }
// --- suggested_model_tier 意图 → 档位映射 --- // --- suggested_model_tier 预留 ---
#[test] #[test]
fn model_tier_mapping_now_returns_actual() { fn model_tier_always_none_for_now() {
// Code/Debug/Http → Heavy(复杂推理/重构/排查/多跳调用) // 接口预留:当前所有意图均返 None
for i in [Intent::Code, Intent::Debug, Intent::Http] {
assert_eq!(
suggested_model_tier(&i),
Some(ModelTier::Heavy),
"intent {:?} 应映射 Heavy",
i
);
}
// File/Search/Task/Idea/Project/Conversation → Standard(默认复杂度)
for i in [ for i in [
Intent::Code,
Intent::Debug,
Intent::File, Intent::File,
Intent::Search, Intent::Project,
Intent::Task, Intent::Task,
Intent::Idea, Intent::Idea,
Intent::Project,
Intent::Conversation, Intent::Conversation,
Intent::Search,
Intent::Http,
Intent::Chat,
Intent::Unknown,
] { ] {
assert_eq!( assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i);
suggested_model_tier(&i),
Some(ModelTier::Standard),
"intent {:?} 应映射 Standard",
i
);
} }
// Chat → Fast(简单闲聊)
assert_eq!(suggested_model_tier(&Intent::Chat), Some(ModelTier::Fast));
// Unknown → None(未识别,不强加 tier 偏好,fallback 上游默认)
assert_eq!(suggested_model_tier(&Intent::Unknown), None);
} }
// --- IntentRecognizer Default --- // --- IntentRecognizer Default ---
@@ -1113,7 +944,7 @@ mod tests {
/// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。 /// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition { fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
df_ai_core::types::ToolDefinition { df_ai_core::types::ToolDefinition {
tool_type: "function".into(), tool_type: "function".to_string(),
function: df_ai_core::types::ToolFunction { function: df_ai_core::types::ToolFunction {
name: name.to_string(), name: name.to_string(),
description: String::new(), description: String::new(),
@@ -1147,7 +978,7 @@ mod tests {
#[test] #[test]
fn filter_subset_hit_filters_to_matching() { fn filter_subset_hit_filters_to_matching() {
// File 意图 subset = 基本盘(File+Exec),不含 Http 域;http_request 应被滤掉 // File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉
let all = vec![ let all = vec![
tool_def("read_file"), tool_def("read_file"),
tool_def("write_file"), tool_def("write_file"),
@@ -1166,7 +997,7 @@ mod tests {
#[test] #[test]
fn filter_subset_drift_skips_missing_names() { fn filter_subset_drift_skips_missing_names() {
// subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic // subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic
// Project 意图 subset = Data+基本盘,create_project 命中;http_request 不在 subset,滤掉 // Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具
let all = vec![ let all = vec![
tool_def("create_project"), // 命中 tool_def("create_project"), // 命中
tool_def("http_request"), // 不在 Data domain,滤掉 tool_def("http_request"), // 不在 Data domain,滤掉
@@ -1264,32 +1095,21 @@ mod tests {
} }
#[test] #[test]
fn run_command_exposed_for_all_work_intents() { fn run_command_only_exposed_for_debug_intent() {
// 2026-09-30 用户拍板:读写执行是 agent 基本能力,不受意图收敛限制。 // 核心修复断言:run_command 仅 Debug 暴露。
// 所有非空子集意图都经基本盘含 run_command; // Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。
// 安全由执行层兜底(validate_path/授权/RiskLevel),藏工具≠安全。 for intent in [Intent::Code, Intent::File, Intent::Search] {
for intent in [
Intent::Code,
Intent::Debug,
Intent::File,
Intent::Project,
Intent::Task,
Intent::Idea,
Intent::Search,
Intent::Http,
] {
let s = tool_subset_for(&intent); let s = tool_subset_for(&intent);
assert!( assert!(
s.contains(&"run_command"), !s.contains(&"run_command"),
"Intent {:?} subset 应含 run_command(基本盘), 实际: {:?}", "Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}",
intent, intent,
s s
); );
} }
// 空子集意图不受影响(上游语义=全量,天然含全部工具) // Debug 必含 run_command(Exec domain)
assert!(tool_subset_for(&Intent::Chat).is_empty()); let dbg = tool_subset_for(&Intent::Debug);
assert!(tool_subset_for(&Intent::Unknown).is_empty()); assert!(dbg.contains(&"run_command"), "Debug 必含 run_command");
assert!(tool_subset_for(&Intent::Conversation).is_empty());
} }
#[test] #[test]
@@ -1325,18 +1145,13 @@ mod tests {
]; ];
for intent in all_intents { for intent in all_intents {
let subset = tool_subset_for(&intent); let subset = tool_subset_for(&intent);
// 全 registry 工具名(六 domain 并集:Data + DataReadOnly + File + Exec + Http + Agent) // 全 registry 工具名(四 domain 并集:Data + File + Exec + Http)
// 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
// 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
// Agent(spawn_agent)是编排委派基本盘成员,子集合法出现,漏 chain 会误报。
let registry: std::collections::HashSet<&str> = ToolDomain::Data let registry: std::collections::HashSet<&str> = ToolDomain::Data
.tools() .tools()
.iter() .iter()
.chain(ToolDomain::DataReadOnly.tools().iter())
.chain(ToolDomain::File.tools().iter()) .chain(ToolDomain::File.tools().iter())
.chain(ToolDomain::Exec.tools().iter()) .chain(ToolDomain::Exec.tools().iter())
.chain(ToolDomain::Http.tools().iter()) .chain(ToolDomain::Http.tools().iter())
.chain(ToolDomain::Agent.tools().iter())
.copied() .copied()
.collect(); .collect();
for name in &subset { for name in &subset {
@@ -1367,7 +1182,8 @@ mod tests {
#[test] #[test]
fn subset_code_and_debug_dedup_http_across_domains() { fn subset_code_and_debug_dedup_http_across_domains() {
// Code/Debug 均含 Http 场景增量,叠加基本盘后 subset 内工具名必须去重 // 边界:Code = [File, Http], Debug = [File, Http, Data]
// File + Http 都含 http_request,必须去重(subset 内不重复)
for intent in [Intent::Code, Intent::Debug] { for intent in [Intent::Code, Intent::Debug] {
let s = tool_subset_for(&intent); let s = tool_subset_for(&intent);
let http_count = s.iter().filter(|n| **n == "http_request").count(); let http_count = s.iter().filter(|n| **n == "http_request").count();
@@ -1424,7 +1240,7 @@ mod tests {
tool_def("append_file"), tool_def("append_file"),
tool_def("delete_file"), tool_def("delete_file"),
tool_def("rename_file"), tool_def("rename_file"),
tool_def("http_request"), // 不在 File 意图子集,应被滤掉 tool_def("http_request"), // 不在 File domain,应被滤掉
]; ];
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它"); let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它");
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
+7 -100
View File
@@ -1,23 +1,14 @@
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册 //! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
pub mod ai_tools; pub mod ai_tools;
// 声明式工具注册宏 `declare_tool!`(tool_registry 拆分第一步基础设施)。
// macro_rules + #[macro_export]:零新依赖,展开为等价 `register` 调用,与现有 48 工具并存。
// 试点:list_projects 已迁至声明式(commands/ai/tools/list_projects.rs),验证可行+零回归。
#[macro_use]
pub mod ai_tools_decl;
pub mod anthropic_compat; pub mod anthropic_compat;
pub mod anthropic_helpers; pub mod anthropic_helpers;
pub mod context; pub mod context;
pub mod context_helpers; pub mod context_helpers;
pub mod memory_adapter;
pub(crate) mod net_probe;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator; pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/ // 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。 // 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。 // 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
pub mod git_worktree;
pub mod intent; pub mod intent;
pub mod model_fetch; pub mod model_fetch;
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化), // model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
@@ -33,28 +24,16 @@ mod model_probe_helpers;
pub mod plan_hint; pub mod plan_hint;
pub mod openai_compat; pub mod openai_compat;
pub mod openai_helpers; pub mod openai_helpers;
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
pub mod image_ref;
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。 // Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。 // 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。 // Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
pub mod planner; pub mod planner;
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
pub mod persona;
pub mod plan_executor;
pub mod provider; pub mod provider;
pub mod router; pub mod router;
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用, // CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类) // stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。 // 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry; pub mod retry;
pub mod sse_parser;
pub mod namespace_store;
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
pub mod tool_offload;
use provider::LlmProvider; use provider::LlmProvider;
use reqwest::Client; use reqwest::Client;
@@ -62,7 +41,7 @@ use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。 // df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core; pub use df_ai_core;
/// 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。 /// SW-260618-10: 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
/// ///
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的 /// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层 /// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
@@ -70,11 +49,6 @@ pub use df_ai_core;
pub(crate) fn build_provider_client() -> Client { pub(crate) fn build_provider_client() -> Client {
Client::builder() Client::builder()
.connect_timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(30))
// 连接池收敛:idle 连接 15s 弃用 + 每主机最多存 2 条。reqwest 默认 idle 90s 且
// 无上限,服务端/前置 keepalive 更短时复用陈旧连接首包即被 RST(ConnectionReset
// 10054),呈现「隔几十秒一发就失败」的间歇形态;15s 保守低于常见网关 60s keepalive。
.pool_idle_timeout(std::time::Duration::from_secs(15))
.pool_max_idle_per_host(2)
.build() .build()
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e); tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
@@ -82,68 +56,12 @@ pub(crate) fn build_provider_client() -> Client {
}) })
} }
/// 流式 send 阶段 reqwest 错误的谓词快照。
///
/// reqwest::Error 无公开构造器,抽谓词结构体过纯函数,单测才能锁定分类
/// (send 阶段 ConnectionReset → 可重试类词)。
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SendErrPreds {
pub is_timeout: bool,
pub is_connect: bool,
pub is_decode: bool,
pub is_body: bool,
pub is_request: bool,
pub is_builder: bool,
}
impl SendErrPreds {
pub(crate) fn of(e: &reqwest::Error) -> Self {
Self {
is_timeout: e.is_timeout(),
is_connect: e.is_connect(),
is_decode: e.is_decode(),
is_body: e.is_body(),
is_request: e.is_request(),
is_builder: e.is_builder(),
}
}
}
/// 流式 send 阶段错误分类,返回类词(置于 bail 串首位供下游按串分类)。
///
/// reqwest 0.12 的 Display 只给 "error sending request for url",不含 source 关键词,
/// 纯 Display 串分类必得 unknown → Fatal 零重试。此处用谓词确定性给类词:
/// timeout/connect/decode/body 各归本类;is_request(非 builder)覆盖陈旧池化连接
/// 首包 RST(ConnectionReset 10054)等传输层发送错,归 connect(与 retry 的
/// is_reqwest_error_retryable 对 is_request 可重试口径一致);builder 构造错归
/// request(不可重试)。类词须落在 stream_recv classify_status_or_class 传输词表内,
/// 改任一侧需同步。
pub(crate) fn send_error_class(p: SendErrPreds) -> &'static str {
if p.is_timeout {
"timeout"
} else if p.is_connect {
"connect"
} else if p.is_decode {
"decode"
} else if p.is_body {
"body"
} else if p.is_request && !p.is_builder {
"connect"
} else {
"request"
}
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match) /// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
/// ///
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方), /// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。 /// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂, /// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
/// 新增 provider 只改这一处。 /// 新增 provider 只改这一处。
///
/// 未知 provider_type 无法走显式报错(工厂签名不动,调用方横跨 src-tauri/df-nodes),
/// 兜底 OpenAI 兼容协议但 warn 留痕,不再静默——协议错配的请求失败可溯源到配置项。
/// anthropic_family 口径与 prompt.rs cache_split / chat.rs needs_url_prefetch 对齐。
pub fn build_provider( pub fn build_provider(
provider_type: &str, provider_type: &str,
base_url: &str, base_url: &str,
@@ -151,22 +69,11 @@ pub fn build_provider(
model: &str, model: &str,
) -> Box<dyn LlmProvider> { ) -> Box<dyn LlmProvider> {
match provider_type { match provider_type {
"anthropic" | "anthropic_compat" => Box::new( "anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new(
anthropic_compat::AnthropicCompatProvider::new(base_url, api_key, model),
),
"openai_compat" | "openai" | "glm" | "deepseek" => {
Box::new(openai_compat::OpenAICompatProvider::new(
base_url, api_key, model, base_url, api_key, model,
)) )),
} _ => Box::new(openai_compat::OpenAICompatProvider::new(
unknown => {
tracing::warn!(
"未知 provider_type {:?},按 OpenAI 兼容协议兜底;请检查 ai_providers 配置的 provider_type",
unknown
);
Box::new(openai_compat::OpenAICompatProvider::new(
base_url, api_key, model, base_url, api_key, model,
)) )),
}
} }
} }
-86
View File
@@ -1,86 +0,0 @@
//! MemoryAdapter trait — 上下文记忆层抽象
//!
//! 将 ContextManager 的核心接口提取为 trait,支持多种记忆后端:
//! - InMemoryAdapter(当前 ContextManager 的包装,默认实现)
//! - VectorMemoryAdapter(未来:接入向量检索,支持语义记忆)
//!
//! 使用方式:`Box<dyn MemoryAdapter>` 替代 `ContextManager`。
use crate::context::ContextManager;
use crate::context_helpers::{ContextConfig, TrackedMessage};
use crate::provider::ChatMessage;
/// 上下文记忆层抽象接口
///
/// 覆盖 agentic loop / IPC 最常用的 15 个方法。
/// 保留 `ContextManager` 作为默认实现(`InMemoryAdapter`),行为零变化。
pub trait MemoryAdapter: Send + Sync {
// ── 写入 ──
fn push(&mut self, msg: ChatMessage);
fn clear(&mut self);
fn insert_at(&mut self, index: usize, message: ChatMessage);
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool;
fn pop_last_assistant_round(&mut self) -> bool;
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()>;
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()>;
// ── 读取 ──
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool);
fn all_messages_clone(&self) -> Vec<ChatMessage>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool;
fn history_tokens(&self) -> u32;
fn budget_limit(&self) -> u32;
fn config(&self) -> &ContextConfig;
// ── 压缩 ──
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage>;
fn has_compressible_messages(&self, protect_start: usize) -> bool;
fn is_compressing(&self) -> bool;
fn set_compressing(&mut self, v: bool);
// ── 生命周期 ──
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>);
fn take_topic_marker(&mut self) -> Option<String>;
fn messages_mut(&mut self) -> &mut [TrackedMessage];
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_>;
}
/// ContextManager 作为默认 MemoryAdapter 实现
impl MemoryAdapter for ContextManager {
fn push(&mut self, msg: ChatMessage) { self.push(msg); }
fn clear(&mut self) { self.clear(); }
fn insert_at(&mut self, index: usize, message: ChatMessage) { self.insert_at(index, message); }
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
self.replace_tool_result_content(tool_call_id, new_content)
}
fn pop_last_assistant_round(&mut self) -> bool { self.pop_last_assistant_round() }
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
self.truncate_after_user_message(target_content)
}
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
self.replace_last_active_user_content(new_content)
}
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
self.build_for_request(sys_tokens)
}
fn all_messages_clone(&self) -> Vec<ChatMessage> { self.all_messages_clone() }
fn len(&self) -> usize { self.len() }
fn is_empty(&self) -> bool { self.is_empty() }
fn history_tokens(&self) -> u32 { self.history_tokens() }
fn budget_limit(&self) -> u32 { self.budget_limit() }
fn config(&self) -> &ContextConfig { self.config() }
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage> { self.compress_old_messages(end) }
fn has_compressible_messages(&self, protect_start: usize) -> bool { self.has_compressible_messages(protect_start) }
fn is_compressing(&self) -> bool { self.is_compressing() }
fn set_compressing(&mut self, v: bool) { self.set_compressing(v); }
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { self.restore_from_messages(messages); }
fn take_topic_marker(&mut self) -> Option<String> { self.take_topic_marker() }
fn messages_mut(&mut self) -> &mut [TrackedMessage] { self.messages_mut() }
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_> {
Box::new(self.iter())
}
}
+9 -221
View File
@@ -1,4 +1,4 @@
//! 厂商模型列表拉取。 //! 厂商模型列表拉取 — F-01 阶段3
//! //!
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。 //! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。 //! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
@@ -18,7 +18,6 @@ use std::time::Duration;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use df_ai_core::model::ModelConfig; use df_ai_core::model::ModelConfig;
use serde_json::Value;
use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList}; use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
use crate::model_probe::probe; use crate::model_probe::probe;
@@ -84,15 +83,12 @@ async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String
} }
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。 // OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
// 不用 resp.json():reqwest::Error::Decode 的 Display 吞 serde 详情(只给 "error decoding let body: ModelsList = resp
// response body"),SenseNova 等厂商解析失败时无法定位根因。改 text() + serde_json::from_str, .json()
// 解析失败时 serde_json::Error 含具体 field/type/position;再叠加宽松 Value fallback 兜底。
let body = resp
.text()
.await .await
.map_err(|e| anyhow!("openai_compat 读取响应体失败({url}):{e}"))?; .map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?;
Ok(filter_chat_models(parse_models_compat("openai_compat", &url, &body)?)) Ok(filter_chat_models(body.into_ids()))
} }
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。 /// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
@@ -116,93 +112,12 @@ async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<Str
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。 // Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中, // 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。 // 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
// 与 openai_compat 同:text() + 严格 serde + Value 宽松 fallback,见 parse_models_compat。 let body: ModelsList = resp
let body = resp .json()
.text()
.await .await
.map_err(|e| anyhow!("anthropic_compat 读取响应体失败({url}):{e}"))?; .map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?;
Ok(filter_chat_models(parse_models_compat("anthropic_compat", &url, &body)?)) Ok(filter_chat_models(body.into_ids()))
}
// ────────────────────────────────────────────────────────────
// 响应解析(text → 严格 serde → Value 宽松 fallback)
// ────────────────────────────────────────────────────────────
/// 响应体诊断片段最大字符数。完整 body 可能巨大,日志只取前缀定位结构。
const BODY_DIAGNOSTIC_CHARS: usize = 200;
/// 解析厂商 `/v1/models` 响应体,返回模型 id 列表(过滤前)。
///
/// 三层解析(诊断优先,兜底保成功):
/// 1. **严格**:`serde_json::from_str::<ModelsList>` — 标准结构命中,错误信息含具体
/// field/type/position(serde_json::Error Display 自带 line/column,不丢 detail)。
/// 2. **宽松 fallback**:`serde_json::Value` 解析 → 取 `data` / `models` 任一数组 →
/// 遍历项取 `id` / `name` 字符串。容错厂商额外字段、类型变体(如 id 漏成 number)。
/// 3. **诊断错误**:严格 + 宽松都失败时,返回含 HTTP 标识 + serde detail + body 前缀
/// 的友好错误,而非 reqwest 默认 "error decoding response body"。
///
/// 注:fallback 只取 id/name(模型名),丢弃 ModelEntry 上的其他字段 — 厂商变体下
/// 我们关心的就是模型名,ModelsList 本身也只消费 id/name,语义对齐。
fn parse_models_compat(provider_type: &str, url: &str, body: &str) -> Result<Vec<String>> {
// 1) 严格解析(标准结构,serde 错误 detail 完整)。
match serde_json::from_str::<ModelsList>(body) {
Ok(list) => return Ok(list.into_ids()),
Err(strict_err) => {
// 2) 宽松 Value fallback — 不依赖 ModelsList 结构,容错厂商变体。
if let Some(ids) = parse_ids_loose(body) {
return Ok(ids);
}
// 3) 双双失败:叠 HTTP 标识 + serde detail + body 前缀诊断。
return Err(anyhow!(
"{provider_type} 响应解析失败({url}):{strict_err} | body 前缀:{}",
body_preview(body)
));
}
}
}
/// 用 `serde_json::Value` 宽松提取模型 id/name。失败(非 JSON / 无 data / 无 id)返回 None。
///
/// 取数组字段优先级:`data`(OpenAI/Anthropic)→ `models`(Ollama 风格 alias)。
/// 项里取 `id` → 兜底 `name`,只接受字符串值(number/bool 等跳过)。
fn parse_ids_loose(body: &str) -> Option<Vec<String>> {
let val: Value = serde_json::from_str(body).ok()?;
let obj = val.as_object()?;
// 任一存在即取;data 优先(标准结构)。
let arr = obj.get("data").or_else(|| obj.get("models"))?;
let arr = arr.as_array()?;
let mut ids = Vec::with_capacity(arr.len());
for item in arr {
let id = item
.get("id")
.or_else(|| item.get("name"))
.and_then(|v| v.as_str());
if let Some(id) = id {
ids.push(id.to_string());
}
}
Some(ids)
}
/// body 前缀诊断(截断 + 控制字符占位,避免换行/制表符污染日志单行)。
fn body_preview(body: &str) -> String {
let prefix: String = body.chars().take(BODY_DIAGNOSTIC_CHARS).collect();
if prefix.chars().all(|c| c.is_control()) && !prefix.is_empty() {
// 整段控制字符(二进制?)→ 给长度提示而非乱码。
return format!("<非文本 body,长度 {}>", body.len());
}
let truncated = body.chars().count() > BODY_DIAGNOSTIC_CHARS;
// 把控制字符(换行/制表等)压成空格,保持日志单行可读。
let cleaned: String = prefix
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if truncated {
format!("{cleaned}…")
} else {
cleaned
}
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
@@ -258,131 +173,4 @@ mod tests {
assert!(msg.contains("ollama"), "err={msg}"); assert!(msg.contains("ollama"), "err={msg}");
assert!(msg.contains("provider_type"), "err={msg}"); assert!(msg.contains("provider_type"), "err={msg}");
} }
// ── parse_models_compat:严格 / fallback / 诊断三层 ──
#[test]
fn parse_strict_openai_format() {
// 标准 OpenAI 结构 → 严格解析命中,不进 fallback
let body = r#"{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini"}]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
}
#[test]
fn parse_loose_fallback_on_unknown_field_type_variant() {
// 厂商变体:data 项里多了非标准字段、且某项漏 id → 严格可能仍过(serde default),
// 此用例构造严格失败 + 宽松应成功:id 字段为 number(非字符串)致 ModelEntry serde 失败。
// 宽松 fallback 应:跳过 number id,保留 string id。
let body = r#"{"data":[{"id":12345},{"id":"glm-4-flash"}]}"#;
// 严格 ModelsList 的 id: Option<String>,number 12345 无法反序列化为 String → 失败
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["glm-4-flash"]);
}
#[test]
fn parse_loose_fallback_via_models_alias() {
// 严格解析缺 data 字段时进 fallback,走 models alias 取 name
let body = r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
}
#[test]
fn parse_loose_fallback_tolerates_extra_top_level_fields() {
// 宽松 fallback 应容错顶层额外字段、非 id 项(只关心 data[].id/name)
let body = r#"{"object":"list","data":[{"id":"deepseek-chat","object":"model"},{"id":"deepseek-coder"}],"supported_ids":["x"]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["deepseek-chat", "deepseek-coder"]);
}
#[test]
fn parse_diagnostic_error_has_serde_detail_and_body_prefix() {
// 完全无法解析(非 JSON)→ 严格 + 宽松双失败 → 错误含 serde detail + body 前缀 + HTTP 标识
let body = "this is not json at all {{{";
let err = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap_err();
let msg = format!("{err}");
// provider_type 标识
assert!(msg.contains("openai_compat"), "err={msg}");
// url 便于定位
assert!(msg.contains("http://x/v1/models"), "err={msg}");
// serde detail(serde_json 错误含 line/column 或 expected 字样)
assert!(
msg.contains("line") || msg.contains("column") || msg.contains("expected"),
"err={msg}"
);
// body 前缀诊断片段
assert!(msg.contains("this is not json"), "err={msg}");
}
#[test]
fn parse_diagnostic_truncates_long_body() {
// 超长 body → 前缀截断(… 标记),不整段灌进错误信息
let long_id = "a".repeat(500);
let body = format!(r#"{{"garbage":"{long_id}""#); // 缺尾 → 非 JSON
let err = parse_models_compat("openai_compat", "http://x/v1/models", &body).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("…"), "长 body 应截断(err={})\n{}", msg.len(), msg);
// 诊断片段不应超过 BODY_DIAGNOSTIC_CHARS + 容差
assert!(
msg.len() < long_id.len(),
"错误信息不应含完整 500 字符 body"
);
}
#[test]
fn parse_diagnostic_empty_body() {
// 空 body → 双失败,错误信息不 panic、含 provider 标识
let err = parse_models_compat("openai_compat", "http://x/v1/models", "").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("openai_compat"), "err={msg}");
assert!(msg.contains("解析失败"), "err={msg}");
}
// ── parse_ids_loose:边界 ──
#[test]
fn parse_ids_loose_returns_none_on_non_json() {
assert!(parse_ids_loose("not json").is_none());
}
#[test]
fn parse_ids_loose_returns_none_on_missing_data_field() {
// 合法 JSON 但无 data/models → None(parse_models_compat 会进而报诊断错误)
assert!(parse_ids_loose(r#"{"foo":"bar"}"#).is_none());
}
#[test]
fn parse_ids_loose_data_not_array_returns_none() {
// data 存在但非数组 → None
assert!(parse_ids_loose(r#"{"data":"not-an-array"}"#).is_none());
}
#[test]
fn parse_ids_loose_skips_non_string_id() {
// id 为 number/null/object → 跳过,只留字符串 id
let body = r#"{"data":[{"id":1},{"id":null},{"id":"keep-me"},{"name":"named"}]}"#;
let ids = parse_ids_loose(body).unwrap();
assert_eq!(ids, vec!["keep-me", "named"]);
}
// ── body_preview:控制字符 + 截断 ──
#[test]
fn body_preview_replaces_control_chars_with_space() {
// 换行/制表压成空格,保持日志单行
let preview = body_preview("line1\nline2\tcol");
assert!(!preview.contains('\n'), "preview={preview}");
assert!(!preview.contains('\t'), "preview={preview}");
assert!(preview.contains("line1"), "preview={preview}");
}
#[test]
fn body_preview_truncates_with_ellipsis() {
let body = "abcdefghij".repeat(100); // 1000 chars
let preview = body_preview(&body);
assert!(preview.ends_with('…'), "preview should end with ellipsis");
// 不应含完整 body
assert!(preview.len() < body.len());
}
} }
+1 -27
View File
@@ -41,7 +41,7 @@ pub fn build_models_url(base_url: &str) -> String {
/// 判断是否为非 chat 模型(应从列表中剔除)。 /// 判断是否为非 chat 模型(应从列表中剔除)。
/// ///
/// 规则(设计 §5.4 + 合理扩展,见 issues): /// 规则(设计 §5.4 + 合理扩展,见 issues):
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen / sensenova-u1 / infographic / image-generation /// - 图片生成:dall-e / midjourney / stable-diffusion / imagen
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别) /// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions) /// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
/// - 转写:transcribe / transcription /// - 转写:transcribe / transcription
@@ -50,9 +50,6 @@ pub fn build_models_url(base_url: &str) -> String {
/// ///
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding, /// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding,
/// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。 /// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。
///
/// 注:`sensenova-u1` 用厂商前缀限定而非裸 `u1`,避免误伤未来含 `u1` 词素的 chat 模型
/// (SenseNova U1 系列走 `/v1/images/generations` 端点,非 chat completions)。
pub fn is_non_chat_model(id: &str) -> bool { pub fn is_non_chat_model(id: &str) -> bool {
let id = id.to_lowercase(); let id = id.to_lowercase();
// 图片生成 // 图片生成
@@ -60,11 +57,6 @@ pub fn is_non_chat_model(id: &str) -> bool {
|| id.contains("midjourney") || id.contains("midjourney")
|| id.contains("stable-diffusion") || id.contains("stable-diffusion")
|| id.contains("imagen") || id.contains("imagen")
// SenseNova U1 系列(图像生成,如 sensenova-u1-fast,走 /v1/images/generations 非 chat)
|| id.contains("sensenova-u1")
// 通用图像生成命名(infographic 海报生成 / image-generation 直白命名)
|| id.contains("infographic")
|| id.contains("image-generation")
// 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice) // 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice)
|| id.contains("tts") || id.contains("tts")
|| id.contains("whisper") || id.contains("whisper")
@@ -234,24 +226,6 @@ mod tests {
assert!(is_non_chat_model("stable-diffusion-xl")); assert!(is_non_chat_model("stable-diffusion-xl"));
} }
#[test]
fn non_chat_sensenova_u1_image_filtered() {
// SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions),
// /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。
assert!(is_non_chat_model("sensenova-u1-fast"));
assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关
assert!(is_non_chat_model("sensenova-u1-pro"));
// 通用图像生成命名也剔
assert!(is_non_chat_model("infographic-v1"));
assert!(is_non_chat_model("image-generation-latest"));
// 对照组:同厂商的 chat 模型 sensenova-6.7-flash-lite 不应被误剔
// (它是原生多模态 chat,见 model_probe.rs 预设表)
assert!(
!is_non_chat_model("sensenova-6.7-flash-lite"),
"sensenova-6.7-flash-lite 是 chat 模型,不应被当图像生成剔除"
);
}
#[test] #[test]
fn non_chat_speech_filtered() { fn non_chat_speech_filtered() {
assert!(is_non_chat_model("tts-1")); assert!(is_non_chat_model("tts-1"));
+12 -148
View File
@@ -1,10 +1,10 @@
//! 模型探测器。 //! 模型探测器 — F-01 阶段2
//! //!
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。 //! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
//! //!
//! 多源探测,高优先源命中即返(短路): //! 多源探测,高优先源命中即返(短路):
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable` //! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
//! 2. 内置预设表前缀匹配(前缀 + 分隔符边界) → `ProbeSource::PresetTable` //! 2. 内置预设表模糊匹配(子串包含) → `ProbeSource::PresetTable`
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic` //! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default` //! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
//! //!
@@ -26,7 +26,7 @@ use crate::model_probe_helpers::{heuristic_infer, presets};
/// ///
/// 多源探测顺序(高优先源命中即返): /// 多源探测顺序(高优先源命中即返):
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感) /// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
/// 2. 预设表前缀匹配(`model_id` 是入参前缀且后缀以分隔符开始,大小写不敏感) /// 2. 预设表模糊匹配(`model_id` 双向子串包含,大小写不敏感)
/// 3. 启发式推断(模型名命名模式) /// 3. 启发式推断(模型名命名模式)
/// 4. 默认值兜底 /// 4. 默认值兜底
/// ///
@@ -38,27 +38,14 @@ pub fn probe(model_id: &str) -> ModelConfig {
return hit; return hit;
} }
// 2. 预设表前缀匹配(入参名以预设名开头 + 后缀以分隔符开始,大小写不敏感) // 2. 预设表模糊匹配(双向子串包含,大小写不敏感)
// 收紧自原「双向子串包含」:子串匹配会把 glm-4.6v 误配给 glm-4v(继承其 vision), // 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。
// gpt-4o-mini 误配给 gpt-4o 等「张冠李戴」。前缀匹配保留合理继承
// (glm-4v-flash → glm-4v),同时要求后缀以分隔符(-/./_)或结尾开始,
// 防止 glm-4v2/glm-4vx 命中 glm-4v。
let needle = model_id.to_lowercase(); let needle = model_id.to_lowercase();
let fuzzy = presets() let fuzzy = presets()
.iter() .iter()
.filter(|m| { .filter(|m| {
let cand = m.model_id.to_lowercase(); let cand = m.model_id.to_lowercase();
if cand.is_empty() || cand.len() > needle.len() { !cand.is_empty() && (cand.contains(&needle) || needle.contains(&cand))
return false;
}
if !needle.starts_with(&cand) {
return false;
}
// 前缀后须为分隔符或字符串结束,避免部分单词命中(glm-4v2 不算 glm-4v)
match needle[cand.len()..].chars().next() {
None => true, // 完全相等(精确匹配已覆盖,这里兜底)
Some(c) => matches!(c, '-' | '.' | '_'),
}
}) })
.max_by_key(|m| m.model_id.len()); .max_by_key(|m| m.model_id.len());
@@ -113,7 +100,7 @@ mod tests {
assert_eq!(m.model_id, "glm-4"); assert_eq!(m.model_id, "glm-4");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]); assert_eq!(m.modalities, vec![Modality::Text]);
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值 // B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard); assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium); assert_eq!(m.cost_tier, CostTier::Medium);
} }
@@ -143,113 +130,20 @@ mod tests {
); );
} }
// ── 预设表:已知多模态模型(命名无 vision 词素,靠预设显式标 vision) ── // ── 预设模糊匹配 ──
// SenseNova flash-lite 原生多模态(商汤文档:支持图像输入理解),
// 但模型名不含 vision/vl/-v 词素,启发式 has_vision_token 无法识别 →
// 必须靠预设表显式标 vision,否则 modalities 漏 vision 致路由误判。
#[test]
fn probe_preset_sensenova_flash_lite_has_vision() {
let m = probe("sensenova-6.7-flash-lite");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(
m.modalities.contains(&Modality::Vision),
"sensenova-6.7-flash-lite 应有 Vision(原生多模态): {:?}",
m.modalities
);
assert!(m.modalities.contains(&Modality::Text));
// context_window 应为预设的 256K
assert_eq!(m.context_window, 262144);
// P1 温度:商汤 sensenova 仅接受 temperature=1.0,透传其它值(标题 0.3/压缩 0.2)
// → 上游 HTTP 400(实测 9 次)。预设表须标 temperature_supported=false,
// 协议转换层据此省略该字段。
assert!(
!m.temperature_supported,
"sensenova 应标 temperature_supported=false(仅接受 1.0)"
);
}
/// 温度支持判定:仅 sensenova 系列为 false,其余模型默认 true(零回归)。
#[test]
fn probe_temperature_supported_defaults_true_except_sensenova() {
assert!(!probe("sensenova-6.7-flash-lite").temperature_supported);
// 启发式路径(非预设表):sensenova 前缀仍判 false
assert!(!probe("sensenova-unknown-model").temperature_supported);
// 常规模型保持 true(原样透传)
assert!(probe("deepseek-v4-flash").temperature_supported);
assert!(probe("glm-4v").temperature_supported);
}
#[test]
fn probe_preset_deepseek_v4_flash_text_only() {
// SenseNova 上的 deepseek-v4-flash:纯文本对话 + 1M 上下文,无 vision
let m = probe("deepseek-v4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]);
assert_eq!(m.context_window, 1_048_576);
}
// ── 预设前缀匹配 ──
#[test] #[test]
fn probe_preset_fuzzy_match_glm4v_variant() { fn probe_preset_fuzzy_match_glm4v_variant() {
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其前缀(后缀 "-x" 以分隔符开始) → 前缀命中 // "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中
let m = probe("glm-4v-x"); let m = probe("glm-4v-x");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.model_id, "glm-4v-x", "前缀命中后 model_id 应用入参名"); assert_eq!(m.model_id, "glm-4v-x", "模糊命中后 model_id 应用入参名");
assert!( assert!(
m.modalities.contains(&Modality::Vision), m.modalities.contains(&Modality::Vision),
"应继承 glm-4v 的 vision 模态" "应继承 glm-4v 的 vision 模态"
); );
} }
#[test]
fn probe_preset_prefix_match_glm4v_flash() {
// 合理继承:glm-4v-flash 以 glm-4v 为前缀 + 分隔符 -,命中并继承 vision
let m = probe("glm-4v-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(m.modalities.contains(&Modality::Vision));
}
#[test]
fn probe_preset_prefix_match_gpt4o_mini() {
// 合理继承:gpt-4o-mini 以 gpt-4o 为前缀 + 分隔符 -
let m = probe("gpt-4o-mini");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.model_id, "gpt-4o-mini");
}
#[test]
fn probe_preset_prefix_match_case_insensitive() {
// 前缀匹配大小写不敏感(精确匹配大小写敏感,前缀兜底)
let m = probe("GLM-4V-FLASH");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert!(m.modalities.contains(&Modality::Vision));
}
#[test]
fn probe_preset_prefix_reject_non_prefix_variant() {
// 张冠李戴防线:glm-4.6v 不以 glm-4v 为前缀(字符顺序不同),不继承 glm-4v 的 vision。
// 但它以 glm-4 为前缀(+ 分隔符 .)→ 命中 glm-4 预设(纯文本),合理。
let m = probe("glm-4.6v");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(
m.model_id, "glm-4.6v",
"前缀命中后 model_id 应用入参名"
);
assert!(
!m.modalities.contains(&Modality::Vision),
"glm-4.6v 不应继承 glm-4v 的 vision(只继承 glm-4 纯文本): {:?}",
m.modalities
);
}
#[test]
fn probe_preset_prefix_reject_no_separator() {
// 分隔符边界:glm-4v2 前缀后是数字(非 -/./_),不算 glm-4v 的合法变体
let m = probe("glm-4v2");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
}
// ── 启发式:Vision ── // ── 启发式:Vision ──
#[test] #[test]
@@ -283,7 +177,7 @@ mod tests {
fn heuristic_flash_keeps_neutral_tier() { fn heuristic_flash_keeps_neutral_tier() {
let m = probe("unknown-flash"); let m = probe("unknown-flash");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
// cost/intel 一律中性,不靠名字猜 // B-260618-04:cost/intel 一律中性,不靠名字猜
assert_eq!(m.intelligence, IntelligenceTier::Standard); assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium); assert_eq!(m.cost_tier, CostTier::Medium);
} }
@@ -351,36 +245,6 @@ mod tests {
assert_eq!(m.cost_tier, CostTier::Medium); assert_eq!(m.cost_tier, CostTier::Medium);
} }
// ── 启发式:版本号后缀不误判 Vision ──
#[test]
fn heuristic_version_suffix_no_vision() {
// "-v" 后接数字属版本号(deepseek-v3 为纯文本模型),不得误判 Vision
for name in ["deepseek-v3", "acme-chat-v2", "acme-v12"] {
let m = probe(name);
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(
!m.modalities.contains(&Modality::Vision),
"{name} 版本号后缀不应误判 Vision: {:?}",
m.modalities
);
}
}
#[test]
fn heuristic_v_morpheme_tail_still_vision() {
// v 词素尾仍判视觉:独立 v 段(qwen-v-plus 形态)与段尾 v(glm-4v 形态,假名避开预设表)
for name in ["acme-chat-v", "acme-4v-plus"] {
let m = probe(name);
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(
m.modalities.contains(&Modality::Vision),
"{name} 段尾 v 词素应判 Vision: {:?}",
m.modalities
);
}
}
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ── // ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
#[test] #[test]
@@ -397,7 +261,7 @@ mod tests {
#[test] #[test]
fn probe_preset_beats_heuristic() { fn probe_preset_beats_heuristic() {
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性, // "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable // 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash"); let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
+6 -19
View File
@@ -1,4 +1,4 @@
//! 模型探测器 — 纯逻辑子模块。 //! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
//! //!
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。 //! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 — //! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
@@ -36,7 +36,7 @@ pub(super) fn presets() -> &'static [ModelConfig] {
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities), /// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。 /// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
/// ///
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据, /// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。 /// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。 /// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
/// ///
@@ -49,7 +49,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
let name = model_id.to_lowercase(); let name = model_id.to_lowercase();
let mut modalities: Vec<Modality> = Vec::new(); let mut modalities: Vec<Modality> = Vec::new();
let mut capabilities: Vec<Capability> = Vec::new(); let mut capabilities: Vec<Capability> = Vec::new();
// 中性默认:不靠模型名猜档位 // 中性默认:不靠模型名猜档位(B-260618-04)
let cost_tier = CostTier::Medium; let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard; let intelligence = IntelligenceTier::Standard;
@@ -74,7 +74,6 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
}; };
} }
@@ -107,26 +106,14 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
} }
} }
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) —— // —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
/// Vision 词素:`vision` / `vl` 子串、`v` 词素尾(任一分隔段以 v 结尾:glm-4v / qwen-v-plus)。 /// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。
/// 裸 "v" 必须是词素尾:`contains("-v")` 会把版本号误判成视觉(deepseek-v3 纯文本, /// 不直接 `contains('v')`(避免误伤 review/verbal 等)。
/// "-v" 后接数字属版本号而非视觉词素),按段尾判定后 v3/v2 不命中、4v/v 段仍命中。
pub(super) fn has_vision_token(name: &str) -> bool { pub(super) fn has_vision_token(name: &str) -> bool {
name.contains("vision") name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
|| name.contains("vl")
|| name.split(['-', '.', '_']).any(|seg| seg.ends_with('v'))
}
/// 温度参数支持判定(命名约定式):商汤 sensenova 系列仅接受 temperature=1.0,
/// 透传其它温度值(标题 0.3/压缩 0.2 等)→ 上游 HTTP 400(实测 9 次)。
/// 返回 false 时协议转换层省略 temperature 字段(交上游默认),避免无效往返;
/// 其余模型默认 true(原样透传,零回归)。
pub(super) fn temperature_supported_by_name(name: &str) -> bool {
!name.starts_with("sensenova")
} }
-648
View File
@@ -1,648 +0,0 @@
//! 工具命名空间存储 — 大工具结果不进主消息队列
//!
//! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。
//! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。
//!
//! 存储格式: "namespace://tool_name/args_hash"
//! 例: namespace://read_file/a1b2c3d4
//!
//! ## 淘汰治理(2026-10-06,设计文档 namespace淘汰治理-设计-2026-10-04.md,拍板 2/2)
//! - **pin 机制**:关键产出(子代理报告等)标记 pinned,LRU 永不淘汰;pin 总字节超
//! 池容量 50% 时最旧 pin 降级可淘(拍板 C1,防 pin 滥用撑死池)。
//! - **EvictionLog**:淘汰留档(50 条上限),展开点 read_only()=None 时查 Log 给富提示
//! (原工具名+内容前 80 字+淘汰时刻),替代裸 EVICTED_PLACEHOLDER。
//! - **owner 软隔离**:条目带归属(调用方 conv_id),淘汰顺序=无主最旧→他主未 pin→
//! 当前主未 pin;软隔离只影响淘汰顺序,owner 传错退回现状行为,无正确性风险。
//! - **零 schema 变更**:纯内存态增强。
use std::collections::HashMap;
use std::time::Instant;
/// 引用路径前缀
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。
///
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
/// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
/// namespace 字节阈值(> 2048 bytes 触发)
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
/// namespace 行数阈值(> 50 行 触发)
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
/// EvictionLog 容量上限(条)。单条 ~200B,50 条可忽略。
pub const EVICTION_LOG_CAP: usize = 50;
/// pin 总字节占池容量比例上限。超过后最旧 pin 降级可淘(拍板 C1)。
pub const PIN_RATIO_CAP: f64 = 0.5;
/// 判定工具结果是否应进入 namespace
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
return true;
}
content.len() > NAMESPACE_BYTE_THRESHOLD
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
}
/// 判定 tool_result 内容是否为 namespace 引用路径
pub fn is_namespace_ref(content: &str) -> bool {
content.starts_with(NAMESPACE_REF_PREFIX)
}
/// 从引用路径中提取内部 key
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
// 939①: 先剥 ?len= 长度尾段(估算入账用),否则尾段并入 key 致全线查空
.and_then(|s| s.split("?len=").next())
.and_then(|s| s.split('/').nth(1))
}
/// 从引用路径提取原文字节数(939①)。带 `?len=N` 尾段的引用返回 Some(N);
/// 旧格式/放弃入库的无尾段引用返回 None(调用方按字面字符保守估算)。
pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
full_path.rsplit("?len=")
.next()
.and_then(|s| s.parse::<usize>().ok())
.filter(|_| full_path.contains("?len="))
}
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
}
/// namespace 条目
#[derive(Debug, Clone)]
pub struct NamespaceEntry {
pub key: String,
pub tool_name: String,
pub content: String,
pub created_at: Instant,
pub access_count: u64,
/// pin 标记:LRU 永不淘汰(pin 总量超 50% 容量时最旧 pin 降级,拍板 C1)
pub pinned: bool,
/// 归属(conv_id / 子代理 id),软隔离淘汰顺序用;None=无主
pub owner: Option<String>,
/// 预生成的淘汰提示料(工具名+参数摘要,store 时固化),淘汰留档用
pub evict_hint: String,
}
/// 淘汰留档记录(EvictionLog 元素)
#[derive(Debug, Clone)]
pub struct EvictionRecord {
pub key: String,
pub tool_name: String,
/// 内容前 80 字符(hint),供富提示识别原文
pub hint: String,
pub evicted_at: Instant,
pub owner: Option<String>,
}
/// namespace 存储(内存 HashMap + LRU 淘汰 + pin/EvictionLog/owner 治理)
#[derive(Debug, Clone)]
pub struct NamespaceStore {
entries: HashMap<String, NamespaceEntry>,
max_bytes: usize,
current_bytes: usize,
/// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP)
eviction_log: std::collections::VecDeque<EvictionRecord>,
}
impl NamespaceStore {
/// 创建 namespace 存储
pub fn new(max_bytes: usize) -> Self {
Self {
entries: HashMap::new(),
max_bytes,
current_bytes: 0,
eviction_log: std::collections::VecDeque::new(),
}
}
/// 默认大小:10MB
pub fn default() -> Self {
Self::new(10 * 1024 * 1024)
}
/// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主)
///
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
self.store_with_owner(tool_name, content, None)
}
/// 存入(带归属)。owner 软隔离:淘汰优先淘无主/他主,当前主最后。
pub fn store_with_owner(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
self.store_inner(tool_name, content, owner, false)
}
/// 存入并 pin(关键产出:子代理报告等)。pin 条目 LRU 永不淘汰,
/// pin 总字节超池 50% 时最旧 pin 降级可淘(在 evict_if_needed 内兜底)。
pub fn store_pinned(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
self.store_inner(tool_name, content, owner, true)
}
fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String {
let key = simple_hash(content);
// 939① 估算入账:ref 尾段携带原文字节数,TokenEstimator 解析即按真实体量入账
// (旧口径 ref 仅 ~30 字符被记 ~15 token,预算/裁剪/压缩三重门对其失效)。
// parse_namespace_key 取 nth(1) 不受尾段影响,展开/淘汰提示零改动。
// 放弃入库(超容量/淘汰后仍放不下)返回**不带长度**的 path:原文不在池,展开点
// 落占位提示,预算按轻量字符计——已淘汰内容不应继续虚占预算。
let path_with_len = format!("{}{}/{}?len={}", NAMESPACE_REF_PREFIX, tool_name, key, content.len());
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
if self.entries.contains_key(&key) {
if let Some(entry) = self.entries.get_mut(&key) {
entry.access_count += 1;
if pinned && !entry.pinned {
entry.pinned = true;
}
}
return path_with_len;
}
let new_bytes = content.len();
// 单条超过总容量:硬入库会把 current_bytes 永久顶破上限(evict 清空后仍无条件
// insert 的旧路径),拒绝入库保住 current_bytes <= max_bytes 不变式
if new_bytes > self.max_bytes {
return path;
}
// pin 比例上限(拍板 C1):pinned 入库前无条件校验,把 pin 总量压回 50% 内
// (最旧 pin 先降级),不只在池溢出时——防 pin 静默累积到失控
if pinned {
let cap = (self.max_bytes as f64 * PIN_RATIO_CAP) as usize;
while self.pinned_bytes() + new_bytes > cap {
if !self.demote_oldest_pin() { break; }
}
}
// 检查容量,超限淘汰
self.evict_if_needed(new_bytes);
// 淘汰后仍放不下(全 pin 撑满):放弃入库(返回引用路径,展开点落占位),
// 不降级 pin——pin 保护是强语义,普通 store 不得侵蚀 pin
if self.current_bytes + new_bytes > self.max_bytes && !pinned {
return path;
}
let entry = NamespaceEntry {
key: key.clone(),
tool_name: tool_name.to_string(),
content: content.to_string(),
created_at: Instant::now(),
access_count: 1,
pinned,
owner: owner.map(str::to_string),
evict_hint: content.chars().take(80).collect(),
};
self.current_bytes += new_bytes;
self.entries.insert(key, entry);
path_with_len
}
/// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。
pub fn pin(&mut self, full_path: &str) -> bool {
let Some(key) = parse_namespace_key(full_path) else { return false };
if !self.entries.contains_key(key) {
return false;
}
if self.pinned_bytes() + self.entries.get(key).map(|e| e.content.len()).unwrap_or(0)
> (self.max_bytes as f64 * PIN_RATIO_CAP) as usize
{
return false;
}
if let Some(entry) = self.entries.get_mut(key) {
entry.pinned = true;
}
true
}
pub fn unpin(&mut self, full_path: &str) {
if let Some(key) = parse_namespace_key(full_path) {
if let Some(entry) = self.entries.get_mut(key) {
entry.pinned = false;
}
}
}
/// pin 条目总字节
pub fn pinned_bytes(&self) -> usize {
self.entries.values().filter(|e| e.pinned).map(|e| e.content.len()).sum()
}
/// 降级最旧 pin。返回是否有降级发生(全 pin 已降无可降返回 false)。
fn demote_oldest_pin(&mut self) -> bool {
let oldest_pin = self.entries.iter()
.filter(|(_, e)| e.pinned)
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone());
if let Some(key) = oldest_pin {
if let Some(entry) = self.entries.get_mut(&key) {
entry.pinned = false;
return true;
}
}
false
}
/// 读取完整内容
pub fn read(&mut self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
let entry = self.entries.get_mut(key)?;
entry.access_count += 1;
Some(entry.content.as_str())
}
/// 读取(只读,不改 access_count)
pub fn read_only(&self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
self.entries.get(key).map(|e| e.content.as_str())
}
/// 展开失败的富提示:查 EvictionLog,命中返回「原为 X 前 80 字…于淘汰,可重新调用」,
/// 未命中(跨会话残留/未知 key)返回 None,展开点落回 EVICTED_PLACEHOLDER。
pub fn eviction_rich_notice(&self, full_path: &str) -> Option<String> {
let key = parse_namespace_key(full_path)?;
let rec = self.eviction_log.iter().find(|r| r.key == key)?;
Some(format!(
"[结果已淘汰] 原为 {}(\"{}\"…) 因内存容量淘汰。可重新调用 {} 取回原文。",
rec.tool_name, rec.hint, rec.tool_name,
))
}
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
/// 不进返回值,调用方维持淘汰占位语义。
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
/// 返回 引用路径 → 原文。
pub fn backfill_from_messages(
&mut self,
missed_refs: &[String],
message_contents: &[String],
) -> HashMap<String, String> {
let mut recovered: HashMap<String, String> = HashMap::new();
if missed_refs.is_empty() {
return recovered;
}
let wanted: HashMap<&str, &String> = missed_refs.iter()
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
.collect();
for content in message_contents {
let key = simple_hash(content);
if let Some(path) = wanted.get(key.as_str()) {
// 同一引用可能在多条历史消息出现,找回一次即可
if recovered.contains_key(path.as_str()) {
continue;
}
if let Some(tool) = parse_ref_tool_name(path) {
self.store(tool, content);
}
recovered.insert((*path).clone(), content.clone());
}
}
recovered
}
/// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path)
.and_then(|k| self.entries.get(k))
.is_some()
}
/// 条目数
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4):
/// ①无主且未 pin 的最旧 → ②他主(非最后入参 owner)未 pin 的最旧 → ③当前主未 pin 最旧。
/// pin 永不淘(除非 store_pinned 路径 demote 降级)。needed 所需空间,cur_owner=当前
/// 存入方归属(淘汰保护对象,None=无主视角)。
fn evict_if_needed_with_owner(&mut self, needed: usize, cur_owner: Option<&str>) {
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
// 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned)
let oldest_key = self.entries.iter()
.filter(|(_, e)| !e.pinned && e.owner.is_none())
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone())
.or_else(|| {
self.entries.iter()
.filter(|(_, e)| {
!e.pinned && match (&e.owner, cur_owner) {
(Some(o), Some(c)) => o != c,
(Some(_), None) => true, // 有主 vs 无主调用:有主条目算"他主"
_ => false,
}
})
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone())
})
.or_else(|| {
self.entries.iter()
.filter(|(_, e)| !e.pinned)
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone())
});
match oldest_key {
Some(key) => {
if let Some(entry) = self.entries.remove(&key) {
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
// EvictionLog 留档(50 条上限,超出弹最旧)
self.eviction_log.push_back(EvictionRecord {
key: entry.key,
tool_name: entry.tool_name,
hint: entry.evict_hint,
evicted_at: Instant::now(),
owner: entry.owner,
});
while self.eviction_log.len() > EVICTION_LOG_CAP {
self.eviction_log.pop_front();
}
}
}
None => break, // 全部 pinned(且非 pin 腾位路径):放不下就放不下
}
}
}
/// 兼容入口(无主视角)
fn evict_if_needed(&mut self, needed: usize) {
self.evict_if_needed_with_owner(needed, None);
}
/// 清空
pub fn clear(&mut self) {
self.entries.clear();
self.current_bytes = 0;
self.eviction_log.clear();
}
}
/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
///
/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、
/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key`
/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。
/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。
fn simple_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_use_namespace_small_content() {
assert!(!should_use_namespace("short", "custom_tool"));
}
#[test]
fn should_use_namespace_large_bytes() {
let large = "x".repeat(3000);
assert!(should_use_namespace(&large, "custom_tool"));
}
#[test]
fn should_use_namespace_always_large_tool() {
assert!(should_use_namespace("short", "read_file"));
assert!(should_use_namespace("short", "list_directory"));
assert!(should_use_namespace("short", "diff_files"));
}
#[test]
fn should_use_namespace_many_lines() {
let many = (0..60).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
assert!(should_use_namespace(&many, "custom_tool"));
}
#[test]
fn is_namespace_ref_detects_prefix() {
assert!(is_namespace_ref("namespace://read_file/abc"));
assert!(!is_namespace_ref("file content here"));
}
#[test]
fn parse_namespace_key_extracts_key() {
assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123"));
assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz"));
assert_eq!(parse_namespace_key("no_prefix"), None);
}
#[test]
fn store_and_read() {
let mut ns = NamespaceStore::new(100_000);
let path = ns.store("read_file", "hello world content");
assert!(is_namespace_ref(&path));
assert!(path.contains("read_file"));
assert_eq!(ns.read(&path), Some("hello world content"));
}
#[test]
fn store_duplicate_key_does_not_double_count() {
let mut ns = NamespaceStore::new(100_000);
let content = "test content for duplicate check";
let p1 = ns.store("tool_a", content);
let p2 = ns.store("tool_b", content);
assert_eq!(ns.len(), 1, "相同内容应去重");
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
let r1 = ns.read(&p1).map(str::to_owned);
let r2 = ns.read(&p2).map(str::to_owned);
assert_eq!(r1, r2);
}
#[test]
fn store_content_with_slash_in_prefix_reads_back() {
// 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep
// 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断
// → read None,原文丢失。hex hash 修复后 store→read 正确取回。
let mut ns = NamespaceStore::new(100_000);
let content = "foo/bar/baz/qux\nline2\nline3";
let path = ns.store("list_directory", content);
assert!(is_namespace_ref(&path));
assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string()));
}
#[test]
fn eviction_oldest_removed_when_over_limit() {
let mut ns = NamespaceStore::new(100); // 极小容量
let small = "a"; // 1 byte
ns.store("t1", small);
assert_eq!(ns.len(), 1);
// 填充到超限
let big = "x".repeat(200);
ns.store("t2", &big);
// t1 可能被淘汰
if ns.current_bytes > 100 {
assert!(ns.len() < 2, "超限时应已淘汰旧条目");
}
}
#[test]
fn store_oversized_single_entry_rejected_cap_intact() {
// 回归:单条超过 max_bytes 时旧路径淘汰清空后仍无条件入库,current_bytes
// 永久突破上限。现拒绝入库,仅返回引用路径,展开点落 EVICTED_PLACEHOLDER。
let mut ns = NamespaceStore::new(100);
let path = ns.store("t1", &"x".repeat(200));
assert_eq!(ns.len(), 0, "超容量单条应拒绝入库");
assert_eq!(ns.current_bytes, 0, "字节账目不得突破上限");
assert!(is_namespace_ref(&path), "返回值仍是引用路径");
assert_eq!(ns.read_only(&path), None, "不可解析 → 既有淘汰占位语义");
// 拒绝后不污染状态,常规条目仍可正常入
let p2 = ns.store("t2", "ok");
assert_eq!(ns.read(&p2).map(str::to_owned), Some("ok".to_string()));
assert_eq!(ns.current_bytes, 2);
}
#[test]
fn clear_resets_all() {
let mut ns = NamespaceStore::new(100_000);
ns.store("t1", "content");
assert!(!ns.is_empty());
ns.clear();
assert!(ns.is_empty());
assert_eq!(ns.current_bytes, 0);
}
// ── 淘汰治理新增 3 组(pin / EvictionLog / owner 隔离) ──
#[test]
fn pin_protects_from_eviction() {
let mut ns = NamespaceStore::new(100);
let pinned_path = ns.store_pinned("report", &"p".repeat(50), Some("conv-a"));
assert!(ns.contains(&pinned_path));
// 塞入未 pin 大内容逼淘汰:pin 条目须存活
let _ = ns.store("t2", &"x".repeat(80));
assert!(ns.contains(&pinned_path), "pinned 条目不得被 LRU 淘汰");
assert_eq!(ns.read_only(&pinned_path).map(str::to_owned), Some("p".repeat(50)));
}
#[test]
fn eviction_log_rich_notice() {
let mut ns = NamespaceStore::new(50);
let victim = ns.store("read_file", &"v".repeat(40));
let victim_path = victim.clone();
// 挤出 victim
let _ = ns.store("t2", &"y".repeat(45));
assert_eq!(ns.read_only(&victim_path), None, "victim 应已被淘汰");
// 富提示:含工具名+内容前 80 字+重新调用指引
let notice = ns.eviction_rich_notice(&victim_path).expect("Log 应命中");
assert!(notice.contains("read_file"), "富提示应含工具名: {notice}");
assert!(notice.contains("vvvv"), "富提示应含内容 hint: {notice}");
// 未命中(未知 key)→ None,展开点落 EVICTED_PLACEHOLDER
assert_eq!(ns.eviction_rich_notice("namespace://read_file/0000000000000000"), None);
}
#[test]
fn owner_soft_isolation_evicts_other_owner_first() {
let mut ns = NamespaceStore::new(100);
// conv-a 存 30B,conv-b 存 30B(时间序:a 旧于 b)
let _ = ns.store_with_owner("ta", &"a".repeat(30), Some("conv-a"));
let path_b = ns.store_with_owner("tb", &"b".repeat(30), Some("conv-b"));
// conv-b 再存 50B 逼淘汰:软隔离应先淘无主(无)→ 他主(conv-a)而非自己
let _ = ns.store_with_owner("tc", &"c".repeat(50), Some("conv-b"));
assert!(!ns.contains("namespace://ta/x"), "ta 的 key 查询仅辅助");
assert!(ns.entries.values().all(|e| e.owner.as_deref() != Some("conv-a")),
"他主(conv-a)条目应先被淘汰");
assert!(ns.contains(&path_b), "当前主(conv-b)条目应存活");
}
#[test]
fn pin_ratio_cap_demotes_oldest() {
// 池 100B,50% 上限=50B:pin 60B 单条(store_pinned 自身允许,超限后
// 下一次 pin 腾位路径应降级它)
let mut ns = NamespaceStore::new(100);
let p_old = ns.store_pinned("old", &"o".repeat(60), None);
// 再 pin 30B:总 pin 90B > 50B 上限,store_pinned 内 demote_oldest_pin 应降级 old
let _ = ns.store_pinned("new", &"n".repeat(30), None);
// 挤压逼淘汰:降级后的 old 可被淘,受保护的 new 存活
let _ = ns.store("t3", &"z".repeat(80));
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
"降级机制应使 pin 总量可被压缩");
}
#[test]
fn ref_carries_content_len_for_budget() {
// 939①: 成功入库的 ref 带 ?len= 原文字节数,parse 往返一致;
// parse_namespace_key 不受尾段影响(展开点零改动)
let mut ns = NamespaceStore::new(100_000);
let content = "x".repeat(5000);
let path = ns.store("read_file", &content);
assert!(path.contains(&format!("?len={}", content.len())), "ref 应带 len: {path}");
assert_eq!(parse_ref_content_len(&path), Some(content.len()));
assert_eq!(parse_namespace_key(&path), parse_namespace_key(&format!("namespace://read_file/{}", crate::namespace_store::simple_hash(&content))));
// 旧格式/无尾段 → None(调用方保守按字面计)
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
}
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
#[test]
fn backfill_recovers_original_by_content_hash() {
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
let mut hot = NamespaceStore::new(1 << 20);
let original = "目录列表内容\n".repeat(100);
let path = hot.store("list_directory", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&[original.clone(), "无关消息".to_string()],
);
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
}
#[test]
fn backfill_without_original_stays_empty() {
let mut hot = NamespaceStore::new(1 << 20);
let path = hot.store("read_file", &"真原文".repeat(500));
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&["无关A".to_string(), "无关B".to_string()],
);
assert!(recovered.is_empty(), "无对应原文不应误填");
assert_eq!(cold.read_only(&path), None);
assert!(cold.is_empty());
}
#[test]
fn backfill_duplicate_refs_recover_once() {
// 同一引用出现在多条历史消息:找回一次,store 只建一条
let mut hot = NamespaceStore::new(1 << 20);
let original = "z".repeat(3000);
let path = hot.store("grep", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
assert_eq!(recovered.len(), 1);
assert_eq!(cold.len(), 1);
}
}
-64
View File
@@ -1,64 +0,0 @@
//! 本机连通性基准面探测(弱网重试的感知依据)。
//!
//! 场景: 隧道/电梯等移动网络波动可达分钟级, 固定次数重试必然失败。send 失败时探
//! 基准面区分「本机断网」与「单点故障」: 前者进长等待(自适应间隔探测, 恢复即重试),
//! 后者短退避即可。纯 IP TCP 与系统 DNS 解析双测, 任一通过即判在线
//! (DNS 单挂时 IP 直连上游仍可能可用, 双测互为盲区互补)。
//!
//! 演进路线(P2, 需求触发再上): 系统网络事件订阅双通道——事件源治接口级变化
//! (WiFi 断开/切蜂窝)零延迟, 本探针治链路级不通(隧道半断/DNS 故障, 系统事件
//! 不触发的盲区)。跨平台无统一事件 crate(Windows=INetworkListManager COM,
//! macOS=NWPathMonitor, Linux=netlink), 届时以 NetEventSource trait 抽象,
//! 按 devflow 实际部署面(Windows 桌面+Linux 云端)逐平台实现; Linux 服务端
//! 网络稳定, 纯探针已足够。
use std::net::ToSocketAddrs;
use std::time::Duration;
/// 探测超时(单次解析或建连)。弱网下慢响应不代表断网, 2s 内完成才计可用。
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// send 弱网重试总窗口(隧道/电梯级分钟级波动覆盖)。
pub(crate) const SEND_RETRY_WINDOW: Duration = Duration::from_secs(600);
/// 脱网确认后的探针间隔表(秒): 首段 5s 快探保即时性(网络抖动常秒级恢复),
/// 确认持续脱网后间隔拉长到 30s 封顶——蜂窝 radio 休眠省电, 分钟级隧道场景无感差异。
pub(crate) const OFFLINE_PROBE_SCHEDULE_SECS: &[u64] = &[5, 5, 5, 5, 10, 15, 30];
/// 按脱网轮次取探针间隔(越界取表尾封顶值)。
pub(crate) fn offline_probe_wait(poll_count: usize) -> Duration {
let idx = poll_count.saturating_sub(1).min(OFFLINE_PROBE_SCHEDULE_SECS.len() - 1);
Duration::from_secs(OFFLINE_PROBE_SCHEDULE_SECS[idx])
}
/// IP 直连探针目标(公共 DNS 服务商的 443, 不依赖 DNS)。
const PROBE_IPS: &[&str] = &["223.5.5.5:443", "119.29.29.29:443"];
/// DNS 面探针目标(公共域, 走系统 resolver)。
const PROBE_DNS_HOST: &str = "www.aliyun.com:443";
async fn tcp_alive(addr: &str) -> bool {
let target = match addr.to_socket_addrs() {
Ok(mut it) => match it.next() {
Some(a) => a,
None => return false,
},
Err(_) => return false,
};
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(target))
.await
.is_ok()
}
/// 本机网络基准面是否可用。任一探针通过即在线。
pub(crate) async fn baseline_alive() -> bool {
for ip in PROBE_IPS {
if tcp_alive(ip).await {
return true;
}
}
matches!(
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::lookup_host(PROBE_DNS_HOST)).await,
Ok(Ok(_))
)
}
File diff suppressed because it is too large Load Diff
+19 -330
View File
@@ -9,9 +9,9 @@
//! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。 //! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, error}; use tracing::debug;
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta}; use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================ // ============================================================
// OpenAI API 请求/响应结构体 // OpenAI API 请求/响应结构体
@@ -65,30 +65,11 @@ pub(crate) struct OpenAiResponse {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct OpenAiChoice { pub(crate) struct OpenAiChoice {
pub(crate) message: OpenAiMessageResp, pub message: OpenAiMessageResp,
/// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`] // SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
/// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被 #[allow(dead_code)]
/// 下游当解析失败整批丢弃而无任何日志线索。 pub finish_reason: Option<String>,
#[serde(default)]
pub finish_reason: FinishReason,
}
/// finish_reason 载体(透明 NewType)。
///
/// 消费方只取 message,本字段值随 choice 整体 drop 时触发截断告警;对外公开
/// (pub 模块下的 pub 结构属库 API 面)同时免去字段 dead_code 豁免。
/// serde 透明,线上 JSON 形态与 Option<String> 一致。
#[derive(Debug, Default, Deserialize)]
#[serde(transparent)]
pub struct FinishReason(Option<String>);
impl Drop for FinishReason {
fn drop(&mut self) {
if self.0.as_deref() == Some("length") {
tracing::warn!("[OpenAI] 同步响应因 max_tokens 截断(finish_reason=length),输出不完整");
}
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -102,14 +83,10 @@ pub(crate) struct OpenAiMessageResp {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiToolCallResp { pub(crate) struct OpenAiToolCallResp {
/// 缺省容错:个别兼容端点同步响应省略 id → 缺省空串,由 parse_tool_calls 的
/// tool_call_id_or_fallback 生成唯一 fallback。此前必填,单条 tool_call 缺 id 令
/// 整个响应 serde 失败 → complete 报"响应解析失败"整批丢弃(流式路径早已容错)。
#[serde(default)]
pub id: String, pub id: String,
// 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning // SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
#[serde(rename = "type", default)] #[serde(rename = "type")]
pub call_type: String, pub call_type: String,
pub function: OpenAiFunctionResp, pub function: OpenAiFunctionResp,
} }
@@ -117,116 +94,32 @@ pub(crate) struct OpenAiToolCallResp {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiFunctionResp { pub(crate) struct OpenAiFunctionResp {
pub name: String, pub name: String,
/// 缺省容错:arguments 缺失 → 空串(对齐 anthropic 同步 tool_use input 缺失 args="" 口径),
/// 防单条缺字段令整个响应解析失败;name 保持必填(无名 tool_call 无法派发,无安全缺省)。
#[serde(default)]
pub arguments: String, pub arguments: String,
} }
/// OpenAI 标准嵌套缓存口径形态(usage.prompt_tokens_details):
/// GLM/Qwen/OpenAI 官方在嵌套结构报缓存命中,DeepSeek 用扁平 prompt_cache_hit_tokens。
/// cached_tokens 用 Option 容错给空 details 对象的网关。
#[derive(Debug, Default, Deserialize)]
pub(crate) struct PromptTokensDetails {
#[serde(default)]
pub cached_tokens: Option<u64>,
}
/// usage 反序列化中间载体:与 OpenAiUsage 字段同构,经 `From` 做缓存口径归一后产出
/// OpenAiUsage(见 impl From)。流式 SSE 与非流式 JSON 两条解析路径因此都自动获得
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsageRaw {
/// 三个计数缺省 0:部分兼容网关 usage 只报部分字段(如仅 total_tokens),必填会让
/// 整帧 serde 失败走空 chunk 分支 — 同帧 content 一并蒸发、usage 归零无痕。
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default)]
pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_hit_tokens: u32,
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_miss_tokens: u32,
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
/// 非 reasoning 模型无此字段 → default 0。
#[serde(default)]
pub reasoning_tokens: u32,
/// OpenAI 标准嵌套缓存口径(GLM/Qwen/OpenAI 官方)。
#[serde(default)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
/// OpenAI usage(缓存口径已归一)
///
/// 归一发生在反序列化时(`#[serde(from)]`):扁平 hit 有值优先(DeepSeek 自报 hit+miss);
/// 扁平 hit 未报而嵌套 cached_tokens > 0 时回填 hit,miss 推断为 prompt - hit(嵌套口径
/// 缺全价分项);两种口径都没有则保持 0。消费方(apply_openai_sse / openai_compat 非流式)
/// 直接读扁平字段,无需感知 provider 差异。
#[derive(Debug, Deserialize)]
#[serde(from = "OpenAiUsageRaw")]
pub(crate) struct OpenAiUsage { pub(crate) struct OpenAiUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_tokens: u32, pub total_tokens: u32,
/// 缓存命中 token(低价;扁平优先,缺失时回填自嵌套 cached_tokens)
pub prompt_cache_hit_tokens: u32,
/// 缓存未命中 token(全价真实输入;嵌套口径推断时 = prompt - hit)
pub prompt_cache_miss_tokens: u32,
/// 思考 token(隐藏输出)
pub reasoning_tokens: u32,
}
impl From<OpenAiUsageRaw> for OpenAiUsage {
fn from(raw: OpenAiUsageRaw) -> Self {
let (hit, miss) = match raw.prompt_tokens_details.and_then(|d| d.cached_tokens) {
Some(nested) if nested > 0 && raw.prompt_cache_hit_tokens == 0 => {
let hit = u32::try_from(nested).unwrap_or(u32::MAX);
(hit, raw.prompt_tokens.saturating_sub(hit))
}
_ => (raw.prompt_cache_hit_tokens, raw.prompt_cache_miss_tokens),
};
Self {
prompt_tokens: raw.prompt_tokens,
completion_tokens: raw.completion_tokens,
total_tokens: raw.total_tokens,
prompt_cache_hit_tokens: hit,
prompt_cache_miss_tokens: miss,
reasoning_tokens: raw.reasoning_tokens,
}
}
} }
/// SSE 流式响应 chunk /// SSE 流式响应 chunk
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk { pub(crate) struct OpenAiStreamChunk {
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
/// 流呈无声截断。
#[serde(default)]
pub choices: Vec<OpenAiStreamChoice>, pub choices: Vec<OpenAiStreamChoice>,
/// 末 chunk(choices 为空)携带的累计 usage /// 末 chunk(choices 为空)携带的累计 usage
#[serde(default)] #[serde(default)]
pub usage: Option<OpenAiUsage>, pub usage: Option<OpenAiUsage>,
/// 流中途 error 事件(OpenAI 兼容协议:`{"error":{"message":..,"type":..}}`)。
/// 部分中转站按 OpenAI 协议在流中途发 error 帧而非走 HTTP 非 200,
/// serde default + Value 兜底:旧响应无此字段不受影响,且对 error 载荷形态不敏感。
#[serde(default)]
pub error: Option<serde_json::Value>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChoice { pub(crate) struct OpenAiStreamChoice {
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
#[serde(default)]
pub delta: OpenAiStreamDelta, pub delta: OpenAiStreamDelta,
pub finish_reason: Option<String>, pub finish_reason: Option<String>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamDelta { pub(crate) struct OpenAiStreamDelta {
pub content: Option<String>, pub content: Option<String>,
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>, pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
@@ -237,8 +130,6 @@ pub(crate) struct OpenAiStreamDelta {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamToolCall { pub(crate) struct OpenAiStreamToolCall {
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
#[serde(default)]
pub index: u32, pub index: u32,
pub id: Option<String>, pub id: Option<String>,
pub function: Option<OpenAiStreamFunction>, pub function: Option<OpenAiStreamFunction>,
@@ -274,103 +165,33 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
match serde_json::from_str::<OpenAiStreamChunk>(data) { match serde_json::from_str::<OpenAiStreamChunk>(data) {
Ok(chunk) => { Ok(chunk) => {
// 流中途 error 事件(中转站按 OpenAI 协议在流中途发 error 帧)。
// 不走 finished 完成路径(避免残缺响应被当正常完成入库),由 stream_llm
// 识别 error 非空 → 发 AiError + 丢弃残缺(对齐 anthropic_helpers 215-219)。
if let Some(err_val) = chunk.error {
let msg = err_val
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("stream error")
.to_string();
error!(%msg, raw = %err_val, "OpenAI 流式错误事件");
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
error: Some(msg),
reasoning_content: None,
truncated_by_max_tokens: false,
};
}
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积) // 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
if let Some(u) = chunk.usage { if let Some(u) = chunk.usage {
// 缺省计数的留痕:两个分项均 0 基本可断定 provider 上报不完整(真实完成
// 必有 completion 计数),非流路径下游拿到零 usage 无从察觉。
if u.prompt_tokens == 0 && u.completion_tokens == 0 {
tracing::warn!(
total = u.total_tokens,
"[OpenAI] usage 缺 prompt/completion 分项(按 0 兜底),provider 上报不完整"
);
}
tracing::info!(
prompt = u.prompt_tokens,
completion = u.completion_tokens,
cache_hit = u.prompt_cache_hit_tokens,
cache_miss = u.prompt_cache_miss_tokens,
reasoning = u.reasoning_tokens,
"[OpenAI] 末 chunk usage 解析(deepseek 等报 cache)"
);
*usage_accum = Some(TokenUsage { *usage_accum = Some(TokenUsage {
prompt_tokens: u.prompt_tokens, prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens, completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens, total_tokens: u.total_tokens,
prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
reasoning_tokens: u.reasoning_tokens,
}); });
} }
if let Some(choice) = chunk.choices.into_iter().next() { if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default(); let delta_text = choice.delta.content.unwrap_or_default();
// 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/ // "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
// length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。 let finished = choice.finish_reason.as_deref() == Some("stop")
// content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂 || choice.finish_reason.as_deref() == Some("tool_calls")
let finished = matches!( || choice.finish_reason.as_deref() == Some("length");
choice.finish_reason.as_deref(),
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter")
);
// length = 输出被 max_tokens 截断:仍按正常终止收敛(finished=true),
// 但截断标志随 chunk 带出,消费方据此把该轮产出标记截断,不冒充完整。
let truncated_by_max_tokens = choice.finish_reason.as_deref() == Some("length");
let tool_calls = choice.delta.tool_calls.map(|tcs| { let tool_calls = choice.delta.tool_calls.map(|tcs| {
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
// 冲突时缺省会互相踩(下游按 index 累积 → 参数拼错位),warn 留痕定位
// provider 兼容缺陷,不做静默改写。
if tcs.len() > 1 {
let mut idxs: Vec<u32> = tcs.iter().map(|tc| tc.index).collect();
idxs.sort_unstable();
idxs.dedup();
if idxs.len() < tcs.len() {
tracing::warn!(
calls = tcs.len(),
unique = idxs.len(),
"[OpenAI] 同帧多 tool_call 增量 index 冲突(缺省 0 互踩),按 index 累积将错位"
);
}
}
tcs.into_iter() tcs.into_iter()
.map(|tc| { .map(|tc| ToolCallDelta {
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
// 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI
// 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免
// 覆盖首 chunk 的权威 id。Some("") → `gen_stream_{index}` fallback,
// Some(非空) → 原样。下游 stream_recv 按 index 累积,draft.id 透传
// 至 ToolCall.id(accumulate_tool_calls 仅 Some 覆盖,None 不动)。
let id = tc.id.map(|raw| tool_call_id_or_fallback(&raw, tc.index as usize, "gen_stream"));
ToolCallDelta {
index: tc.index, index: tc.index,
id, id: tc.id,
function_name: tc.function.as_ref().and_then(|f| f.name.clone()), function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
function_arguments: tc.function.and_then(|f| f.arguments), function_arguments: tc.function.and_then(|f| f.arguments),
}
}) })
.collect() .collect()
}); });
@@ -379,23 +200,19 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
delta: delta_text, delta: delta_text,
finished, finished,
tool_calls, tool_calls,
// 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上, usage: None,
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: choice.delta.reasoning_content, reasoning_content: choice.delta.reasoning_content,
truncated_by_max_tokens,
} }
} else { } else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出 // choices 为空 = usage-only chunk,不输出文本(usage 已累积)
StreamChunk { StreamChunk {
delta: String::new(), delta: String::new(),
finished: false, finished: false,
tool_calls: None, tool_calls: None,
usage: usage_accum.clone(), usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
} }
} }
} }
@@ -408,135 +225,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
} }
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// DeepSeek 扁平口径:hit/miss 各取其值,不参与嵌套回填
#[test]
fn usage_deepseek_flat_cache() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}"#,
)
.unwrap();
assert_eq!(u.prompt_tokens, 100);
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// OpenAI/GLM 嵌套口径:扁平未报,hit 回填自 cached_tokens,miss 推断为 prompt - hit
#[test]
fn usage_nested_cached_tokens_backfill() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_tokens_details":{"cached_tokens":64}}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// 两种口径都没有:hit/miss 保持 0(前端不渲染 cache 段)
#[test]
fn usage_no_cache_fields() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 0);
assert_eq!(u.prompt_cache_miss_tokens, 0);
}
/// 端到端:usage-only chunk 经 apply_openai_sse 累加路径带出归一后的缓存分项
#[test]
fn sse_usage_chunk_nested_cache_reaches_token_usage() {
let data = r#"{"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,
"total_tokens":110,"prompt_tokens_details":{"cached_tokens":64}}}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
let usage = chunk.usage.expect("usage chunk 应携带 usage");
assert_eq!(usage.prompt_cache_hit_tokens, 64);
assert_eq!(usage.prompt_cache_miss_tokens, 36);
}
/// 部分兼容网关 usage 只报部分字段:缺省 0 入累加器,同帧 content 不再因整帧
/// serde 失败被吞(旧行为:必填字段缺失 → 空 chunk → 文本蒸发 + usage 归零无痕)
#[test]
fn sse_partial_usage_frame_parses_with_defaults() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"content":"hi"}}],"usage":{"total_tokens":10}}"#;
let c = apply_openai_sse(data, &mut accum);
assert_eq!(c.delta, "hi", "usage 缺分项不应令整帧解析失败丢 content");
let u = accum.expect("部分 usage 应写入累加器");
assert_eq!(u.total_tokens, 10);
assert_eq!(u.prompt_tokens, 0, "缺省分项按 0 兜底");
assert_eq!(u.completion_tokens, 0);
}
/// 同帧多 tool_call 增量缺 index:缺省 0 触发 index 冲突路径(仅 warn 留痕,不改写
/// 缺省策略);解析本身不失败,增量仍逐条带出。
#[test]
fn sse_tool_calls_missing_index_in_multi_call_frame_still_parse() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[
{"id":"call_a","function":{"name":"ta","arguments":"{"}},
{"id":"call_b","function":{"name":"tb","arguments":"}"}}
]}}]}"#;
let c = apply_openai_sse(data, &mut accum);
let tcs = c.tool_calls.expect("缺 index 的多 call 帧应可解析");
assert_eq!(tcs.len(), 2);
assert!(tcs.iter().all(|tc| tc.index == 0), "缺 index 缺省 0");
}
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
#[test]
fn finish_reason_content_filter_is_terminal() {
let data = r#"{"choices":[{"delta":{"content":""},"finish_reason":"content_filter"}]}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
assert!(chunk.finished, "content_filter 应收敛为终态,不再悬挂");
let mut accum2 = None;
let chunk2 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
&mut accum2,
);
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结");
}
/// finish_reason=length:max_tokens 截断仍按正常终止收敛(finished=true),
/// 但截断标志必须随 chunk 带出(零信号会让腰斩报告冒充完整完成);
/// stop 等正常终态不置标志。
#[test]
fn finish_reason_length_carries_truncation_flag() {
let mut accum = None;
let chunk = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"报告写到一半"},"finish_reason":"length"}]}"#,
&mut accum,
);
assert!(chunk.finished, "length 属正常终止");
assert!(chunk.truncated_by_max_tokens, "length 必须带截断标志");
let mut accum2 = None;
let chunk2 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}"#,
&mut accum2,
);
assert!(chunk2.finished && !chunk2.truncated_by_max_tokens, "stop 不置截断标志");
let mut accum3 = None;
let chunk3 = apply_openai_sse(
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
&mut accum3,
);
assert!(!chunk3.truncated_by_max_tokens, "普通 delta 不置截断标志");
}
}
-385
View File
@@ -1,385 +0,0 @@
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
//!
//! 构成 AI Native 的基础:角色划分是分工的前提。
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
//!
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
use std::collections::HashMap;
/// 内置人设 ID 常量
pub const PERSONA_CODER: &str = "coder";
pub const PERSONA_REVIEWER: &str = "reviewer";
pub const PERSONA_ARCHITECT: &str = "architect";
pub const PERSONA_TESTER: &str = "tester";
pub const PERSONA_ANALYST: &str = "analyst";
/// 人设能力标签枚举
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PersonaCapability {
/// 代码生成与修改
CodeGeneration,
/// Code Review
CodeReview,
/// 架构设计与分析
ArchitectureDesign,
/// 测试编写与执行
Testing,
/// 数据分析与搜索
Analysis,
/// 文件读写
FileOperation,
/// 命令执行
CommandExecution,
/// Git 操作
GitOperation,
/// 知识库操作
KnowledgeOperation,
/// 项目管理
ProjectManagement,
}
impl PersonaCapability {
pub fn label(&self) -> &'static str {
match self {
Self::CodeGeneration => "代码生成",
Self::CodeReview => "代码审查",
Self::ArchitectureDesign => "架构设计",
Self::Testing => "测试",
Self::Analysis => "分析",
Self::FileOperation => "文件操作",
Self::CommandExecution => "命令执行",
Self::GitOperation => "Git 操作",
Self::KnowledgeOperation => "知识库",
Self::ProjectManagement => "项目管理",
}
}
}
/// 人设定义
#[derive(Debug, Clone)]
pub struct AgentPersona {
/// 唯一标识(如 "coder", "reviewer")
pub id: String,
/// 人设名称(如 "程序员", "审查员")
pub name: String,
/// 人设描述(LLM 理解用)
pub description: String,
/// 能力标签集
pub capabilities: Vec<PersonaCapability>,
/// 工具白名单(空=不限,非空=仅允许列出的工具)
pub tool_whitelist: Vec<String>,
/// system prompt 模板(注入 {context} {goals} 等占位符)
pub system_prompt_template: String,
/// 优先级(选人设时冲突排名,0=最高)
pub priority: u8,
}
impl AgentPersona {
/// 检查此人设是否具备某项能力
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
self.capabilities.contains(cap)
}
/// 检查某工具是否在此人设的 whitelist 中
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
}
/// 构建带上下文的 system prompt
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
let goals_text = if goals.is_empty() {
String::new()
} else {
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
};
self.system_prompt_template
.replace("{context}", context)
.replace("{goals}", &goals_text)
}
}
/// 人设注册表 — 管理内置 + 自定义人设
#[derive(Debug, Clone)]
pub struct PersonaRegistry {
personae: HashMap<String, AgentPersona>,
/// 默认人设 id(兜底)
default_id: String,
}
impl PersonaRegistry {
/// 构造默认注册表(含 5 内置人设)
pub fn new() -> Self {
let mut reg = Self {
personae: HashMap::new(),
default_id: PERSONA_CODER.to_string(),
};
reg.register(Self::builtin_coder());
reg.register(Self::builtin_reviewer());
reg.register(Self::builtin_architect());
reg.register(Self::builtin_tester());
reg.register(Self::builtin_analyst());
reg
}
// ── 5 内置人设 ──
fn builtin_coder() -> AgentPersona {
AgentPersona {
id: PERSONA_CODER.to_string(),
name: "程序员".to_string(),
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能,不关注大范围架构变更".to_string(),
capabilities: vec![
PersonaCapability::CodeGeneration,
PersonaCapability::FileOperation,
PersonaCapability::CommandExecution,
],
tool_whitelist: vec![], // 不限(全部工具可用)
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
priority: 1,
}
}
fn builtin_reviewer() -> AgentPersona {
AgentPersona {
id: PERSONA_REVIEWER.to_string(),
name: "审查员".to_string(),
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性,返回审查意见不直接改代码".to_string(),
capabilities: vec![
PersonaCapability::CodeReview,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"git_diff".into(), "git_log".into(), "git_status".into(),
],
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_architect() -> AgentPersona {
AgentPersona {
id: PERSONA_ARCHITECT.to_string(),
name: "架构师".to_string(),
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
capabilities: vec![
PersonaCapability::ArchitectureDesign,
PersonaCapability::Analysis,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "list_directory".into(),
"write_file".into(), // 写架构文档
],
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
priority: 2,
}
}
fn builtin_tester() -> AgentPersona {
AgentPersona {
id: PERSONA_TESTER.to_string(),
name: "测试员".to_string(),
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
capabilities: vec![
PersonaCapability::Testing,
PersonaCapability::CommandExecution,
PersonaCapability::FileOperation,
],
tool_whitelist: vec![], // 不限
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
priority: 3,
}
}
fn builtin_analyst() -> AgentPersona {
AgentPersona {
id: PERSONA_ANALYST.to_string(),
name: "分析师".to_string(),
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
capabilities: vec![
PersonaCapability::Analysis,
PersonaCapability::CommandExecution,
PersonaCapability::KnowledgeOperation,
],
tool_whitelist: vec![
"read_file".into(), "search_files".into(), "grep_search".into(),
"run_command".into(), "git_log".into(),
"knowledge_search".into(), "knowledge_inject".into(),
],
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
priority: 4,
}
}
// ── 注册表操作 ──
/// 注册/覆盖人设
pub fn register(&mut self, persona: AgentPersona) {
self.personae.insert(persona.id.clone(), persona);
}
/// 按 id 查人设
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
self.personae.get(id)
}
/// 取默认人设
pub fn default(&self) -> &AgentPersona {
self.personae.get(&self.default_id).expect("默认人设必存在")
}
/// 设置默认人设 id
pub fn set_default(&mut self, id: &str) {
if self.personae.contains_key(id) {
self.default_id = id.to_string();
}
}
/// 按意图推荐人设(启发式规则)
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
let lower = intent.to_lowercase();
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
} else if lower.contains("测试") || lower.contains("test") {
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
} else {
// 默认走 coder(代码生成是最常见场景)
self.default()
}
}
/// 获取全部人设列表
pub fn all(&self) -> Vec<&AgentPersona> {
let mut list: Vec<_> = self.personae.values().collect();
list.sort_by_key(|p| p.priority);
list
}
/// 人设数量
pub fn len(&self) -> usize {
self.personae.len()
}
pub fn is_empty(&self) -> bool {
self.personae.is_empty()
}
}
impl Default for PersonaRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_5_builtin() {
let reg = PersonaRegistry::new();
assert_eq!(reg.len(), 5);
assert!(reg.get(PERSONA_CODER).is_some());
assert!(reg.get(PERSONA_REVIEWER).is_some());
assert!(reg.get(PERSONA_ARCHITECT).is_some());
assert!(reg.get(PERSONA_TESTER).is_some());
assert!(reg.get(PERSONA_ANALYST).is_some());
}
#[test]
fn default_is_coder() {
let reg = PersonaRegistry::new();
assert_eq!(reg.default().id, PERSONA_CODER);
}
#[test]
fn recommend_reviewer() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("review the code changes");
assert_eq!(p.id, PERSONA_REVIEWER);
}
#[test]
fn recommend_architect() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("设计新的模块架构");
assert_eq!(p.id, PERSONA_ARCHITECT);
}
#[test]
fn recommend_tester() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("write tests for this module");
assert_eq!(p.id, PERSONA_TESTER);
}
#[test]
fn recommend_default_to_coder() {
let reg = PersonaRegistry::new();
let p = reg.recommend_for_intent("implement a new feature");
assert_eq!(p.id, PERSONA_CODER);
}
#[test]
fn tool_whitelist_filters() {
let reviewer = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec!["read_file".into(), "search_files".into()],
system_prompt_template: "".into(),
priority: 5,
};
assert!(reviewer.is_tool_allowed("read_file"));
assert!(!reviewer.is_tool_allowed("write_file"));
}
#[test]
fn empty_whitelist_allows_all() {
let coder = AgentPersona {
id: "test".into(),
name: "test".into(),
description: "".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "".into(),
priority: 5,
};
assert!(coder.is_tool_allowed("run_command"));
assert!(coder.is_tool_allowed("write_file"));
}
#[test]
fn build_prompt_replaces_placeholders() {
let reg = PersonaRegistry::new();
let coder = reg.get(PERSONA_CODER).unwrap();
let prompt = coder.build_prompt("some context", &["goal1".into()]);
assert!(prompt.contains("程序员"));
assert!(prompt.contains("goal1"));
assert!(prompt.contains("some context"));
}
#[test]
fn register_custom_persona() {
let mut reg = PersonaRegistry::new();
let custom = AgentPersona {
id: "custom".into(),
name: "自定义".into(),
description: "test".into(),
capabilities: vec![],
tool_whitelist: vec![],
system_prompt_template: "You are custom".into(),
priority: 0,
};
reg.register(custom);
assert_eq!(reg.len(), 6);
assert!(reg.get("custom").is_some());
}
}
-200
View File
@@ -1,200 +0,0 @@
//! Plan 执行器(Phase 2 · DAG 分层调度)
//!
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
//! - 层间串行:上层全部完成才进下一层(deps 保证)
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行(设计目标;
//! 当前为串行循环模拟,见 execute 内 TODO,本执行器未接生产链路)
//!
//! **当前状态**:骨架实现,PLAN_EXECUTION_ENABLED 门控(默认关)。
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
//!
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
use std::sync::Arc;
use crate::planner::{Plan, SubTask};
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
///
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
/// 默认关。现状:PlanExecutor::execute 零生产调用点(仅测试),本执行器未接入主链;
/// agentic loop 读本开关走的是 coordinator::dispatch(Phase 1 串行桩),与本文件无关。
/// JoinSet 真并行留 Phase 3 对接时接通(见 execute 内 TODO)。
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
use std::sync::atomic::{AtomicBool, Ordering};
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
/// 设置 Plan 执行开关。
pub fn set_plan_execution(enabled: bool) {
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
}
/// 读取 Plan 执行开关。
pub fn plan_execution_enabled() -> bool {
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
}
/// 单个 SubTask 的执行结果。
#[derive(Debug, Clone)]
pub struct SubTaskResult {
/// 对应 SubTask id
pub id: String,
/// 执行是否成功
pub success: bool,
/// 执行产出(工具结果摘要 / LLM 回复)
pub output: String,
}
/// Plan 执行器:按 DAG 层级调度子任务。
///
/// 泛型参数 `F` 为子任务执行函数:
/// - 输入:`&SubTask`(子任务定义)
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
///
/// 调用方(Phase 3 对接 agentic loop 时)传入真实的执行闭包,
/// 每个 SubTask 独立跑一轮 agentic loop,共享父上下文快照。
pub struct PlanExecutor;
impl PlanExecutor {
/// 按 Plan DAG 分层执行。
///
/// - 层间串行:上层全部完成才进下一层
/// - 层内并行:同层 SubTask 用 JoinSet 并发
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
/// 若需中断策略,调用方据返回结果自行判断。
///
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
pub async fn execute<F, Fut>(
plan: &Plan,
mut run_subtask: F,
) -> anyhow::Result<Vec<SubTaskResult>>
where
F: FnMut(Arc<SubTask>, String) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<String>>,
{
let layers = plan
.to_layers()
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
let mut results: Vec<SubTaskResult> = Vec::new();
let mut parent_summary = String::new();
for (layer_idx, layer) in layers.iter().enumerate() {
tracing::info!(
layer_idx,
task_count = layer.len(),
"[plan-exec] 执行第 {} 层, {} 个子任务",
layer_idx,
layer.len()
);
let summary_clone = parent_summary.clone();
for task in layer {
let task = Arc::new(task.clone());
let summary = summary_clone.clone();
// TODO(Phase 3 真并行): 当前为串行循环模拟并行(假并行),仅验证分层逻辑。
// run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用);
// 接真并行的改法:run_subtask 换 Arc<Fn> 或 channel 模式,
// 本层全部 spawn 进 JoinSet 统一收集后再汇总 parent_summary,替换此串行 await。
// 现状无用户可见影响:本执行器未接生产链路(execute 零调用点)。
let result = run_subtask(task.clone(), summary).await;
let sr = match result {
Ok(output) => SubTaskResult {
id: task.id.clone(),
success: true,
output,
},
Err(e) => SubTaskResult {
id: task.id.clone(),
success: false,
output: e.to_string(),
},
};
results.push(sr);
}
// 汇总本层结果作为下层 parent_summary
let layer_summary: Vec<String> = results
.iter()
.filter(|r| layer.iter().any(|t| t.id == r.id))
.map(|r| format!("[{}] {}", r.id, r.output))
.collect();
parent_summary = layer_summary.join("\n");
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::planner::{Plan, SubTask};
#[tokio::test]
async fn execute_simple_linear_plan() {
// a → b → c (三层,各一个任务)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.success));
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
}
#[tokio::test]
async fn execute_parallel_layer() {
// a → {b, c} → d (三层,第二层两个并行)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 4);
assert!(results.iter().all(|r| r.success));
}
#[tokio::test]
async fn execute_with_failure_continues() {
// a(fail) → b: a 失败,b 仍执行(容错策略)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
if task.id == "a" {
anyhow::bail!("task-a failed")
}
Ok("ok".to_string())
})
.await
.unwrap();
assert_eq!(results.len(), 2);
assert!(!results[0].success); // a 失败
assert!(results[1].success); // b 仍执行
}
}
+13 -101
View File
@@ -5,7 +5,7 @@
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。 //! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
//! //!
//! 策略: //! 策略:
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter //! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误 //! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错 //! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合 //! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
@@ -20,7 +20,7 @@ use std::time::Duration;
use rand::Rng; use rand::Rng;
use tracing::warn; use tracing::warn;
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。 /// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
/// ///
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关 /// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。 /// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
@@ -34,18 +34,13 @@ const BASE_BACKOFF_SECS: u64 = 1;
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。 /// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// 单次退避基准上限(毫秒),与 [`MAX_TOTAL_BUDGET`] 同口径: 超预算的退避没有意义,
/// attempt 走深时不再指数膨胀。与总预算的一致性由测试 `backoff_cap_matches_budget` 守住。
/// (同构先例: df-tunnel 的 backoff_delay 也以 RECONNECT_MAX 封顶。)
const MAX_BACKOFF_MS: u64 = 30_000;
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。 /// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。 /// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0; const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。 /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
/// ///
/// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。 /// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
#[derive(Debug)] #[derive(Debug)]
pub enum AttemptOutcome<T> { pub enum AttemptOutcome<T> {
/// 成功,携带结果。 /// 成功,携带结果。
@@ -65,50 +60,25 @@ pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。 /// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
/// ///
/// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。 /// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
pub fn is_status_retryable(status: u16) -> bool { pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status) status == 429 || (500..600).contains(&status)
} }
/// 退避倍率 `2^(attempt-1)`,位移溢出安全。
///
/// 替代裸 `1u64 << (attempt - 1)`: attempt > 64 时位移越界(debug panic / release 环绕)、
/// attempt = 0 时下溢 panic。现 attempt = 0 视作 1(倍率 1),attempt - 1 ≥ 64 时
/// checked_shl 返回 None,倍率封顶 u64::MAX,最终时长由 [`MAX_BACKOFF_MS`] 兜底。
/// 全输入无 panic,序列单调不减。
fn backoff_multiplier(attempt: u32) -> u64 {
match 1u64.checked_shl(attempt.saturating_sub(1)) {
Some(m) => m,
None => u64::MAX,
}
}
/// 退避基准毫秒(未加 jitter),`BASE_BACKOFF_SECS * 2^(attempt-1) * 1000`,封顶 [`MAX_BACKOFF_MS`]。
///
/// 乘法全程 saturating: 倍率封顶 u64::MAX 后 `* 1000` 也会二次溢出,不能裸乘。
/// 单一事实源——backoff_delay 与单测的 base 断言都走这里,不再各写一份公式。
fn base_backoff_ms(attempt: u32) -> u64 {
BASE_BACKOFF_SECS
.saturating_mul(backoff_multiplier(attempt))
.saturating_mul(1000)
.min(MAX_BACKOFF_MS)
}
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。 /// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。 /// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
/// ///
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。 /// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
/// ///
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用 /// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。 /// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration { pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = base_backoff_ms(attempt) as f64; let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// ±50% jitter,相对 base 时长的浮动比例 // ±50% jitter,相对 base 时长的浮动比例
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5) let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
let factor = 1.0 + jitter_ratio * JITTER_RATIO; let factor = 1.0 + jitter_ratio * JITTER_RATIO;
let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64; let ms = (base_ms as f64 * factor).max(0.0) as u64;
Duration::from_millis(ms) Duration::from_millis(ms)
} }
@@ -121,7 +91,7 @@ pub fn backoff_delay(attempt: u32) -> Duration {
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder), /// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。 /// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
/// ///
/// 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数 /// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用 /// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
/// backoff_delay + is_status_retryable)。 /// backoff_delay + is_status_retryable)。
pub(crate) async fn retry_with_backoff<T, F, Fut>( pub(crate) async fn retry_with_backoff<T, F, Fut>(
@@ -190,12 +160,12 @@ mod tests {
#[test] #[test]
fn backoff_is_monotonic_within_jitter() { fn backoff_is_monotonic_within_jitter() {
// base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围 // base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围
for attempt in 1..=3 { for attempt in 1..=3 {
let d = backoff_delay(attempt); let d = backoff_delay(attempt);
let base_ms = base_backoff_ms(attempt); let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000;
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64; let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64; let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64;
assert!( assert!(
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi, d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
"attempt={} delay={:?} 应在 [{},{}]ms", "attempt={} delay={:?} 应在 [{},{}]ms",
@@ -207,64 +177,6 @@ mod tests {
} }
} }
/// 倍率溢出安全: attempt=0 不下溢,≥65 封顶 u64::MAX,序列单调不减
#[test]
fn backoff_multiplier_extremes_safe() {
assert_eq!(backoff_multiplier(0), 1, "attempt=0 视作 1,不下溢 panic");
assert_eq!(backoff_multiplier(1), 1);
assert_eq!(backoff_multiplier(2), 2);
assert_eq!(backoff_multiplier(3), 4);
assert_eq!(backoff_multiplier(63), 1u64 << 62);
assert_eq!(backoff_multiplier(64), 1u64 << 63, "u64 可表达的最大位移");
assert_eq!(backoff_multiplier(65), u64::MAX, "checked_shl 越界封顶");
assert_eq!(backoff_multiplier(100), u64::MAX);
assert_eq!(backoff_multiplier(u32::MAX), u64::MAX);
let seq: Vec<u64> = [0u32, 1, 2, 3, 10, 63, 64, 65, 100, u32::MAX]
.iter()
.map(|&a| backoff_multiplier(a))
.collect();
assert!(seq.windows(2).all(|w| w[0] <= w[1]), "倍率应单调不减: {:?}", seq);
}
/// 基准毫秒单调不减、封顶值正确(2^5×1s=32s 已超 30s 预算,从 attempt=6 起封顶)
#[test]
fn base_backoff_ms_monotonic_and_capped() {
let mut prev = 0u64;
for attempt in [0u32, 1, 2, 3, 4, 5, 6, 10, 63, 64, 65, 100, u32::MAX] {
let b = base_backoff_ms(attempt);
assert!(b >= prev, "attempt={} base_ms={} 应 ≥ 前值 {}", attempt, b, prev);
assert!(b <= MAX_BACKOFF_MS, "attempt={} base_ms={} 应 ≤ {}", attempt, b, MAX_BACKOFF_MS);
prev = b;
}
assert_eq!(base_backoff_ms(1), 1000);
assert_eq!(base_backoff_ms(2), 2000);
assert_eq!(base_backoff_ms(3), 4000);
assert_eq!(base_backoff_ms(5), 16_000, "未触及封顶");
assert_eq!(base_backoff_ms(6), MAX_BACKOFF_MS, "32s 超预算封顶 30s");
assert_eq!(base_backoff_ms(u32::MAX), MAX_BACKOFF_MS);
}
/// 极端 attempt 下 backoff_delay 不 panic(修复: 1u64<<(attempt-1) 曾 debug panic/release 环绕)
#[test]
fn backoff_delay_extreme_attempts_no_panic_and_capped() {
for attempt in [0u32, 1, 10, 63, 64, 65, 100, u32::MAX] {
let ms = backoff_delay(attempt).as_millis() as u64;
assert!(
ms <= MAX_BACKOFF_MS,
"attempt={} 实际 {}ms 应 ≤ {}ms(含 jitter 后仍不超)",
attempt,
ms,
MAX_BACKOFF_MS
);
}
}
/// 封顶常量与总预算同口径(改预算时两处同步)
#[test]
fn backoff_cap_matches_budget() {
assert_eq!(MAX_BACKOFF_MS, MAX_TOTAL_BUDGET.as_millis() as u64);
}
#[test] #[test]
fn status_classification() { fn status_classification() {
assert!(is_status_retryable(429)); assert!(is_status_retryable(429));
+33 -241
View File
@@ -1,45 +1,37 @@
//! 模型路由器。 //! 模型路由器 — F-01 阶段4
//! //!
//! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs / //! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! df-ideas / df-nodes ai_node.rs)。 //! df-ideas / df-nodes ai_node.rs)。
//! //!
//! ModelRouter 为单元结构,select 是无状态关联函数。 //! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}` // 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点 // 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。 // 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API // 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。 // 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
// ModelTier 从 crate::intent re-export(同 crate,无跨 crate 路径问题),供调用点构造
// `tier: suggested_model_tier(&intent)` 传入,router 同 weight 时按 tier tiebreak。
pub use crate::intent::ModelTier;
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig}; pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(4 维度)。 /// 任务对模型的需求(3 维度)。
/// ///
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文/档位, /// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。 /// 交 ModelRouter::select 在候选池中选最优模型。
/// ///
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。 /// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 + /// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier) /// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。 /// 保留供未来出现真实判别源时再接回。
///
/// `tier`(子项 2 根因修复):任务建议的模型档位(由 `intent::suggested_model_tier` 派生,
/// 或无意图场景传 None)。原 `max_by_key(weight)` 同 weight 返最后一个,顺序敏感无语义;
/// 接 tier 后,同 weight 时优先选 `intelligence` 满足 tier 下限的候选(见 `tier_match`)。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TaskRequirements { pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里) /// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>, pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse) /// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool, pub needs_tool_use: bool,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)。 /// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
/// 调用点应传 TokenEstimator 估值而非 0(0 = 当前空操作,窗口过滤维度失效)。
pub estimated_context: usize, pub estimated_context: usize,
/// 任务建议的模型档位(意图→ModelTier,无意图场景 None)。
/// 同 weight 候选间按 tier tiebreak(满足 tier 下限的候选胜)。
pub tier: Option<ModelTier>,
} }
/// 模型路由器(单元结构,无状态)。 /// 模型路由器(单元结构,无状态)。
@@ -47,33 +39,6 @@ pub struct TaskRequirements {
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。 /// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
pub struct ModelRouter; pub struct ModelRouter;
/// ModelTier → IntelligenceTier 下限映射(子项 2 tier tiebreak 用)。
///
/// 任务建议档位(ModelTier:F-Heavy)映射到模型智力下限(IntelligenceTier),
/// 同 weight 候选间优先选 `model.intelligence >= 下限` 的(满足任务复杂度需求)。
/// - `Fast` → `Lite`(轻量意图,任何模型都满足)
/// - `Standard` → `Standard`(日常,需 Standard 及以上)
/// - `Heavy` → `Plus`(复杂推理,需 Plus 及以上)
fn tier_min_intelligence(tier: ModelTier) -> IntelligenceTier {
match tier {
ModelTier::Fast => IntelligenceTier::Lite,
ModelTier::Standard => IntelligenceTier::Standard,
ModelTier::Heavy => IntelligenceTier::Plus,
}
}
/// 同 weight tiebreak:候选是否满足任务建议档位的智力下限。
///
/// 返 `bool`(满足 = true)。调用方在 `max_by` 闭包内 `a_match.cmp(&b_match)` 把 bool 转 Ordering:
/// a 满足而 b 不满足 → Greater(a 胜);都满足/都不满足 → Equal(max_by 并列返最后一个)。
/// - `req.tier = None`(无意图场景,标题/扫描/压缩):恒 true(所有候选等价,保留旧行为)。
/// - `req.tier = Some(t)`:返 `model_intel >= tier_min_intelligence(t)`。
fn tier_match(model_intel: IntelligenceTier, req_tier: Option<ModelTier>) -> bool {
req_tier
.map(|t| model_intel >= tier_min_intelligence(t))
.unwrap_or(true) // None → 视作满足(tiebreak 维度不参与,保旧行为)
}
impl ModelRouter { impl ModelRouter {
/// 在候选池中选出最优模型(过滤链)。 /// 在候选池中选出最优模型(过滤链)。
/// ///
@@ -82,42 +47,23 @@ impl ModelRouter {
/// 2. 模态匹配 — 任务所需模态全在模型模态里 /// 2. 模态匹配 — 任务所需模态全在模型模态里
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse /// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
/// 4. 窗口够大 — context_window >= estimated_context /// 4. 窗口够大 — context_window >= estimated_context
/// 5. max_by 选最优:**主键 weight 降序**(权重高者胜),**同 weight 时按 tier tiebreak** /// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
/// (满足任务建议档位 `intelligence >= tier_min` 的候选胜)。
/// ///
/// tier tiebreak(子项 2 根因修复):原 `max_by_key(weight)` 同 weight 返最后一个, /// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
/// 顺序敏感无语义(intent suggested_model_tier 恒 None)→ 现接 `req.tier` /// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
/// (由 intent→ModelTier 派生),同 weight 时优先选满足档位下限的候选。 /// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
/// `req.tier = None` 时 tiebreak 维度退化为等价(保留旧行为,标题/扫描路径无回归)。 /// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
///
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序主键 weight 主导,
/// tiebreak 由 tier(基于 intelligence,有客观档位映射依据)替代纯 max_by_key 顺序。
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> { pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
pool.iter() pool.iter()
.filter(|m| m.enabled) // 1. 只选启用的 .filter(|m| m.enabled) // 1. 只选启用的
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配 .filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配 .filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大 .filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
// 5. 主键 weight 降序,同 weight 时 tier tiebreak(满足档位下限的候选胜)。 .max_by_key(|m| m.weight) // 5. 纯 weight 主导
// max_by 语义:comparator 返 a 相对 b 的 Ordering,Greater = a 胜;
// 同 key(全 Equal)时 max_by 返最后一个(对齐原 max_by_key 并列返最后的语义)。
.max_by(|a, b| {
// 主键:weight,a 大则 a 胜(Greater)。
let by_weight = a.weight.cmp(&b.weight);
if by_weight != std::cmp::Ordering::Equal {
return by_weight;
}
// tiebreak:tier 满足度。a 满足档位下限而 b 不满足 → a 胜(Greater)。
// tier_match 返 bool,bool 比较:true > false(满足 > 不满足)。
let a_match = tier_match(a.intelligence, req.tier);
let b_match = tier_match(b.intelligence, req.tier);
a_match.cmp(&b_match)
})
} }
} }
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。 /// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
/// ///
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。 /// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)` /// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
@@ -131,22 +77,6 @@ pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<S
ModelRouter::select(req, pool).map(|m| m.model_id.clone()) ModelRouter::select(req, pool).map(|m| m.model_id.clone())
} }
/// 判断 model_id 是否为图像生成模型(文本任务不应使用)。
/// 用于 select 返回 None 兜底 default_model 时的防御:避免标题/压缩等文本任务
/// 误用图像模型(如 sensenova-u1-fast/dall-e)致 LLM 404 刷屏(治 default_model 配错为图像模型)。
/// 判断:pool 有该模型 → 校验模态是否含 Text(最可靠);pool 无 → 名字启发式(已知图像模型模式)。
pub fn is_likely_image_model(model_id: &str, pool: &[ModelConfig]) -> bool {
if let Some(m) = pool.iter().find(|m| m.model_id == model_id) {
return !m.modalities.contains(&Modality::Text);
}
let lower = model_id.to_lowercase();
const IMAGE_PATTERNS: &[&str] = &[
"dall-e", "gpt-image", "imagen", "flux", "stable-diffusion",
"sensenova-u1", "u1-fast", "midjourney", "sd-", "sdxl",
];
IMAGE_PATTERNS.iter().any(|p| lower.contains(p))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -163,22 +93,20 @@ mod tests {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。tier=None 保留旧行为。 /// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。
fn req() -> TaskRequirements { fn req() -> TaskRequirements {
TaskRequirements { TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
estimated_context: 0, estimated_context: 0,
tier: None,
} }
} }
// ── select_model_id helper ── // ── 阶段5 select_model_id helper ──
#[test] #[test]
fn select_model_id_empty_pool_returns_none() { fn select_model_id_empty_pool_returns_none() {
@@ -320,7 +248,7 @@ mod tests {
); );
} }
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ── // ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
#[test] #[test]
fn context_window_insufficient() { fn context_window_insufficient() {
@@ -333,36 +261,7 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none()); assert!(ModelRouter::select(&r, &pool).is_none());
} }
#[test] // ── 步骤 5:max_by_key (纯 weight) ──
fn estimated_context_filters_small_window_model() {
// 子项 1 根因修复回归测:estimated_context 非零(调用点传 TokenEstimator 估值,非死代码 0)
// → 步骤 4 窗口过滤生效。两候选:小窗口(4K)weight 90(高诱惑)+ 大窗口(128K)weight 50。
// 任务预估 8K 上下文 → 小窗口模型被滤,只剩大窗口候选胜(即使 weight 低)。
// 若调用点回退传 0(原 bug),两候选窗口都 >= 0,weight 90 的小窗口模型会胜(误选)。
let pool = vec![
ModelConfig {
weight: 90,
context_window: 4096, // 小窗口,高 weight 诱惑
..model("small-window-heavy")
},
ModelConfig {
weight: 50,
context_window: 131072, // 大窗口,低 weight
..model("large-window-light")
},
];
let r = TaskRequirements {
estimated_context: 8000, // 任务预估 8K,小窗口模型装不下
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"large-window-light",
"estimated_context 非零应滤掉小窗口候选,即使其 weight 更高"
);
}
// ── 步骤 5:max_by(weight 主键,tier tiebreak) ──
#[test] #[test]
fn single_match_returns_it() { fn single_match_returns_it() {
@@ -394,9 +293,8 @@ mod tests {
#[test] #[test]
fn same_weight_picks_first_match() { fn same_weight_picks_first_match() {
// 同 weight 70,tier=None(req() 默认):tiebreak 维度退等价,max_by 遇并列返最后一个 // 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
// (rust Iterator::max_by 语义,与原 max_by_key 一致)。验证同 weight + tier=None // 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
// 不再按 cost 取舍,行为对齐接入 tier tiebreak 前的语义(标题/扫描路径无回归)。
let pool = vec![ let pool = vec![
ModelConfig { ModelConfig {
weight: 70, weight: 70,
@@ -417,17 +315,17 @@ mod tests {
#[test] #[test]
fn all_dimensions_match_picks_best() { fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证过滤链全过 + max_by(weight, tier) 选最优。 // 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
// (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉) // (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
// //
// 候选: // 候选:
// a: weight 60 → 通过全部过滤 // a: weight 60 → 通过全部过滤,key=60
// b: weight 80 → 通过 — weight 次高档(与 c 并列,但 tier=None 故 tiebreak 退等价) // b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
// c: weight 80 → 通过 — 同 weight 80,tier=None 时 max_by 返并列最后一个 // c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
// d: weight 90 → 通过(intelligence 不参与过滤)— weight 最高,胜 // d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
// e: enabled=false → 步骤 1 滤掉 // e: enabled=false → 步骤 1 滤掉
// //
// 预期:d 胜(weight 90 最高,tier tiebreak 不触发因 weight 已决出胜负) // 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
let pool = vec![ let pool = vec![
ModelConfig { ModelConfig {
weight: 60, weight: 60,
@@ -463,113 +361,7 @@ mod tests {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: true, needs_tool_use: true,
estimated_context: 0, estimated_context: 0,
tier: None,
}; };
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d"); assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
} }
// ── 步骤 5 tiebreak(子项 2):同 weight 时 tier 决胜 ──
#[test]
fn tier_tiebreak_heavy_prefers_meeting_model() {
// 子项 2 根因修复:同 weight 时,任务建议 Heavy(req.tier=Some(Heavy))→ tier_min=Plus,
// 满足 intelligence>=Plus 的候选胜过不满足的。
// 候选 a:Standard(不满足 Plus),候选 b:Plus(满足),同 weight 50。
// 预期:b 胜(满足 Heavy 档位下限)。原 max_by_key 会返最后一个(顺序敏感无语义)。
let pool = vec![
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Standard,
..model("a_standard")
},
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Plus,
..model("b_plus")
},
];
let r = TaskRequirements {
tier: Some(ModelTier::Heavy),
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"b_plus",
"同 weight 时 Heavy 档位应优先选 Plus(满足)而非 Standard(不满足)"
);
}
#[test]
fn tier_tiebreak_none_preserves_max_by_key_semantics() {
// tier=None(标题/扫描/压缩无意图场景)→ tiebreak 维度退等价,
// max_by 同 key 返最后一个(对齐原 max_by_key 行为,无回归)。
// 候选 a/b 同 weight 70,顺序 a 在前 b 在后 → 预期返 b(max_by 并列返最后)。
let pool = vec![
ModelConfig {
weight: 70,
intelligence: IntelligenceTier::Standard,
..model("a")
},
ModelConfig {
weight: 70,
intelligence: IntelligenceTier::Plus,
..model("b")
},
];
// tier=None 时即使 b 的 intelligence 更高也不应胜(tiebreak 不参与),保 max_by_key 语义。
assert_eq!(ModelRouter::select(&req(), &pool).unwrap().model_id, "b");
}
#[test]
fn tier_tiebreak_chat_fast_any_model_meets_lite() {
// 任务建议 Fast → tier_min=Lite,任何模型 intelligence>=Lite(Lite 是最低档)→ 都满足。
// 故 Fast 档位下 tiebreak 退等价(都满足),max_by 同 weight 返最后一个,行为不变。
let pool = vec![
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Lite,
..model("a_lite")
},
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Ultra,
..model("b_ultra")
},
];
let r = TaskRequirements {
tier: Some(ModelTier::Fast),
..req()
};
// 都满足 Lite 下限 → tiebreak 等价 → max_by 返最后一个 = b_ultra
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "b_ultra");
}
#[test]
fn tier_tiebreak_weight_still_dominates() {
// tier 不凌驾 weight:weight 高者永远胜,即使低 weight 候选满足 tier 而高 weight 不满足。
// 候选 a:weight 90,Standard(不满足 Heavy/Plus)。
// 候选 b:weight 50,Plus(满足 Heavy)。
// 预期:a 胜(weight 主键优先,tiebreak 只在 weight 相同时触发)。
let pool = vec![
ModelConfig {
weight: 90,
intelligence: IntelligenceTier::Standard,
..model("a_heavy_weight")
},
ModelConfig {
weight: 50,
intelligence: IntelligenceTier::Plus,
..model("b_meets_tier")
},
];
let r = TaskRequirements {
tier: Some(ModelTier::Heavy),
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"a_heavy_weight",
"weight 主键应凌驾 tier tiebreak"
);
}
} }
-272
View File
@@ -1,272 +0,0 @@
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
//!
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
//!
//! 根因分析:
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
//! 即报错(且不可恢复):
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
//! - 缺少结束的 \n\n(连接断开常见)
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
//!
//! 本解析器实现:
//! - 宽松的 UTF-8 处理(用 bytes 累积,优先 `String::from_utf8` 严格解码保留多字节完整,
//! 失败再降级 `from_utf8_lossy` 不报错)
//! - SSE 协议简单解析(分隔符兼容 `\n\n` / `\r\n\r\n` / `\r\r` 三种行尾归一,
//! data: 前缀提取)
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM,
//! 超限时按事件边界对齐保尾裁剪(丢最旧的超限事件整体),不产出半截破损事件
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
/// SSE 事件流的 data 字段内容
pub type SseEvent = String;
/// 缓冲区字节上限(防御性):正常流会被 parse_events 持续消费,
/// 仅畸形上游(持续不发分隔符)时触发裁剪,避免无界增长 OOM。
const BUF_MAX: usize = 1 << 20; // 1 MiB
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
pub struct SseStream<S> {
inner: S,
buffer: Vec<u8>,
/// 超限裁剪置位:无边界的超限事件已整体丢弃,但其尾部字节仍在后续 chunk 中,
/// 到达收尾 boundary 前的数据须静默吞掉,避免残缺事件体被当完整事件产出
skip_partial: bool,
}
impl<S> SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
pub fn new(inner: S) -> Self {
Self {
inner,
buffer: Vec::with_capacity(8192),
skip_partial: false,
}
}
/// 从 buffer 解析完整的 SSE 事件,返回事件列表。
///
/// 事件分隔符兼容三种行尾归一:
/// - `\n\n` (LF LF,规范形态)
/// - `\r\n\r\n` (CRLF CRLF,部分中转站/反代发送)
/// - `\r\r` (CR CR,极少见但同源)
///
/// 取最早出现的分隔符;事件体内部的 `\r\n` 在解码后归一为 `\n`,
/// 确保 `lines()` / `strip_prefix` 等行级解析正常工作。
///
/// 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
fn parse_events(&mut self) -> Vec<SseEvent> {
// 超限裁剪收尾:被整体丢弃的残缺事件,其后续字节以首个 boundary 收尾,
// 此处静默吞掉不产出,之后的正常事件恢复正常解析
if self.skip_partial {
match Self::find_event_boundary(&self.buffer) {
Some((pos, len)) => {
self.buffer.drain(..pos + len);
self.skip_partial = false;
}
None => return Vec::new(),
}
}
let mut events = Vec::new();
loop {
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
let sep = Self::find_event_boundary(&self.buffer);
let (sep_pos, sep_len) = match sep {
Some(v) => v,
None => break,
};
// 取出事件体 + 分隔符整体(buffer 前 sep_pos+sep_len 字节)
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + sep_len).collect();
// 去掉末尾的分隔符,得到事件体
let body_end = event_bytes.len().saturating_sub(sep_len);
let body_bytes = &event_bytes[..body_end];
// 解码:优先严格 UTF-8(保留多字节完整),失败再降级 lossy(不崩)
let mut event_text = match std::str::from_utf8(body_bytes) {
Ok(s) => s.to_owned(),
Err(_) => String::from_utf8_lossy(body_bytes).into_owned(),
};
// 事件体内 CRLF / 裸 CR 归一为 LF,保证后续行级解析一致
if event_text.contains('\r') {
event_text = event_text.replace("\r\n", "\n").replace('\r', "\n");
}
let data = Self::extract_data_fields(&event_text);
if !data.is_empty() {
events.push(data);
}
}
events
}
/// 在 buffer 中查找最早的事件分隔符,返回 `(起始位置, 分隔符字节长度)`。
/// 扫描 `\r\n\r\n` / `\n\n` / `\r\r` 三种形态,取最小起始位置(取最早分隔符)。
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
// candidates: (pattern, length)
const PATTERNS: &[(&[u8], usize)] = &[
(b"\r\n\r\n", 4),
(b"\n\n", 2),
(b"\r\r", 2),
];
let mut best: Option<(usize, usize)> = None;
for &(pat, len) in PATTERNS {
// windows 匹配;找到首次出现位置
if let Some(pos) = buf.windows(pat.len()).position(|w| w == pat) {
match best {
None => best = Some((pos, len)),
Some((bpos, _)) if pos < bpos => best = Some((pos, len)),
_ => {}
}
}
}
best
}
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
fn extract_data_fields(event_text: &str) -> String {
let mut data_parts: Vec<&str> = Vec::new();
for line in event_text.lines() {
if let Some(rest) = line.strip_prefix("data:") {
let rest = rest.strip_prefix(' ').unwrap_or(rest);
data_parts.push(rest);
}
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
}
data_parts.join("\n")
}
}
impl<S> Stream for SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
type Item = Result<Vec<SseEvent>, String>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
use futures::StreamExt;
loop {
// 先尝试从 buffer 解析完整事件
let events = self.parse_events();
if !events.is_empty() {
return Poll::Ready(Some(Ok(events)));
}
// buffer 不足以解析出完整事件,从 inner 读更多数据
match self.inner.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(chunk))) => {
self.buffer.extend_from_slice(&chunk);
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
// 超限只发生在单个事件体超过 BUF_MAX 的场景(畸形上游不发分隔符,
// 或超大单事件)。按字节盲截头部会把半截事件体留给后续解析,产出
// 破损 JSON 且无法复原;改为对齐事件边界的保尾裁剪:
// - 已有完整分隔符 → 最旧的一个事件(超限源头)整体丢弃,保留的
// 数据从干净的事件起点开始,后续事件解析不受损;
// - 全程无分隔符 → 整体丢弃并置 skip_partial,该事件的收尾字节
// 到达时静默吞掉。
if self.buffer.len() > BUF_MAX {
match Self::find_event_boundary(&self.buffer) {
Some((pos, len)) => {
self.buffer.drain(..pos + len);
}
None => {
self.buffer.clear();
self.skip_partial = true;
}
}
}
continue;
}
Poll::Ready(Some(Err(e))) => {
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
}
Poll::Ready(None) => {
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段);
// skip_partial 置位时剩余数据是被丢弃残缺事件的尾段,不得溢出为事件
if !self.buffer.is_empty() && !self.skip_partial {
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
let mut remaining = match String::from_utf8(self.buffer.clone()) {
Ok(s) => s,
Err(_) => String::from_utf8_lossy(&self.buffer).into_owned(),
};
self.buffer.clear();
// 同主解析路径:CRLF / 裸 CR 归一为 LF
if remaining.contains('\r') {
remaining = remaining.replace("\r\n", "\n").replace('\r', "\n");
}
let data = Self::extract_data_fields(&remaining);
if !data.is_empty() {
return Poll::Ready(Some(Ok(vec![data])));
}
}
return Poll::Ready(None);
}
Poll::Pending => return Poll::Pending,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::stream;
/// 构造只含 Ok chunk 的内存流,跑完整个 SseStream,返回拍平的事件序列
fn run_chunks(chunks: Vec<String>) -> Vec<SseEvent> {
use futures::StreamExt;
let items: Vec<Result<bytes::Bytes, reqwest::Error>> = chunks
.into_iter()
.map(|c| Ok(bytes::Bytes::from(c)))
.collect();
let mut s = SseStream::new(stream::iter(items));
futures::executor::block_on(async {
let mut out = Vec::new();
while let Some(item) = s.next().await {
out.extend(item.expect("测试流不应产生错误"));
}
out
})
}
/// 回归:正常多事件流照常解析
#[test]
fn normal_events_parse() {
let events = run_chunks(vec![
"data: {\"a\":1}\n\n".into(),
"data: {\"b\":2}\n\n".into(),
]);
assert_eq!(events, vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()]);
}
/// 超限单事件(末尾带完整分隔符)整体丢弃:不产出半截破损事件,后续正常事件照常解析。
/// 旧按字节截头实现会把残留尾部 "data: TAIL" 行当伪事件产出
#[test]
fn oversize_event_with_boundary_dropped_whole() {
let giant = format!("data: {}\ndata: TAIL\n\n", "x".repeat(BUF_MAX));
let events = run_chunks(vec![giant, "data: {\"ok\":1}\n\n".into()]);
assert_eq!(events, vec!["{\"ok\":1}".to_string()]);
}
/// 验收:超限注入(无边界大块)丢弃后,残缺事件收尾静默吞掉,后续正常事件仍可解析。
/// 旧按字节截头实现会把 STALE-TAIL 残段当伪事件产出
#[test]
fn oversize_boundaryless_blob_then_normal_events_still_parse() {
let events = run_chunks(vec![
"x".repeat(BUF_MAX + 100),
"data: STALE-TAIL\n\n".into(),
"data: {\"ok\":1}\n\n".into(),
"data: [DONE]\n\n".into(),
]);
assert_eq!(events, vec!["{\"ok\":1}".to_string(), "[DONE]".to_string()]);
}
}
-117
View File
@@ -1,117 +0,0 @@
//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。
//!
//! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」:
//! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开
//! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。
//!
//! 与 namespace_store 的区别(两者互补,不冲突):
//! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。
//! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。
//!
//! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。
//! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。
//! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。
//!
//! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它;
//! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程
//! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。
/// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。
static BASE: OnceLock<PathBuf> = OnceLock::new();
/// 注入外置根目录(app data dir)。幂等,首次生效。
pub fn init_base(base: PathBuf) {
let _ = BASE.set(base);
}
/// base 是否已注入(降级判定用)。
pub fn is_enabled() -> bool {
BASE.get().is_some()
}
/// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。
fn content_id(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
content.hash(&mut h);
h.finish()
}
/// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。
pub fn offload_with_handle(content: &str, tool_name: &str) -> Option<String> {
offload_to(BASE.get()?, content, tool_name)
}
/// 核心实现(显式 base,零全局态,可测)。
///
/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。
pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option<String> {
let dir = base.join("tool-results");
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
let file = dir.join(format!("ctx-{:016x}.txt", content_id(content)));
if !file.exists() && std::fs::write(&file, content).is_err() {
return None;
}
Some(format!(
"[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]",
tool_name,
content.chars().count(),
file.display()
))
}
/// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。
pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_base(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id()))
}
#[test]
fn offload_to_writes_readable_file_and_is_idempotent() {
let base = tmp_base("ok");
let _ = std::fs::remove_dir_all(&base);
let payload = "large tool output ".repeat(1000);
let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘");
assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}");
assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}");
// 幂等:同内容二次调用返回同一文件名
let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄");
assert_eq!(handle, handle2, "同内容应幂等复用同一文件");
// 落盘文件可读回原文(无损)
let path = std::fs::read_dir(base.join("tool-results"))
.expect("目录应存在")
.flatten()
.map(|e| e.path())
.find(|p| p.extension().map(|e| e == "txt").unwrap_or(false))
.expect("应有落盘文件");
assert_eq!(
read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"),
payload
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn different_content_gets_different_file() {
let base = tmp_base("diff");
let _ = std::fs::remove_dir_all(&base);
let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap();
let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap();
assert_ne!(h1, h2, "不同内容应落不同文件");
let _ = std::fs::remove_dir_all(&base);
}
}
-85
View File
@@ -1,85 +0,0 @@
//! P1 工具结果瘦身 — 端到端集成验证。
//!
//! 独立测试二进制(独立进程):`tool_offload::init_base` 的 OnceLock 全局态不外泄,
//! 不污染 lib 单测(反之亦然)。这是验证「超阈值 → 落盘句柄 → 可无损读回」全链的唯一安全方式。
use df_ai::context_helpers::{extract_key_info, READ_FILE_OFFLOAD_BYTES};
use df_ai::tool_offload;
fn tmp_base(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("df-ai-ctx-offload-{tag}-{}", std::process::id()))
}
/// 从句柄行里粗取落盘路径(格式:`[...已外置: <path> —— ...]`)。
fn path_of(handle_line: &str) -> String {
handle_line
.split("已外置: ")
.nth(1)
.and_then(|s| s.split(" ——").next())
.expect("句柄行应含路径")
.trim()
.to_string()
}
#[test]
fn large_read_file_summarized_with_lossless_handle() {
let base = tmp_base("large");
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
// 造一个超阈值的 read_file 结果(代码风格多行文本)
let body: String = (1..=800)
.map(|i| format!("line {i}: fn sample_{i}() {{ let x = {i}; }}\n"))
.collect();
assert!(body.len() > READ_FILE_OFFLOAD_BYTES, "夹具须超阈值");
let out = extract_key_info(&body, "read_file");
assert!(out.len() < body.len(), "超阈值 read_file 应被压缩");
assert!(out.contains("已外置"), "应追加落盘句柄:\n{out}");
assert!(out.contains("read_file 读该路径"), "句柄应指引读回方式");
// 无损性:按句柄路径读回 = 原文逐字节一致
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
let back = tool_offload::read_offloaded(&path_of(line)).expect("句柄路径应可读回");
assert_eq!(back, body, "落盘原文须与输入逐字节一致(无损)");
// 摘要确实变小(瘦身生效)
let ratio = out.len() as f64 / body.len() as f64;
assert!(ratio < 0.2, "压缩比应显著(<0.2),实际 {ratio:.3}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn small_read_file_still_exempt() {
let base = tmp_base("small");
tool_offload::init_base(base);
let small = "line1: fn a() {}\nline2: fn b() {}\n";
assert_eq!(
extract_key_info(small, "read_file"),
small,
"阈值内 read_file 应原样返回(保 AI 定向读代码语义)"
);
}
#[test]
fn non_read_file_large_result_also_gets_handle() {
let base = tmp_base("other");
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
// 中性行夹具:不得含 error/panic/.rs: 等错误行信号——错误行保留策略会全量保留
// 命中行,用信号行做夹具会让「应被压缩」断言先天不成立(测的是普通大结果外置)
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.txt:{i}\n")).collect();
assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)");
let out = extract_key_info(&body, "grep");
assert!(out.len() < body.len(), "应被压缩");
assert!(out.contains("已外置"), "≥8KB 结果应带落盘句柄");
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
assert_eq!(
tool_offload::read_offloaded(&path_of(line)).expect("应可读回"),
body
);
let _ = std::fs::remove_dir_all(&base);
}
-191
View File
@@ -1,191 +0,0 @@
//! 提交冻结机械闸(39 治理) — AI 引擎派发命令的 git commit 拦截。
//!
//! 冻结期间(用户 🔒 令)禁止引擎/挂机会话执行 git commit:命令在 spawn 前被拒,
//! 改动留在工作区。用户本人终端不受影响(本闸只作用于引擎 execute 入口)。
//!
//! 冻结标志解析(优先级:环境变量 DEVFLOW_COMMIT_FROZEN > env 内 DevFlow 标志):
//! - env::var("DEVFLOW_COMMIT_FROZEN")="1"/"true" → 冻结
//! - ShellRequest.env 同名键同样生效(测试/调用方定向注入)
//! 默认不冻结(用户本人正常提交流程零影响)。
//!
//! 拦截范围:命令子串含 `git commit`(含 `git -C x commit`)即拒;`git commit --amend`
//! 同拒。`git log/diff/status/add/push` 不拦(只冻结提交动作)。
//!
//! 提示文案给 LLM 指明出路:改留工作区,等「攒批提交通知」。
use std::sync::atomic::{AtomicBool, Ordering};
use crate::shell::ShellRequest;
/// 进程级冻结标志(引擎侧写入:unattended 轮询每轮按看板 🔒 行 + KV 覆盖同步)。
/// 用静态位而非 std::env::set_var(Rust 2024 edition set_var unsafe 且多线程 UB)。
static FROZEN: AtomicBool = AtomicBool::new(false);
/// 引擎侧设置/解除冻结(unattended 轮询每轮调用)。
pub fn set_frozen(v: bool) {
FROZEN.store(v, Ordering::SeqCst);
}
/// 环境变量真值判定:"1"/"true"(含大小写变体)为冻结,其余("0"/"false"/空)为不冻结。
fn env_val_is_truthy(v: &str) -> bool {
matches!(v, "1" | "true" | "TRUE" | "True")
}
/// 是否启用冻结:静态标志(引擎同步) 或 环境变量 DEVFLOW_COMMIT_FROZEN(调用方/测试定向注入)。
pub fn is_frozen(extra_env: Option<&std::collections::HashMap<String, String>>) -> bool {
if FROZEN.load(Ordering::SeqCst) {
return true;
}
if let Some(v) = extra_env.and_then(|m| m.get("DEVFLOW_COMMIT_FROZEN")) {
return env_val_is_truthy(v);
}
std::env::var("DEVFLOW_COMMIT_FROZEN")
.map(|v| env_val_is_truthy(&v))
.unwrap_or(false)
}
/// 命令是否为 git commit 提交动作(token 判据,含 -C 前缀形态与复合命令)。
pub fn is_commit_command(command: &str) -> bool {
// 按常见命令分隔符切段( ; && || | 换行),每段独立判 git commit(防 "x; git commit" 绕过)
command
.split(|c: char| matches!(c, ';' | '|' | '&' | '\n'))
.any(is_commit_segment)
}
/// 单段判据:token 找 "git",跳过全局选项(-C path 等)后是否接 "commit"。
fn is_commit_segment(segment: &str) -> bool {
let tokens: Vec<String> = segment
.split_whitespace()
.map(|t| t.trim_matches(|c: char| c == '"' || c == '\'').to_lowercase())
.collect();
for (i, t) in tokens.iter().enumerate() {
if t == "git" {
let mut j = i + 1;
while j < tokens.len() {
match tokens[j].as_str() {
// `-c key=val` 两 token 形态:值在下一 token,跳 2
"-c" => j += 2,
// `--git-dir=path`/`--work-tree=path` 单 token 合并形态:自含值,跳 1
t if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") => j += 1,
// `--git-dir path`/`--work-tree path` 两 token 形态:跳 2
"--git-dir" | "--work-tree" => j += 2,
"commit" => return true,
_ => break,
}
}
}
}
false
}
/// 冻结闸检查入口:冻结在场且命令是 git commit → 返回拒绝 Err(不 spawn)。
pub fn check(request: &ShellRequest) -> Result<(), String> {
if !is_frozen(Some(&request.env)) {
return Ok(());
}
if is_commit_command(&request.command) {
return Err(format!(
"[提交冻结令] 检测到 git commit 命令,当前处于提交冻结期(用户 🔒 令),已拒绝执行: \
改动保留在工作区,等用户「攒批提交通知」后才可提交。命令: {}",
truncate_cmd(&request.command)
));
}
Ok(())
}
fn truncate_cmd(s: &str) -> String {
if s.chars().count() <= 120 {
s.to_string()
} else {
let cut: String = s.chars().take(120).collect();
format!("{}…", cut)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn req(cmd: &str) -> ShellRequest {
ShellRequest {
command: cmd.to_string(),
working_dir: None,
env: HashMap::new(),
timeout_secs: None,
shell_type: None,
}
}
fn req_env(cmd: &str, env: &[(&str, &str)]) -> ShellRequest {
let mut r = req(cmd);
r.env = env
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
r
}
#[test]
fn commit_detection_covers_variants() {
assert!(is_commit_command("git commit -m 'x'"));
assert!(is_commit_command("git -C E:/repo commit -m x"));
assert!(is_commit_command("git commit --amend"));
assert!(is_commit_command("git commit -m y"));
assert!(is_commit_command("git commit; git push"));
// PowerShell / sh 包裹形态
assert!(is_commit_command("powershell -Command git commit -m x"));
assert!(is_commit_command("cd repo && git commit -m x"));
assert!(is_commit_command("git add . && git commit -m x"));
}
#[test]
fn non_commit_git_not_blocked() {
assert!(!is_commit_command("git log -1"));
assert!(!is_commit_command("git status"));
assert!(!is_commit_command("git diff --stat"));
assert!(!is_commit_command("git add ."));
assert!(!is_commit_command("git push origin main"));
assert!(!is_commit_command("cargo test"));
// commitment 等误命中防护:commit 后无后续 token 才算
assert!(!is_commit_command("git log --grep commit"));
}
#[test]
fn freeze_gate_blocks_commit_when_frozen() {
let r = req_env("git commit -m '细项批'", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
assert!(check(&r).is_err(), "冻结在场 commit 必须被拒");
let r2 = req_env("git log -1", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
assert!(check(&r2).is_ok(), "冻结只拦 commit 不拦只读命令");
}
/// 解冻态放行用例:持与 static_flag_toggle 同一把串行闸——静态位是进程级单源,
/// 置位瞬间并行跑到本用例会误判冻结(实测互踩),凡依赖「静态位=false」断言的用例一律持闸。
#[test]
fn no_freeze_no_block() {
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let r = req("git commit -m '用户本人提交'");
assert!(check(&r).is_ok(), "无冻结标志零影响(用户正常提交不受影响)");
}
#[test]
fn frozen_false_value_no_block() {
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let r = req_env("git commit -m x", &[("DEVFLOW_COMMIT_FROZEN", "0")]);
assert!(check(&r).is_ok(), "显式关闭标志不拦(解冻态)");
}
/// 静态标志(引擎同步路径)切换:置位拦 commit,复位恢复。
/// 串行闸:静态位是进程级单源,置位期间并行的 env 用例(check 走 is_frozen 静态位短路)会互踩,
/// 持锁串行 + 进出恢复默认(false)防污染。
#[test]
fn static_flag_toggle() {
// 复用 PWSH_CACHE_TEST_LOCK 串行闸(进程级互斥,专用闸后续有需要再单立)
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_frozen(true);
assert!(check(&req("git commit -m x")).is_err(), "静态冻结位拦 commit");
assert!(check(&req("git diff")).is_ok(), "静态冻结位不拦只读命令");
set_frozen(false);
assert!(check(&req("git commit -m x")).is_ok(), "复位后恢复放行");
}
}
-488
View File
@@ -1,488 +0,0 @@
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
//!
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
//!
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
//! 阻断主流程)。
use std::sync::OnceLock;
/// 环境快照 — 系统环境的不可变视图。
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnvSnapshot {
/// 操作系统族:"windows" / "macos" / "linux"
pub os: String,
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
pub os_version: String,
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
pub shell: String,
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
pub path_sep: String,
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
pub encoding: String,
/// 工具版本(逐个探测,缺失为 None)
pub tools: ToolVersions,
}
/// 各开发工具的版本信息。
#[derive(Debug, Clone, serde::Serialize, Default)]
pub struct ToolVersions {
pub python: Option<String>,
pub node: Option<String>,
pub rust: Option<String>,
pub go: Option<String>,
pub docker: Option<String>,
pub git: Option<String>,
}
impl EnvSnapshot {
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
///
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
pub async fn detect() -> &'static EnvSnapshot {
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
if let Some(snap) = SNAPSHOT.get() {
return snap;
}
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
// probe_version 内 std::process::Command::output() 无 timeout,
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
let snap = match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
).await {
Ok(Ok(s)) => s,
Ok(Err(join_err)) => {
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
EnvSnapshot::fallback()
}
Err(_elapsed) => {
eprintln!(
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
);
EnvSnapshot::fallback()
}
};
// 多任务竞态:均等价,以先到者为准。
let _ = SNAPSHOT.set(snap);
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
}
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
fn do_detect() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: detect_os_version(),
shell: detect_shell(),
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: detect_encoding(),
tools: ToolVersions {
python: probe_version("python", &["--version"]),
node: probe_version("node", &["--version"]),
rust: probe_version("rustc", &["--version"]),
go: probe_version("go", &["version"]),
docker: probe_version("docker", &["--version"]),
git: probe_version("git", &["--version"]),
},
}
}
/// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。
/// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探,
/// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。
fn fallback() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: String::new(),
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: "utf-8".into(),
tools: ToolVersions::default(),
}
}
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
///
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
pub fn to_prompt(&self) -> String {
let mut lines: Vec<String> = vec![
"## 系统环境".to_string(),
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
format!("- Shell: {}", self.shell),
format!("- 路径分隔符: {}", self.path_sep),
format!("- 终端编码: {}", self.encoding),
];
if let Some(v) = &self.tools.python {
lines.push(format!("- Python: {}", v));
}
if let Some(v) = &self.tools.node {
lines.push(format!("- Node: {}", v));
}
if let Some(v) = &self.tools.rust {
lines.push(format!("- Rust: {}", v));
}
if let Some(v) = &self.tools.go {
lines.push(format!("- Go: {}", v));
}
if let Some(v) = &self.tools.docker {
lines.push(format!("- Docker: {}", v));
}
if let Some(v) = &self.tools.git {
lines.push(format!("- Git: {}", v));
}
lines.push(String::new());
lines.push("注意: 请生成本平台兼容的命令。".to_string());
lines.join("\n")
}
}
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
fn detect_os_version() -> String {
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
#[cfg(windows)]
{
if let Some(v) = read_windows_version() {
return v;
}
return String::new();
}
// macOS:sw_vers -productVersion 输出如 "12.5"
#[cfg(target_os = "macos")]
{
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
if out.status.success() {
return String::from_utf8_lossy(&out.stdout).trim().to_string();
}
}
return String::new();
}
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
#[cfg(target_os = "linux")]
{
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
return rest.trim_matches('"').to_string();
}
}
}
return String::new();
}
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
{
String::new()
}
}
#[cfg(windows)]
fn read_windows_version() -> Option<String> {
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
let out = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"/v",
"DisplayVersion",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
// 输出形如: " DisplayVersion REG_SZ 22H2"
for line in text.lines() {
let trimmed = line.trim();
if let Some(idx) = trimmed.find("REG_SZ") {
let val = trimmed[idx + "REG_SZ".len()..].trim();
if !val.is_empty() {
return Some(format!("Windows {}", val));
}
}
}
None
}
/// 探测默认 shell(Windows 走 shell.rs 单源,Unix 读 SHELL 环境变量)。
///
/// 单源语义(2026-08 修复):Windows 分支不再独立 probe pwsh/powershell,而是:
/// 1) 先调 `shell::probe_pwsh_blocking()` 填充模块级 `PWSH_CACHE`(与 `shell::probe_pwsh()` 异步路径
/// 共用同一 OnceLock + 同一阻塞探测实现,杜绝两套逻辑漂移);
/// 2) 再调 `shell::current_shell()`(同步读 `PWSH_CACHE`),映射 ShellType → prompt 字符串。
/// 后续 `execute()` → `probe_pwsh().await` 直接命中缓存,跳过重复探测。
/// Unix 分支保留原 SHELL 环境变量读取(与执行侧 ShellType::Sh 默认一致,无漂移风险)。
fn detect_shell() -> String {
#[cfg(windows)]
{
// 单源填充 PWSH_CACHE(本函数运行在 EnvSnapshot::detect 的 spawn_blocking 内,
// 通常先于首次 execute(),故常是缓存的首次写入者)。
crate::shell::probe_pwsh_blocking();
// 单源读取并映射为 prompt 字符串。
match crate::shell::current_shell() {
crate::shell::ShellType::Pwsh => "pwsh".to_string(),
crate::shell::ShellType::PowerShell => "powershell".to_string(),
crate::shell::ShellType::Cmd => "cmd".to_string(),
crate::shell::ShellType::Sh => "sh".to_string(),
}
}
#[cfg(not(windows))]
{
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
if let Ok(sh) = std::env::var("SHELL") {
// 取 basename(/bin/zsh → zsh)
let name = sh.rsplit('/').next().unwrap_or(&sh);
if !name.is_empty() {
return name.to_string();
}
}
"sh".to_string()
}
}
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
fn detect_encoding() -> String {
#[cfg(windows)]
{
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
if let Ok(out) = std::process::Command::new("chcp")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000)
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
if let Some(code) = extract_codepage(&text) {
return match code.as_str() {
"65001" => "utf-8".to_string(),
"936" => "gbk".to_string(),
"950" => "big5".to_string(),
other => format!("cp{}", other),
};
}
}
return "utf-8".to_string();
}
#[cfg(not(windows))]
{
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
if let Ok(lang) = std::env::var("LANG") {
if lang.to_ascii_uppercase().contains("UTF-8") {
return "utf-8".to_string();
}
}
"utf-8".to_string()
}
}
#[cfg(windows)]
fn extract_codepage(text: &str) -> Option<String> {
// 提取末尾数字("...936" / "...: 65001")。
let mut num = String::new();
for c in text.chars().rev() {
if c.is_ascii_digit() {
num.insert(0, c);
} else if !num.is_empty() {
break;
}
}
if num.is_empty() {
None
} else {
Some(num)
}
}
/// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。
///
/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias),
/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时
/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。
///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let first_line = text.lines().next()?;
Some(extract_version_token(first_line))
}
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
///
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
fn extract_version_token(line: &str) -> String {
for token in line.split_whitespace() {
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
// "2.41.0.windows.1",实际应止于 "2.41.0")。
let bytes = token.as_bytes();
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
Some(i) => i,
None => continue,
};
let mut head = String::new();
loop {
// 收数字段
let seg_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
head.push_str(&token[seg_start..i]);
// 点号:仅当下一字符为数字时才续,否则收尾
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit()
{
head.push('.');
i += 1;
continue;
}
break;
}
if head.contains('.') {
return head;
}
}
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
line.trim().to_string()
}
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::Stdio;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_version_python_style() {
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
}
#[test]
fn extract_version_node_style() {
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
}
#[test]
fn extract_version_git_style() {
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
}
#[test]
fn extract_version_go_style() {
assert_eq!(
extract_version_token("go version go1.21.0 windows/amd64"),
"1.21.0"
);
}
#[test]
fn extract_version_no_match_returns_line() {
assert_eq!(extract_version_token("no version here"), "no version here");
}
#[cfg(windows)]
#[test]
fn extract_codepage_parsing() {
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
assert_eq!(
extract_codepage("Active code page: 65001"),
Some("65001".into())
);
assert_eq!(extract_codepage("no digits here"), None);
}
#[tokio::test]
async fn detect_returns_cached_static_ref() {
// 持 shell.rs 缓存串行闸:本测试可能首次创建 SNAPSHOT,其 shell 字段取自创建时刻的
// 缓存状态;若与写缓存的 shell.rs 测试并行,漂移测试会读到与 current_shell 不一致的快照。
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
let a = EnvSnapshot::detect().await as *const _;
let b = EnvSnapshot::detect().await as *const _;
assert_eq!(a, b, "detect() 应返回同一静态引用");
}
/// 单源不变量回归:prompt 期 shell(env_snapshot.shell)与执行期 shell(current_shell)必须一致。
///
/// 历史 bug:detect_shell 持独立 probe_command_success 探测,与 shell.rs::probe_pwsh 各填各的
/// OnceLock → prompt 告诉 LLM 用 pwsh,执行却走 powershell(或反之)。根本修:二者共用
/// shell.rs 的 probe_pwsh_blocking + PWSH_CACHE 单源。本测试锁定「无漂移」不变量。
///
/// 隔离:持 shell.rs 缓存串行闸,快照值与 current_shell() 须读自同一缓存状态,
/// 防与写缓存的 shell.rs 测试并行互踩(Mutex 化后写值会被复位,闸保证读窗口内无交叉写)。
#[cfg(windows)]
#[tokio::test]
async fn detect_shell_matches_shell_rs_current_shell() {
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// detect() 内 detect_shell → 读 PWSH_CACHE 映射,与下方 current_shell() 同源。
let snap = EnvSnapshot::detect().await;
let current = crate::shell::current_shell();
// 二者必须一致(同一 PWSH_CACHE 单源读出)。
match snap.shell.as_str() {
"pwsh" => assert_eq!(current, crate::shell::ShellType::Pwsh),
"powershell" => assert_eq!(current, crate::shell::ShellType::PowerShell),
"cmd" => assert_eq!(current, crate::shell::ShellType::Cmd),
"sh" => assert_eq!(current, crate::shell::ShellType::Sh),
other => panic!("未知 shell 字符串: {}", other),
}
}
#[test]
fn to_prompt_contains_os_and_shell() {
let snap = EnvSnapshot {
os: "test_os".into(),
os_version: "v1".into(),
shell: "test_shell".into(),
path_sep: "/".into(),
encoding: "utf-8".into(),
tools: ToolVersions {
python: Some("3.11".into()),
node: None,
rust: None,
go: None,
docker: None,
git: None,
},
};
let prompt = snap.to_prompt();
assert!(prompt.contains("test_os"));
assert!(prompt.contains("test_shell"));
assert!(prompt.contains("Python: 3.11"));
assert!(!prompt.contains("Node"));
assert!(prompt.contains("请生成本平台兼容的命令"));
}
}
+1 -6
View File
@@ -1,8 +1,3 @@
//! df-execute: 执行运行时 — Shell + 环境感知 + 提交冻结闸 //! df-execute: 执行运行时 — Shell
pub mod commit_freeze;
pub mod env_snapshot;
pub mod shell; pub mod shell;
pub use commit_freeze::is_commit_command;
pub use env_snapshot::EnvSnapshot;
+44 -442
View File
@@ -3,8 +3,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::Stdio; use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
/// Shell 命令执行结果 /// Shell 命令执行结果
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellResult { pub struct ShellResult {
@@ -35,75 +33,40 @@ pub enum ShellType {
impl Default for ShellType { impl Default for ShellType {
fn default() -> Self { fn default() -> Self {
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理 // L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
// 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。 // 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
// 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`, // BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。 // PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果缓存于模块级 PWSH_CACHE(只探一次)。 // 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// // 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
// 【序约束 / 死缓存修复】Default 为同步签名,读模块级单源 PWSH_CACHE(由 probe_pwsh 写入)。 // 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
// 缓存有两个填充点(均走 probe_pwsh_blocking 单真相源,无漂移):
// 1) env_snapshot::detect_shell → EnvSnapshot::detect()(启动期 spawn_blocking 内,先于 execute);
// 2) execute()/execute_streaming() 异步入口 → probe_pwsh().await(spawn_blocking + 3s 超时)。
// 通常 detect_shell 先跑(EnvSnapshot::detect 在 run_agentic_loop 早期被 await),首次 execute 时
// probe_pwsh 直接命中缓存。若 detect 尚未填充,probe_pwsh 自带探测兜底。任一时刻 build_command →
// ShellType::default() 读到缓存值;未探测(缓存空)返回 PowerShell(PS5)。绝不在 Default 同步路径内 spawn 探测。
//
// 历史 bug(2026-08 修复):原 probe_pwsh_cached() 与 probe_pwsh() 各持一个独立 static OnceLock,
// Default 读的那个永不被填充 → Pwsh 全死代码,Windows 永走 PS5(&& 必失败)。根因:两个 OnceLock
// 非单源 + env_snapshot 另持 probe_command_success 独立探测。根本修:合并为模块级 PWSH_CACHE 单源,
// 写读同源;detect_shell 复用 probe_pwsh_blocking(单一探测实现)。
if cfg!(windows) { if cfg!(windows) {
if cache_get().unwrap_or(false) { ShellType::Pwsh } else { ShellType::PowerShell } if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
} else { } else {
ShellType::Sh ShellType::Sh
} }
} }
} }
/// pwsh(PowerShell 7)可用性的全局单源缓存。 /// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
/// fn probe_pwsh_cached() -> bool {
/// 由异步 `probe_pwsh()`(execute 路径)或同步 `probe_pwsh_blocking()`(detect_shell 路径)探测, static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// 同步路径 `ShellType::default()` / `current_shell()` 读取——写读同源, CACHE.get().copied().unwrap_or(false)
/// 杜绝历史上「两个 OnceLock 各填各的、Default 读的永空」死缓存 bug。
///
/// 语义:`None` 表示尚未探测(首次启动 / Windows 未装 pwsh 也仅表示探测未跑或返 false);
/// `Some(true)` 表示已探测且 pwsh 可用。
///
/// 用 `Mutex<Option<bool>>` 而非 OnceLock:OnceLock 写入后进程级不可清,写缓存的测试
/// 与依赖缓存一致性的测试(env_snapshot 漂移测试)全量并行跑时互踩。Mutex 化后测试可
/// 备份/复位隔离(配 PWSH_CACHE_TEST_LOCK 串行闸);生产读写语义不变,有缓存读缓存、
/// 无缓存探测后写。
static PWSH_CACHE: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None);
/// 读缓存当前值(毒化容错:持锁测试 panic 后其余测试仍可继续,行为不变)。
fn cache_get() -> Option<bool> {
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner())
} }
/// 写缓存当前值(探测产物写入 / 测试复位共用,覆盖写)。 /// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
fn cache_set(v: Option<bool>) {
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = v;
}
/// 测试串行闸:缓存为进程级单源,写缓存的测试与依赖缓存一致性的测试并行互踩,
/// 凡改写缓存或依赖缓存/EnvSnapshot 一致性的测试须持锁(仅测试隔离用,非生产路径)。
/// 对齐 path_auth.rs 的 BLACKLIST_BYPASS_TEST_LOCK 模式;env_snapshot 漂移测试持同闸。
#[cfg(test)]
pub(crate) static PWSH_CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 同步探测 pwsh(PowerShell 7)是否可用(std::Command::status),成功返回 true。
/// ///
/// 这是阻塞 IO 的「单真相源」实现——`probe_pwsh()`(异步,spawn_blocking + 超时)与 /// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// env_snapshot::detect_shell(windows 同步路径)都调用本函数,二者探测逻辑永远一致, /// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
/// 杜绝「提示告诉 LLM 用 pwsh,执行却走 powershell」的两套逻辑漂移。 /// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
/// ///
/// 实现等价于原 `probe_pwsh()` 内的闭包:`pwsh -NoProfile -Command exit 0` 成功即 true; /// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
/// Windows 加 CREATE_NO_WINDOW(0x0800_0000)防黑窗闪现。 /// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
/// async fn probe_pwsh() -> bool {
/// 注:此处无超时——`probe_pwsh()` 在调用方包 `tokio::time::timeout`, static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// `detect_shell()` 则由外层 `EnvSnapshot::detect()` 的 spawn_blocking 5s 超时兜底。 if let Some(cached) = CACHE.get() {
/// 故本函数本身只负责「同步 spawn + status」,超时治理在调用点。 return *cached;
pub(crate) fn probe_pwsh_blocking() -> bool { }
let result = tokio::task::spawn_blocking(|| {
let mut cmd = std::process::Command::new("pwsh"); let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0"); cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null()); cmd.stdout(Stdio::null()).stderr(Stdio::null());
@@ -113,200 +76,12 @@ pub(crate) fn probe_pwsh_blocking() -> bool {
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
} }
cmd.status().map(|s| s.success()).unwrap_or(false) cmd.status().map(|s| s.success()).unwrap_or(false)
} })
.await
/// 探测 pwsh(PowerShell 7)是否可用(模块级缓存,3s 超时,只探一次)。 .unwrap_or(false);
/// // 多任务竞态时以先到者为准,均等价
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。 let _ = CACHE.set(result);
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
///
/// 异步化 + 超时治理:在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
/// 外包 `tokio::time::timeout(3s)`。结果写入模块级单源 `PWSH_CACHE`,同步 `Default` 路径共享。
///
/// 超时/panic 时**不写入 PWSH_CACHE**:
/// - 超时根因往往是 Windows Store Alias / 杀软 hook 拦截 `pwsh` 命令(status() 永不返回);
/// 若错误地把 false 缓存,后续 execute() 会一直走 PS5(&& 必失败),把瞬时环境问题冻结成
/// 「永不可用」错误判定。不 set → 每次 execute 重试,环境恢复后自动回正。
/// - panic(join err)同理,可能是临时线程池异常,不应冻结判定。
/// - 仅「正常完成(Ok(Ok(_)))」时 set 缓存(此时结果是可信的探测产物)。
/// 对齐 env_snapshot.rs:64-80 的「spawn_blocking + timeout + 超时不 set」模式。
async fn probe_pwsh() -> bool {
if let Some(cached) = cache_get() {
return cached;
}
match tokio::time::timeout(
std::time::Duration::from_secs(3),
tokio::task::spawn_blocking(probe_pwsh_blocking),
).await {
Ok(Ok(result)) => {
// 正常完成:写入缓存(多任务竞态覆盖写,各写者探测值等价)
cache_set(Some(result));
result result
}
Ok(Err(join_err)) => {
eprintln!(
"[shell] probe_pwsh spawn_blocking 异常(不缓存,下次 execute 重试): {}",
join_err
);
false
}
Err(_elapsed) => {
eprintln!(
"[shell] probe_pwsh 3s 超时(疑似 Windows Store Alias / 杀软 hook 拦截 pwsh, \
不缓存以免冻结错误判定,下次 execute 重试)"
);
false
}
}
}
/// 当前 shell 类型(同步读取模块级 PWSH_CACHE 单源)。
///
/// 探测在 `probe_pwsh()`(异步,带 3s 超时)或 `probe_pwsh_blocking()`(detect_shell 同步路径)中执行并填充 PWSH_CACHE;此处仅读。
/// 缓存空(未探测 / 探测超时未 set)时返回 `ShellType::default()`(Windows → PowerShell,Unix → Sh)。
/// 单源语义:env_snapshot::detect_shell 与 build_command 共用此判定,杜绝「探测与执行两套逻辑漂移」。
pub fn current_shell() -> ShellType {
ShellType::default()
}
/// 剥离 ANSI 转义序列(#8 出口兜底)。
///
/// 源头抑制(build_command 注入 NO_COLOR/TERM=dumb)覆盖主流工具链,但总有漏网
/// (不读环境变量的工具/嵌套脚本转发色码)。本纯函数兜底 CSI/OSC 两类常见序列:
/// - CSI:ESC [ 参数字母(颜色/光标/擦除,如 \x1b[7m \x1b[0K)
/// - OSC:ESC ] ... BEL 或 ESC \(终端标题/超链接,罕见但同样乱码)
/// 不碰普通文本与换行,幂等(已剥离文本再剥无变化)。
pub fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
// c == ESC:按下一字符分派序列类型
match text[i + 1..].chars().next() {
Some('[') => {
// CSI:跳到终止字母(@-~)为止
let rest = &text[i + 2..];
match rest.find(|ch: char| ('\u{40}'..='\u{7e}').contains(&ch)) {
Some(end) => {
// 跳过整个序列: advance 到 i+2+end 的下一字符
let skip = 2 + end + 1;
for _ in 1..skip {
chars.next();
}
}
None => {
// 流式截断的残缺序列(无终止符):丢弃剩余全部
for _ in (i + 1)..text.len() {
chars.next();
}
}
}
}
Some(']') => {
// OSC:到 BEL(\x07) 或 ST(ESC \)为止
let rest = &text[i + 2..];
let bel = rest.find('\x07');
let st = rest.find("\x1b\\");
match (bel, st) {
(Some(b), None) => {
// 已消费 ESC,再消费 i+1..=i+2+b(含 BEL)共 b+2 个
for _ in 0..(2 + b) {
chars.next();
}
}
(_, Some(s)) => {
// 再消费 i+1..=i+2+s+1(含 ST 两字符)共 s+3 个
for _ in 0..(2 + s + 1) {
chars.next();
}
}
(None, None) => {
for _ in (i + 1)..text.len() {
chars.next();
}
}
}
}
_ => {
// 两字符转义(ESC \ ESC ( 等):丢 ESC 保留后字符?保守双丢后单字符
chars.next();
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// #8 ANSI 剥离:CSI 色码/擦除/流式残缺序列/OSC/普通文本不动
#[test]
fn strip_ansi_basic() {
assert_eq!(strip_ansi("\x1b[7ma\x1b[0mb"), "ab");
assert_eq!(strip_ansi("\x1b[2K\x1b[1Gdone"), "done");
assert_eq!(strip_ansi("plain text\nline2"), "plain text\nline2");
// 流式残缺(无终止符):丢残序列
assert_eq!(strip_ansi("ok\x1b[3"), "ok");
// OSC BEL 终止
assert_eq!(strip_ansi("\x1b]0;title\x07body"), "body");
// 幂等
let once = strip_ansi("\x1b[32mgreen\x1b[0m");
assert_eq!(strip_ansi(&once), once);
}
/// 死缓存回归测试:probe_pwsh 写入后,Default 同步路径必须读到同一缓存值。
///
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
/// 本测试经 probe_pwsh 写模块级 PWSH_CACHE 后断言 default() 与探测值一致,锁定「写读同源」不变量。
///
/// 隔离:持串行闸,进时备份、断言前复位。OnceLock 时代写入后全进程不可清,
/// 会污染并行的 env_snapshot 漂移测试(其快照先以空缓存创建,断言即漂移)。
#[cfg(windows)]
#[tokio::test]
async fn probe_pwsh_cache_shared_with_default() {
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backup = cache_get();
// 探测一次填充缓存(无论机器是否装 pwsh,只要写读同源即应一致)。
// 注:正常完成路径才会写缓存;本机 pwsh 探测不超时/不 panic,故 cached 应与 probed 一致。
let probed = probe_pwsh().await;
let cached = cache_get();
// Default 必须读到与 probe 一致的判定:Pwsh ↔ true,PowerShell ↔ false
let default_shell = ShellType::default();
// 复位放在断言前:中途 panic 不残留污染态(对齐 path_auth.rs 先复位再断言)
cache_set(backup);
// 探测完成后缓存必已填充(同步路径由此读到)
assert_eq!(cached, Some(probed));
match probed {
true => assert_eq!(default_shell, ShellType::Pwsh),
false => assert_eq!(default_shell, ShellType::PowerShell),
}
}
/// 不挂死不变量回归:probe_pwsh 必须在有限时间内返回(自带 3s 超时 + spawn_blocking)。
///
/// 历史 bug:probe_pwsh 仅 spawn_blocking 无超时,Windows Store Alias / 杀软 hook 拦截 pwsh 时
/// status() 永不返回 → spawn_blocking 线程永不返回 → probe_pwsh().await 永久挂 →
/// execute()/execute_streaming() 卡死 → run_agentic_loop 死锁(与 env_snapshot::detect 同源 bug)。
/// 本测试外包 10s timeout(远大于 3s 内部超时),无论探测成败都应在 10s 内返回,锁定「不挂死」不变量。
///
/// 隔离:持串行闸,探测在缓存为空时会写入缓存,备份/复位防污染并行测试。
#[cfg(windows)]
#[tokio::test]
async fn probe_pwsh_completes_within_timeout() {
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backup = cache_get();
// 10s >> probe_pwsh 内部 3s 超时;若 10s 仍未返回 → 探测挂死,不变量被破坏。
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
probe_pwsh(),
).await;
cache_set(backup);
assert!(result.is_ok(), "probe_pwsh 必须在 10s 内返回(内部 3s 超时已兜底),不应挂死");
}
} }
/// Shell 命令执行请求 /// Shell 命令执行请求
@@ -320,33 +95,22 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>, pub env: std::collections::HashMap<String, String>,
/// 超时时间(秒),None 表示不超时 /// 超时时间(秒),None 表示不超时
pub timeout_secs: Option<u64>, pub timeout_secs: Option<u64>,
/// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh) /// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
#[serde(default)] #[serde(default)]
pub shell_type: Option<ShellType>, pub shell_type: Option<ShellType>,
} }
/// 输出流类型(回调 on_output 用) /// 执行 Shell 命令
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamKind {
Stdout,
Stderr,
}
impl StreamKind {
/// 序列化为稳定字符串标识(emit 事件 stream 字段用)
pub fn as_str(&self) -> &'static str {
match self {
StreamKind::Stdout => "stdout",
StreamKind::Stderr => "stderr",
}
}
}
/// 构造已配置好(stdio piped + kill_on_drop + CREATE_NO_WINDOW + cwd + env)的子进程 Command。
/// ///
/// execute() 与 execute_streaming() 共用同一构造逻辑(单真相源,DRY): /// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
/// shell 类型选择 / kill_on_drop / Windows 无窗 / cwd / env 全在此。差异仅在后续如何消费 stdout/stderr。 /// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
fn build_command(request: ShellRequest) -> tokio::process::Command { pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
let shell_type = request.shell_type.unwrap_or_default(); let shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type { let mut cmd = match shell_type {
ShellType::PowerShell => { ShellType::PowerShell => {
@@ -376,13 +140,13 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
} }
}; };
// kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。 // CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程, // 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。 // 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。 // 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
cmd.kill_on_drop(true); cmd.kill_on_drop(true);
// Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。 // B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。 // CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。 // tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
#[cfg(windows)] #[cfg(windows)]
@@ -397,185 +161,23 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
for (key, value) in &request.env { for (key, value) in &request.env {
cmd.env(key, value); cmd.env(key, value);
} }
// #8 ANSI 裸露根治(源头抑制):非交互管道下多数 CLI 仍探测 TERM 发色码,cargo/ls 等
// 输出 \x1b[Nm 原样透传前端显示为 ␛[7m 乱码。注入 NO_COLOR(https://no-color.org 标准)+
// TERM=dumb+FORCE_COLOR=0 三重信号,主流工具链(cargo/npm/git/ls/pytest)管道下停发色码。
// 调用方显式传同名 env 时以调用方为准(后写覆盖,见上循环顺序)。
cmd.env("NO_COLOR", "1");
cmd.env("TERM", "dumb");
cmd.env("FORCE_COLOR", "0");
cmd
}
/// 执行 Shell 命令(等 exit 一次性返回,非流式) let output = match request.timeout_secs {
///
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
///
/// 需要执行中实时获取 stdout/stderr 行(如 run_command 进度展示)用 [`execute_streaming`]。
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
// 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
// 先取走 build_command 之外的引用字段(超时错误信息 + timeout 判定),再 move request
let command_for_err = request.command.clone();
let timeout_secs = request.timeout_secs;
let mut cmd = build_command(request);
let output = match timeout_secs {
Some(secs) => tokio::time::timeout( Some(secs) => tokio::time::timeout(
std::time::Duration::from_secs(secs), std::time::Duration::from_secs(secs),
cmd.output(), cmd.output(),
) )
.await .await
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??, .map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??,
None => cmd.output().await?, None => cmd.output().await?,
}; };
let duration = start.elapsed().as_millis() as u64; let duration = start.elapsed().as_millis() as u64;
Ok(ShellResult { Ok(ShellResult {
stdout: strip_ansi(&String::from_utf8_lossy(&output.stdout)), stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: strip_ansi(&String::from_utf8_lossy(&output.stderr)), stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code(), exit_code: output.status.code(),
duration_ms: duration, duration_ms: duration,
}) })
} }
/// 流式执行 Shell 命令 —— spawn 后逐行读 stdout/stderr,每行回调 on_output。
///
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
/// 后返回完整 ShellResult。
///
/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经
/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节
/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。
///
/// 4性:
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
/// - 简洁:与 execute() 共用 build_command(单真相源,shell/kill_on_drop/cwd/env 不重复)
/// - 可靠兜底:timeout_secs 仍生效(超时 drop future → kill_on_drop 杀进程,返回 Err);
/// on_output 回调 Err 不影响主流程(调用方 emit 失败静默吞)
/// - 健壮边界:stdout/stderr 各独立任务并发读,互不阻塞;无管道死锁(piped + 同时消费)
pub async fn execute_streaming<F>(request: ShellRequest, mut on_output: F) -> anyhow::Result<ShellResult>
where
F: FnMut(StreamKind, &str) + Send,
{
let start = std::time::Instant::now();
// 提交冻结机械闸(39 治理):同 execute,流式路径同样 spawn 前拦截
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
#[cfg(windows)]
let _ = probe_pwsh().await;
// 先取走引用字段,再 move request 进 build_command
let command_for_err = request.command.clone();
let timeout_secs = request.timeout_secs;
let mut cmd = build_command(request);
let inner = async {
let mut child = cmd.spawn()?;
// 取出 piped 的 stdout/stderr handle(None → 视为已关,读为空,不影响主流程)
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// mpsc 通道:读 task 把 (kind, line) 推过来,主 task 在 wait 期间 drain 并调 on_output。
// 用通道而非直接共享 on_output:FnMut 不可 clone,两读 task 无法各持一份;通道解耦读写,
// 回调集中在主 task 单点调用(顺序确定、无锁、回调内阻塞不影响读循环)。
let (tx, mut rx) = tokio::sync::mpsc::channel::<(StreamKind, String)>(64);
let mut tasks: Vec<tokio::task::JoinHandle<()>> = Vec::with_capacity(2);
if let Some(out) = stdout {
let tx = tx.clone();
tasks.push(tokio::spawn(async move {
let mut reader = BufReader::new(out).lines();
while let Ok(Some(line)) = reader.next_line().await {
if tx.send((StreamKind::Stdout, line)).await.is_err() {
break; // 接收端 drop(主 task 结束)→ 停止读
}
}
}));
}
if let Some(err) = stderr {
let tx = tx.clone();
tasks.push(tokio::spawn(async move {
let mut reader = BufReader::new(err).lines();
while let Ok(Some(line)) = reader.next_line().await {
if tx.send((StreamKind::Stderr, line)).await.is_err() {
break;
}
}
}));
}
// 主 task 不再 send → drop tx(读 task send 失败即退出)
drop(tx);
// 完整输出累积(主 task 单点写,无锁)。
let mut stdout_buf = String::new();
let mut stderr_buf = String::new();
// wait + drain 并行:边等进程退出边消费输出行(防管道写满阻塞致子进程 hang)。
let wait_fut = child.wait();
tokio::pin!(wait_fut);
let status: std::process::ExitStatus = loop {
tokio::select! {
// 进程退出 → 跳出循环,继续 drain 通道内残余行
status = &mut wait_fut => {
let status = status?;
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
while let Some((kind, line)) = rx.recv().await {
let clean = strip_ansi(&line);
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
}
on_output(kind, &clean);
}
break status;
}
// 收到一行 → 累积 + 回调
Some((kind, line)) = rx.recv() => {
let clean = strip_ansi(&line);
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
}
on_output(kind, &clean);
}
}
};
// 防御性 join 读 task(此时必已 EOF 退出,仅保险;失败静默不阻断)
for t in tasks {
let _ = t.await;
}
Ok::<ShellResult, anyhow::Error>(ShellResult {
stdout: stdout_buf,
stderr: stderr_buf,
exit_code: status.code(),
duration_ms: 0, // 外层统一填
})
};
let result = match timeout_secs {
Some(secs) => tokio::time::timeout(std::time::Duration::from_secs(secs), inner)
.await
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
None => inner.await?,
};
let duration = start.elapsed().as_millis() as u64;
Ok(ShellResult {
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exit_code,
duration_ms: duration,
})
}
+1 -79
View File
@@ -9,7 +9,7 @@
//! //!
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。 //! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
use df_execute::shell::{execute, execute_streaming, ShellRequest, ShellType, StreamKind}; use df_execute::shell::{execute, ShellRequest, ShellType};
use std::collections::HashMap; use std::collections::HashMap;
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh) /// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
@@ -159,81 +159,3 @@ async fn execute_working_dir() {
// 清理 // 清理
let _ = std::fs::remove_dir_all(&tmp_for_cleanup); let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
} }
// ============================================================
// execute_streaming 流式测试
// ============================================================
/// 流式:stdout 多行逐行回调,且 ShellResult 完整(行数对齐 + exit_code=0)。
///
/// 治 run_command 黑盒:验证 spawn 后逐行回调 vs 一次性返回的等价性(行内容 + 完整结果)。
#[tokio::test]
async fn streaming_stdout_lines_callback() {
// 多行输出:Cmd 用多个 echo(用 & 串联无依赖),Sh 用 printf 多行
let cmd = if cfg!(windows) {
"@echo line1 & @echo line2 & @echo line3"
} else {
"printf 'line1\\nline2\\nline3\\n'"
};
let mut lines: Vec<(StreamKind, String)> = Vec::new();
let res = execute_streaming(req(cmd), |kind, line| {
lines.push((kind, line.to_string()));
})
.await
.expect("execute_streaming 应返回 Ok");
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
// stdout 应含三行(line1/line2/line3)
assert!(res.stdout.contains("line1"), "stdout 应含 line1,实际: {:?}", res.stdout);
assert!(res.stdout.contains("line3"), "stdout 应含 line3,实际: {:?}", res.stdout);
// 回调收到的 stdout 行应含三行(过滤 stderr 干扰:Cmd 无 stderr,Sh 无 stderr)
let stdout_lines: Vec<&String> = lines.iter()
.filter(|(k, _)| *k == StreamKind::Stdout)
.map(|(_, l)| l)
.collect();
assert!(
stdout_lines.iter().any(|l| l.contains("line1")),
"回调应收到含 line1 的 stdout 行,实际: {:?}", stdout_lines
);
assert!(
stdout_lines.iter().any(|l| l.contains("line3")),
"回调应收到含 line3 的 stdout 行,实际: {:?}", stdout_lines
);
}
/// 流式:超时仍生效(timeout_secs=1 + 长睡命令,返回 Err)。
#[tokio::test]
async fn streaming_timeout_returns_err() {
let sleep_cmd = if cfg!(windows) {
"ping -n 5 127.0.0.1 > nul".to_string()
} else {
"sleep 5".to_string()
};
let request = ShellRequest {
command: sleep_cmd,
working_dir: None,
env: HashMap::new(),
timeout_secs: Some(1),
shell_type: Some(default_shell()),
};
let result = execute_streaming(request, |_, _| {}).await;
assert!(result.is_err(), "超时应返回 Err,实际: {:?}", result.as_ref().err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
"错误信息应含超时提示,实际: {}",
msg
);
}
/// 流式:非零退出仍返回 Ok + exit_code 非 0(对齐 execute 语义)。
#[tokio::test]
async fn streaming_nonzero_exit() {
let mut callbacks = 0u32;
let res = execute_streaming(req("exit 1"), |_, _| { callbacks += 1; })
.await
.expect("非零退出应仍返回 Ok");
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0");
// exit 1 无输出,回调可为 0 次(无行)——不强制断言次数,只确认无 panic
let _ = callbacks;
}
+30 -93
View File
@@ -7,13 +7,13 @@
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式, //! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。 //! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
//! //!
//! LLM prompt 构造与 JSON 解析在后续接入:[`AdversarialEngine::evaluate_with_llm`] //! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON, //! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。 //! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
//! //!
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至 //! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口, //! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
//! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径( //! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径(
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。 //! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
use std::sync::Arc; use std::sync::Arc;
@@ -33,8 +33,8 @@ pub use crate::adversarial_helpers::{
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation, AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
}; };
use crate::adversarial_helpers::{ use crate::adversarial_helpers::{
SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt, SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval,
enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level, priority_label,
}; };
/// 对抗评估引擎 /// 对抗评估引擎
@@ -42,7 +42,7 @@ pub struct AdversarialEngine {
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。 /// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。 /// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
provider: Option<Arc<dyn LlmProvider>>, provider: Option<Arc<dyn LlmProvider>>,
/// 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型; /// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model /// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
/// (与接入前行为一致,平稳过渡)。 /// (与接入前行为一致,平稳过渡)。
model_pool: Vec<ModelConfig>, model_pool: Vec<ModelConfig>,
@@ -54,7 +54,7 @@ impl AdversarialEngine {
Self { provider: Some(provider), model_pool: Vec::new() } Self { provider: Some(provider), model_pool: Vec::new() }
} }
/// 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。 /// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self { pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
Self { provider: Some(provider), model_pool } Self { provider: Some(provider), model_pool }
} }
@@ -97,19 +97,17 @@ impl AdversarialEngine {
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险, /// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。 /// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
/// ///
/// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`] /// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与
/// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时, /// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
/// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> { async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea); let prompt = build_adversarial_prompt(idea);
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。 // F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl // 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
// 回填自身 default_model(与接入前行为一致,平稳过渡)。 // 回填自身 default_model(与接入前行为一致,平稳过渡)。
let eval_req = df_ai::router::TaskRequirements { let eval_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai_core::model::Modality::Text], modalities: vec![df_ai_core::model::Modality::Text],
needs_tool_use: false, needs_tool_use: false,
estimated_context: 0, estimated_context: 0,
tier: None,
}; };
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default(); let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
let request = df_ai_core::provider::CompletionRequest { let request = df_ai_core::provider::CompletionRequest {
@@ -135,9 +133,7 @@ impl AdversarialEngine {
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1], // 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。 // 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
// ARC-260618-01-e: 解析后做自洽性校验(final_score 与 recommendation / parse_llm_eval(&resp.text, &idea.id)
// analyst.final_assessment 矛盾时按 final_score 修正,见 enforce_score_consistency)。
Ok(enforce_score_consistency(parse_llm_eval(&resp.text, &idea.id)?))
} }
/// 启发式评估(基于评分与内容信号,稳定有区分度) /// 启发式评估(基于评分与内容信号,稳定有区分度)
@@ -148,7 +144,7 @@ impl AdversarialEngine {
let positive = self.generate_positive_argument(idea, &scores)?; let positive = self.generate_positive_argument(idea, &scores)?;
let negative = self.generate_negative_argument(idea, &scores)?; let negative = self.generate_negative_argument(idea, &scores)?;
let analyst = self.analyst_analysis(idea, &scores)?; let analyst = self.analyst_analysis(idea, &scores)?;
let recommendation = recommendation_for_level(&analyst.final_assessment); let recommendation = self.recommendation_for(&analyst.final_assessment);
Ok(AdversarialEval { Ok(AdversarialEval {
idea_id: idea.id.clone(), idea_id: idea.id.clone(),
@@ -243,8 +239,13 @@ impl AdversarialEngine {
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成 /// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> { fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
// 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移 let final_assessment = match scores.overall {
let final_assessment = assessment_for_score(scores.overall); x if x >= 7.5 => AssessmentLevel::StrongGo,
x if x >= 6.0 => AssessmentLevel::Recommended,
x if x >= 4.5 => AssessmentLevel::Conditional,
x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
let mut strengths = Vec::new(); let mut strengths = Vec::new();
if scores.impact >= 6.0 { if scores.impact >= 6.0 {
@@ -303,12 +304,22 @@ impl AdversarialEngine {
final_assessment, final_assessment,
}) })
} }
/// 评估等级 → 最终建议
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::adversarial_helpers::recommendation_for_score;
use crate::capture::Idea; use crate::capture::Idea;
use crate::scoring::ScoringEngine; use crate::scoring::ScoringEngine;
use df_types::types::{IdeaStatus, Priority}; use df_types::types::{IdeaStatus, Priority};
@@ -410,7 +421,7 @@ mod tests {
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// LLM 路径测试 // LLM 路径测试(F-260614-03)
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。 /// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
@@ -582,78 +593,4 @@ mod tests {
assert!((eval.final_score - 7.0).abs() < 1e-9); assert!((eval.final_score - 7.0).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::Soon); assert_eq!(eval.recommendation, Recommendation::Soon);
} }
/// 自洽性校验(ARC-260618-01-e 已实施): LLM 高分配 Monitor/Defer → 按 final_score 修正
/// 为 ImmediateAction/StrongGo,仅字段修正不整体降级启发式。
#[tokio::test]
async fn a13_llm_inconsistent_high_score_corrected() {
let idea = make_idea("矛盾测试", "高分却给 Monitor", Priority::High, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.8},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.3},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Defer"},
"final_score": 8.5,
"recommendation": "Monitor"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a13] 高分配 Monitor/Defer → 修正");
println!(" final_score={:.2} (高分)", eval.final_score);
println!(" recommendation={:?} (期望 ImmediateAction)", eval.recommendation);
println!(" final_assessment={:?} (期望 StrongGo)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅字段矛盾不整体降级");
assert!((eval.final_score - 8.5).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
}
/// 自洽性校验: LLM 低分配 ImmediateAction/StrongGo → 按 final_score 修正为 Monitor/Defer。
#[tokio::test]
async fn a14_llm_inconsistent_low_score_corrected() {
let idea = make_idea("低分矛盾", "低分却给 ImmediateAction", Priority::Medium, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.9},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.1},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"StrongGo"},
"final_score": 2.0,
"recommendation": "ImmediateAction"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a14] 低分配 ImmediateAction/StrongGo → 修正");
println!(" final_score={:.2} (低分)", eval.final_score);
println!(" recommendation={:?} (期望 Monitor)", eval.recommendation);
println!(" final_assessment={:?} (期望 Defer)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm);
assert!((eval.final_score - 2.0).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::Monitor);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::Defer);
}
/// 自洽性校验分档单测: 与启发式 analyst_analysis 同一分档(阈值边界逐一覆盖)。
#[test]
fn consistency_banding_matches_heuristic() {
assert_eq!(assessment_for_score(9.0), AssessmentLevel::StrongGo);
assert_eq!(assessment_for_score(7.5), AssessmentLevel::StrongGo); // 上界含
assert_eq!(assessment_for_score(7.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(6.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(5.0), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.5), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(3.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(2.9), AssessmentLevel::Defer);
assert_eq!(assessment_for_score(0.0), AssessmentLevel::Defer);
assert_eq!(recommendation_for_score(9.0), Recommendation::ImmediateAction);
assert_eq!(recommendation_for_score(6.5), Recommendation::Soon);
assert_eq!(recommendation_for_score(4.8), Recommendation::WithResources);
assert_eq!(recommendation_for_score(3.5), Recommendation::ResearchMore);
assert_eq!(recommendation_for_score(1.0), Recommendation::Monitor);
}
} }
+5 -69
View File
@@ -1,9 +1,8 @@
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。 //! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
//! //!
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造、JSON 解析与自洽性校验 //! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用
//! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm //! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
//! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在 //! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
//! `adversarial.rs`,二者经 [`use`] 互相引用。
//! //!
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部 //! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等) //! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
@@ -85,7 +84,7 @@ pub enum Recommendation {
} }
// ============================================================ // ============================================================
// LLM 对抗评估 — prompt 构造 / JSON 解析 // LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03)
// ============================================================ // ============================================================
/// LLM 角色 / 输出契约的系统级约束。 /// LLM 角色 / 输出契约的系统级约束。
@@ -296,7 +295,7 @@ pub(crate) fn extract_json(text: &str) -> String {
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde, // (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。) // 必然失败降级启发式,即便正则本可救回。)
} }
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象。 // 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象(CR-40-2)。
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。 // (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。 // 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new(); static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
@@ -337,69 +336,6 @@ pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
} }
} }
// ============================================================
// 自洽性校验(ARC-260618-01-e 已实施)— 综合评分分档 / 修正矛盾字段
// ============================================================
/// 评估等级 → 最终建议(与启发式 recommendation 映射同一套,收敛于此单点实现)。
pub(crate) fn recommendation_for_level(level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
/// 综合评分 → 评估等级(分档与启发式 analyst_analysis 一致;自洽性校验复用,单源防两处漂移)。
pub(crate) fn assessment_for_score(score: f64) -> AssessmentLevel {
if score >= 7.5 {
AssessmentLevel::StrongGo
} else if score >= 6.0 {
AssessmentLevel::Recommended
} else if score >= 4.5 {
AssessmentLevel::Conditional
} else if score >= 3.0 {
AssessmentLevel::Revised
} else {
AssessmentLevel::Defer
}
}
/// 综合评分 → 最终建议(先分档再映射)。
pub(crate) fn recommendation_for_score(score: f64) -> Recommendation {
recommendation_for_level(&assessment_for_score(score))
}
/// 自洽性校验: LLM 解析结果可能 final_score 与 recommendation / analyst.final_assessment
/// 互相矛盾(如高分配 Monitor、低分配 ImmediateAction),parse 只校验枚举合法性不校验语义。
/// 以 final_score 为准重新判定这两个结构化字段消除矛盾;LLM 生成的论点/证据/summary 等
/// 自由文本原样保留。不改 LLM 调用/输出结构,只在解析后修正字段值(ARC-260618-01-e)。
pub(crate) fn enforce_score_consistency(mut eval: AdversarialEval) -> AdversarialEval {
let expected_rec = recommendation_for_score(eval.final_score);
let expected_level = assessment_for_score(eval.final_score);
if eval.recommendation != expected_rec {
tracing::warn!(
score = eval.final_score,
from = ?eval.recommendation,
to = ?expected_rec,
"LLM 评估自洽性修正: recommendation 与 final_score 矛盾,按 final_score 重新判定"
);
eval.recommendation = expected_rec;
}
if eval.analyst.final_assessment != expected_level {
tracing::warn!(
score = eval.final_score,
from = ?eval.analyst.final_assessment,
to = ?expected_level,
"LLM 评估自洽性修正: final_assessment 与 final_score 矛盾,按 final_score 重新判定"
);
eval.analyst.final_assessment = expected_level;
}
eval
}
pub(crate) fn priority_label(p: &Priority) -> &'static str { pub(crate) fn priority_label(p: &Priority) -> &'static str {
match p { match p {
Priority::Critical => "紧急", Priority::Critical => "紧急",
+1
View File
@@ -1,5 +1,6 @@
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。 //! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。 //! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
use serde::Serialize; use serde::Serialize;
+2
View File
@@ -125,6 +125,8 @@ impl ScoringEngine {
} }
} }
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。 /// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
/// ///
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单—— /// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
-29
View File
@@ -1,29 +0,0 @@
[package]
name = "df-im-gateway"
version = "0.1.0"
edition = "2021"
description = "IM 网关:飞书/企微/钉钉/Web 四端远程接入 DevFlow AI Chat(WS Commander 连 u-relay)"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
df-tunnel = { path = "../df-tunnel" }
# HTTP 静态托管 + WS 服务端(P1:Web Chat 页面由网关直接托管,/ 与 /ws 同端口)
axum = { version = "0.7", features = ["ws"] }
# WS 客户端(commander 连 u-relay;与 df-tunnel 同款本地版本声明,workspace 无这几项)
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
futures-util = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 飞书 SDK(长连接 + 归一化消息;channel 自动连带 ws+messaging)
larksuite-oapi-sdk-rs = { version = "0.3.12", features = ["channel"] }
[dev-dependencies]
tempfile = "3"
@@ -1,22 +0,0 @@
[Unit]
Description=DevFlow IM Gateway (飞书/企微/钉钉/Web 四端远程接入)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/df-im-gateway
ExecStart=/opt/df-im-gateway/df-im-gateway
Restart=on-failure
RestartSec=3
# ── 运行时 env(与本地 config.rs 对齐;token 与 u-relay 同源)──
Environment=GATEWAY_RELAY_URL=ws://127.0.0.1:9180/ws/commander
Environment=GATEWAY_RELAY_TOKEN=devflow-relay-default-token
Environment=GATEWAY_DEFAULT_CHANNEL=chan-work
Environment=GATEWAY_DATA_DIR=/opt/df-im-gateway/data
Environment=RUST_LOG=info,df_im_gateway=debug
StandardOutput=append:/opt/df-im-gateway/gateway.log
StandardError=append:/opt/df-im-gateway/gateway.err.log
[Install]
WantedBy=multi-user.target
@@ -1,23 +0,0 @@
# df-im-gateway nginx 反代片段(与 u-relay 同机,复用 *.1216.top 通配符证书)
# 放入 /etc/nginx/conf.d/ 后 nginx -t && systemctl reload nginx
# Web Chat 页面 + WS 同端口 9191(网关 axum 直接托管)
server {
listen 443 ssl;
server_name im.1216.top;
ssl_certificate /etc/nginx/sslkey/_.1216.top.pem;
ssl_certificate_key /etc/nginx/sslkey/_.1216.top.key;
location / {
proxy_pass http://127.0.0.1:9191;
proxy_http_version 1.1;
# WS 升级头(与 u-relay 反代同款)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 流式响应不缓冲(SSE/流式文本)
proxy_buffering off;
proxy_read_timeout 300s;
}
}
@@ -1,86 +0,0 @@
# df-im-gateway 运维手册
> 四端 IM 网关(飞书/企微/钉钉/Web)远程接入 DevFlow AI Chat。
> 部署拓扑:与 u-relay 同机(39.99.243.191),WS Commander 经 u-relay 连桌面端 df-tunnel。
## 一、拓扑与环境
```
飞书/企微/钉钉/Web ──► df-im-gateway(9191, 同机)
│ ws://127.0.0.1:9180/ws/commander (Commander 身份)
▼
u-relay(9180, nginx 前置 wss://u-relay.1216.top)
│ /ws/agent
▼
桌面端 DevFlow(df-tunnel Agent)
```
| 项 | 值 |
|---|---|
| 服务器 | 39.99.243.191:22 (root 免密, Ubuntu 22.04) |
| 部署路径 | /opt/df-im-gateway/(二进制 + data/ + 日志) |
| 监听端口 | 9191(本机明文,nginx 前置 im.1216.top → wss) |
| systemd | df-im-gateway.service(Restart=on-failure, 开机自启) |
| token | devflow-relay-default-token(与 u-relay/桌面端三端同源) |
| 数据库 | /opt/df-im-gateway/data/{sessions.db, audit.db}(SQLite WAL) |
## 二、日常运维
```bash
# 状态 / 日志 / 重启
systemctl status df-im-gateway
journalctl -u df-im-gateway -n 50 --no-pager
systemctl restart df-im-gateway
# 端口验证
ss -tlnp | grep 9191
# 审计查询(最近 20 条)
sqlite3 /opt/df-im-gateway/data/audit.db \
"SELECT datetime(ts/1000,'unixepoch','+8 hours'), direction, action, chat_id, outcome FROM tunnel_audit ORDER BY id DESC LIMIT 20;"
# 会话映射表
sqlite3 /opt/df-im-gateway/data/sessions.db "SELECT * FROM session_map;"
```
⚠️ systemd 托管,勿 kill Main PID、勿 nohup 起野进程;重启一律 `systemctl restart`。
## 三、发布流程
```powershell
# 本机(Windows)一键发布:单测 → musl 交叉编译 → scp → 原子切换 → 9191 验证 → 失败自动回滚
powershell -File E:\wk-lab\devflow\crates\df-im-gateway\scripts\publish-im-gateway.ps1
# 仅构建不部署: 加 -SkipDeploy;仅部署不构建: 加 -SkipBuild
```
首次部署需手动铺底:
```bash
scp crates/df-im-gateway/deploy/df-im-gateway.service root@39.99.243.191:/etc/systemd/system/
ssh root@39.99.243.191 'systemctl daemon-reload && systemctl enable df-im-gateway'
# nginx(可选,Web 端公网访问时): scp deploy/nginx-im.1216.top.conf → /etc/nginx/conf.d/ && nginx -t && systemctl reload nginx
```
## 四、回滚
```bash
cd /opt/df-im-gateway
systemctl stop df-im-gateway
LATEST=$(ls -t df-im-gateway.bak.* | head -1)
cp "$LATEST" df-im-gateway && chmod +x df-im-gateway
systemctl start df-im-gateway
```
## 五、配置变更
| env | 说明 | 改法 |
|---|---|---|
| GATEWAY_RELAY_TOKEN | 与 u-relay 三端同源,**改必须同步桌面端** | 改 unit Environment= → daemon-reload → restart |
| GATEWAY_DEFAULT_CHANNEL | 默认路由 channel | 同上 |
| GATEWAY_WHITELIST | 用户白名单(空=任何人可用,生产必设!) | 同上 |
| FEISHU_APP_ID/SECRET | 飞书长连接 | 同上 |
## 六、已知项
- token 仍为 MVP 单共享值(per-user 是 S2 计划)
- 日志 gateway.log 追加无轮转,可配 logrotate 或定期 truncate
- 四端 E2E 联调依赖各平台凭证到位(飞书/钉钉/企微)
@@ -1,70 +0,0 @@
# df-im-gateway 一键发布脚本(复用 publish-u-relay 模式)
# 用法: powershell -File scripts\publish-im-gateway.ps1 [-SkipBuild] [-SkipDeploy]
# 流程: cargo test → musl 交叉编译 → scp 上传(.new 中转) → systemd 原子切换 → 9191 验证
param(
[switch]$SkipBuild,
[switch]$SkipDeploy
)
$ErrorActionPreference = "Stop"
$Repo = "E:/wk-lab/devflow"
$Target = "x86_64-unknown-linux-musl"
$Server = "root@39.99.243.191"
$RemoteDir = "/opt/df-im-gateway"
$ServiceName = "df-im-gateway"
$LocalBin = "$Repo/target-musl/$Target/release/df-im-gateway"
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
if (-not $SkipBuild) {
Step "Step 0: 单测基线(df-im-gateway)"
cargo test -p df-im-gateway --manifest-path "$Repo/Cargo.toml"
if ($LASTEXITCODE -ne 0) { throw "单测失败,中止发布" }
Step "Step 1: musl 交叉编译(rust-lld self-contained)"
$env:CARGO_TARGET_DIR = "$Repo/target-musl"
cargo build --release -p df-im-gateway --manifest-path "$Repo/Cargo.toml" --target $Target
if ($LASTEXITCODE -ne 0) { throw "编译失败,中止发布" }
Remove-Item Env:\CARGO_TARGET_DIR -ErrorAction SilentlyContinue
if (-not (Test-Path $LocalBin)) { throw "产物不存在: $LocalBin" }
$size = [math]::Round((Get-Item $LocalBin).Length / 1MB, 1)
Write-Host "产物: $LocalBin (${size}MB)"
}
if ($SkipDeploy) { Write-Host "`nSkipDeploy: 仅构建完成"; exit 0 }
Step "Step 2: 服务器预检 + 备份"
ssh $Server "mkdir -p $RemoteDir && test -f $RemoteDir/df-im-gateway && cp $RemoteDir/df-im-gateway $RemoteDir/df-im-gateway.bak.`$(date +%Y%m%d%H%M%S) || echo '首次部署,无备份'"
Step "Step 3: 上传(.new 中转防 Text file busy)"
scp $LocalBin "${Server}:$RemoteDir/df-im-gateway.new"
if ($LASTEXITCODE -ne 0) { throw "scp 失败" }
Step "Step 4: 原子切换 + systemd 重启"
ssh $Server "bash -s" @'
set -e
cd /opt/df-im-gateway
systemctl stop df-im-gateway 2>/dev/null || true
mv -f df-im-gateway.new df-im-gateway
chmod +x df-im-gateway
systemctl start df-im-gateway
sleep 1
systemctl is-active df-im-gateway
'@
if ($LASTEXITCODE -ne 0) { throw "切换/启动失败,检查 journalctl -u df-im-gateway"; }
Step "Step 5: 健康验证"
ssh $Server "ss -tlnp | grep 9191 && journalctl -u df-im-gateway -n 5 --no-pager | sed 's/\x1b\[[0-9;]*m//g'"
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ 9191 未监听,执行回滚..." -ForegroundColor Red
ssh $Server "bash -s" @'
set -e
cd /opt/df-im-gateway
systemctl stop df-im-gateway
LATEST=$(ls -t df-im-gateway.bak.* 2>/dev/null | head -1)
if [ -n "$LATEST" ]; then cp "$LATEST" df-im-gateway; chmod +x df-im-gateway; systemctl start df-im-gateway; echo "回滚完成: $LATEST"; else echo "无备份可回滚(首次部署失败,保持停止)"; fi
'@
throw "健康检查失败,已回滚"
}
Write-Host "`n✅ df-im-gateway 发布完成 (9191)" -ForegroundColor Green
-284
View File
@@ -1,284 +0,0 @@
//! 平台适配器(P0 预留项 ④:trait 分层,统一 InboundEvent/OutboundAction)
//!
//! 网关核心只认识这两个类型,四端差异(飞书卡片/企微 Markdown/钉钉卡片/Web JSON)收敛在各 adapter。
//! P0 只实现 Web,飞书/企微/钉钉留 trait 位(P1/P2/P4 各自落地,不动核心)。
use async_trait::async_trait;
use tokio::sync::mpsc;
/// 平台入站事件(IM 平台 → 网关,已归一化)
#[derive(Debug, Clone)]
pub enum InboundEvent {
/// 用户发消息
Message {
platform: String,
chat_id: String,
user_id: String,
text: String,
},
/// 会话切换(/new 或菜单)
NewSession { platform: String, chat_id: String },
/// 审批按钮回调
Approval {
platform: String,
chat_id: String,
approval_id: String,
approved: bool,
},
}
/// 平台出站动作(网关 → IM 平台,已归一化)
#[derive(Debug, Clone)]
pub enum OutboundAction {
/// 发/更新 AI 流式文本(全量内容,适配器自选卡片或文本)
StreamText { chat_id: String, text: String, done: bool },
/// 发送审批卡片(同意/拒绝按钮)
ApprovalCard { chat_id: String, approval_id: String, title: String, detail: String },
/// 发系统提示
Notice { chat_id: String, text: String },
}
/// 平台适配器
#[async_trait]
pub trait PlatformAdapter: Send + Sync {
/// 平台名(feishu/wecom/dingtalk/web,与 session_map.platform 对应)。
/// 核心循环经 platform_index 注册表间接使用。
#[allow(dead_code)]
fn name(&self) -> &str;
/// 启动适配器(长连接/回调服务器/WS),入站事件从 events 通道流出。阻塞运行。
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()>;
/// 出站动作分发(网关核心调用)
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()>;
/// 清理平台的流式消息状态(网关在新建会话映射时调用,兜底上轮异常中止的残留)。
/// 默认空实现:无流式覆写语义的平台(Web 直接推帧)无需关心。
async fn reset_stream(&self, _chat_id: &str) {}
}
/// Web Chat 适配器(P1:axum 同端口托管静态页 + WS,浏览器直连)
///
/// - `GET /` → 内嵌单文件聊天页(include_str!,零运行时文件依赖)
/// - `GET /ws` → WS 升级,协议见 handle_web_ws(与 P0 纯 WS 版完全一致)
pub mod web {
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::{Html, Response};
use axum::routing::get;
use axum::Router;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{mpsc, Mutex};
use super::{InboundEvent, OutboundAction, PlatformAdapter};
/// 内嵌聊天页(单文件,无外部依赖,构建期打进二进制)
const INDEX_HTML: &str = include_str!("../web/index.html");
/// 浏览器连接注册表(chat_id → ws 发送通道)
type WsRegistry = Arc<Mutex<HashMap<String, mpsc::Sender<WsMessage>>>>;
/// axum 路由共享状态(注册表 + 核心入站通道)
#[derive(Clone)]
struct WsState {
registry: WsRegistry,
events: mpsc::Sender<InboundEvent>,
}
/// Web 适配器:HTTP+WS 服务监听 0.0.0.0:9191
#[derive(Clone)]
pub struct WebAdapter {
registry: WsRegistry,
}
impl WebAdapter {
pub fn new() -> Self {
Self { registry: Arc::new(Mutex::new(HashMap::new())) }
}
}
impl Default for WebAdapter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl PlatformAdapter for WebAdapter {
fn name(&self) -> &str {
"web"
}
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()> {
let state = WsState { registry: self.registry.clone(), events };
let app = Router::new()
.route("/", get(|| async { Html(INDEX_HTML) }))
.route("/ws", get(ws_upgrade))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:9191").await?;
tracing::info!("[web] HTTP+WS 服务监听 http://0.0.0.0:9191 (页面 / , WS /ws)");
axum::serve(listener, app).await?;
Ok(())
}
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
match action {
OutboundAction::StreamText { chat_id, text, done } => {
let frame = serde_json::json!({
"type": if done { "stream_done" } else { "stream_delta" },
"text": text,
});
self.send_ws(&chat_id, &frame.to_string()).await
}
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
let frame = serde_json::json!({
"type": "approval",
"approval_id": approval_id,
"title": title,
"detail": detail,
});
self.send_ws(&chat_id, &frame.to_string()).await
}
OutboundAction::Notice { chat_id, text } => {
let frame = serde_json::json!({ "type": "notice", "text": text });
self.send_ws(&chat_id, &frame.to_string()).await
}
}
}
}
impl WebAdapter {
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
let registry = self.registry.lock().await;
if let Some(tx) = registry.get(chat_id) {
tx.send(WsMessage::Text(text.to_string())).await?;
}
Ok(())
}
}
/// WS 升级入口
async fn ws_upgrade(State(state): State<WsState>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| async move {
if let Err(e) = handle_web_ws(socket, state).await {
tracing::warn!("[web] WS 会话异常结束: {e:#}");
}
})
}
/// 单个浏览器 WS 会话。
///
/// 协议(浏览器 → 网关):
/// ```json
/// 首帧绑定: {"type":"hello","chat_id":"...","user_id":"..."}
/// 消息: {"type":"message","text":"..."}
/// 重置会话: {"type":"new_session"}
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
/// ```
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
async fn handle_web_ws(socket: WebSocket, state: WsState) -> anyhow::Result<()> {
let (mut sink, mut upstream) = socket.split();
// 首帧 hello 绑定 chat_id
let first = match upstream.next().await {
Some(Ok(m)) => m,
_ => {
tracing::warn!("[web] 浏览器连接即断(无首帧)");
return Ok(());
}
};
let hello: serde_json::Value = match &first {
WsMessage::Text(t) => serde_json::from_str(t).unwrap_or_default(),
_ => {
tracing::warn!("[web] 首帧非文本,拒绝绑定");
return Ok(());
}
};
let chat_id = hello
.get("chat_id")
.and_then(|v| v.as_str())
.unwrap_or("web-anon")
.to_string();
let user_id = hello.get("user_id").and_then(|v| v.as_str()).unwrap_or("anon").to_string();
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
let (ws_tx, mut ws_rx) = mpsc::channel::<WsMessage>(64);
state.registry.lock().await.insert(chat_id.clone(), ws_tx);
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
let forward = tokio::spawn(async move {
while let Some(frame) = ws_rx.recv().await {
if sink.send(frame).await.is_err() {
break;
}
}
});
let result: anyhow::Result<()> = async {
while let Some(msg) = upstream.next().await {
let msg = msg?;
match msg {
WsMessage::Text(t) => {
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
match v.get("type").and_then(|x| x.as_str()).unwrap_or("") {
"message" => {
let text =
v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
state
.events
.send(InboundEvent::Message {
platform: "web".into(),
chat_id: chat_id.clone(),
user_id: user_id.clone(),
text,
})
.await
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
}
"new_session" => {
state
.events
.send(InboundEvent::NewSession {
platform: "web".into(),
chat_id: chat_id.clone(),
})
.await
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
}
"approval" => {
let approval_id =
v.get("approval_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
let approved =
v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
state
.events
.send(InboundEvent::Approval {
platform: "web".into(),
chat_id: chat_id.clone(),
approval_id,
approved,
})
.await
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
}
_ => {}
}
}
WsMessage::Close(_) => break,
_ => {}
}
}
Ok(())
}
.await;
state.registry.lock().await.remove(&chat_id);
forward.abort();
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
result
}
}
-236
View File
@@ -1,236 +0,0 @@
//! 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录(SQLite + WAL,与 SessionStore 同机库文件)
//!
//! 设计口径:
//! - **append-only**:只写不改,无 UPDATE/DELETE——审计的公信力来自不可篡改。
//! - **双向打点**:inbound(IM 用户 → 网关)与 outbound(网关/桌面端 → IM)各记一行,
//! 同一 request_id 关联请求与响应(排障时按 id 串链)。
//! - **失败不阻断**:审计写入失败仅 tracing::warn,绝不影响主链路(审计是增益非闸门)。
//! - 表:tunnel_audit(id 自增 / ts 毫秒 / direction / platform / chat_id / user_id /
//! conv_id / action / request_id / payload 截断 4KB / outcome ok|error|denied)。
//! payload 截断防长消息撑爆库;敏感凭证不经过此层(config 层已隔离)。
//!
//! 查询接口(运维用):recent 按时间倒序限量;by_request 串同请求双向链。
use std::path::Path;
use std::sync::Arc;
use rusqlite::Connection;
const MAX_PAYLOAD: usize = 4096;
/// 审计方向
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// IM 用户 → 网关(入站消息/审批点击/新会话)
Inbound,
/// 网关/桌面端 → IM(流式输出/审批卡片/通知)
Outbound,
/// 网关 → 桌面端(TunnelCommand 下发)
Command,
}
impl Direction {
fn as_str(self) -> &'static str {
match self {
Direction::Inbound => "inbound",
Direction::Outbound => "outbound",
Direction::Command => "command",
}
}
}
#[derive(Clone)]
pub struct AuditLog {
conn: Arc<std::sync::Mutex<Connection>>,
}
/// 打点参数(字段可选,None 写空串,保持列 NOT NULL 简化查询)
pub struct AuditEntry {
pub direction: Direction,
pub platform: String,
pub chat_id: String,
pub user_id: String,
pub conv_id: String,
/// 动作名:message/new_session/approval_click/approval_card/stream/completed/notice/send_cmd/approve_cmd
pub action: String,
/// 关联 ID:入站↔出站串链用(消息场景可用 chat+ts 派生,审批场景用 approval_id)
pub request_id: String,
pub payload: String,
/// ok / error / denied(白名单拒/无 channel)
pub outcome: String,
}
impl AuditLog {
/// 打开(或创建)审计库。默认与 sessions.db 同目录独立文件(隔离写放大)。
pub fn open(path: &Path) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tunnel_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
direction TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT '',
conv_id TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
request_id TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok'
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON tunnel_audit(ts);
CREATE INDEX IF NOT EXISTS idx_audit_request ON tunnel_audit(request_id);",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
/// 内存库(测试)
#[allow(dead_code)]
pub fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tunnel_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
direction TEXT NOT NULL, platform TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT '',
conv_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL,
request_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok');",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// 追加一条审计(失败仅告警不冒泡)
pub fn record(&self, e: AuditEntry) {
let payload = if e.payload.len() > MAX_PAYLOAD {
format!("{}…[截断]", &e.payload[..MAX_PAYLOAD])
} else {
e.payload
};
let result = (|| -> anyhow::Result<()> {
let conn = self.conn.lock().map_err(|err| anyhow::anyhow!("audit 锁中毒: {err}"))?;
conn.execute(
"INSERT INTO tunnel_audit (ts, direction, platform, chat_id, user_id, conv_id, action, request_id, payload, outcome) \
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
rusqlite::params![
Self::now_ms(), e.direction.as_str(), e.platform, e.chat_id,
e.user_id, e.conv_id, e.action, e.request_id, payload, e.outcome,
],
)?;
Ok(())
})();
if let Err(err) = result {
tracing::warn!("[audit] 写入失败(不影响主链路): {err}");
}
}
/// 最近 N 条(时间倒序,运维排障)
#[allow(dead_code)]
pub fn recent(&self, limit: usize) -> anyhow::Result<Vec<String>> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
let mut stmt = conn.prepare(
"SELECT ts, direction, platform, chat_id, user_id, conv_id, action, request_id, outcome \
FROM tunnel_audit ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map([limit as i64], |row| {
Ok(format!(
"{} [{}] {} plat={} chat={} user={} conv={} req={} outcome={}",
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(6)?,
row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?,
row.get::<_, String>(5)?, row.get::<_, String>(7)?, row.get::<_, String>(8)?,
))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// 按 request_id 串双向链(入站→命令→出站全链)
#[allow(dead_code)]
pub fn by_request(&self, request_id: &str) -> anyhow::Result<Vec<String>> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
let mut stmt = conn.prepare(
"SELECT ts, direction, action, outcome FROM tunnel_audit WHERE request_id = ?1 ORDER BY id ASC",
)?;
let rows = stmt.query_map([request_id], |row| {
Ok(format!(
"{} [{}] {} outcome={}",
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?,
))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(direction: Direction, action: &str, req: &str, outcome: &str) -> AuditEntry {
AuditEntry {
direction,
platform: "feishu".into(),
chat_id: "oc_123".into(),
user_id: "u_1".into(),
conv_id: "feishu:oc_123".into(),
action: action.into(),
request_id: req.into(),
payload: "hello".into(),
outcome: outcome.into(),
}
}
#[test]
fn record_and_recent_roundtrip() {
let log = AuditLog::open_in_memory().unwrap();
log.record(entry(Direction::Inbound, "message", "req-1", "ok"));
log.record(entry(Direction::Command, "send_cmd", "req-1", "ok"));
log.record(entry(Direction::Outbound, "stream", "req-1", "ok"));
log.record(entry(Direction::Inbound, "message", "req-2", "denied"));
let rows = log.recent(10).unwrap();
assert_eq!(rows.len(), 4);
// 倒序:最新在前
assert!(rows[0].contains("req-2"));
assert!(rows[0].contains("denied"));
let chain = log.by_request("req-1").unwrap();
assert_eq!(chain.len(), 3);
}
#[test]
fn payload_truncated() {
let log = AuditLog::open_in_memory().unwrap();
let mut e = entry(Direction::Inbound, "message", "req-x", "ok");
e.payload = "x".repeat(10_000);
log.record(e);
let conn_count = |log: &AuditLog| -> i64 {
let conn = log.conn.lock().unwrap();
conn.query_row("SELECT COUNT(*) FROM tunnel_audit", [], |r| r.get(0)).unwrap()
};
assert_eq!(conn_count(&log), 1);
let conn = log.conn.lock().unwrap();
let stored: String = conn
.query_row("SELECT payload FROM tunnel_audit", [], |r| r.get(0))
.unwrap();
assert!(stored.len() < 5_000);
assert!(stored.ends_with("…[截断]"));
}
#[test]
fn record_never_panics_on_poisoned_lock() {
let log = AuditLog::open_in_memory().unwrap();
// 毒锁:持锁 panic 使 Mutex poisoned,record 应 warn 而非 panic
let guard = log.conn.lock().unwrap();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
std::panic::panic_any("poison");
}))
.err();
drop(guard);
log.record(entry(Direction::Inbound, "message", "req-p", "ok")); // 不 panic 即通过
}
}
-189
View File
@@ -1,189 +0,0 @@
//! WS Commander 客户端(连 u-relay /ws/commander)
//!
//! 与桌面端 df-tunnel(Agent 角色)对称:网关作为 Commander 发 TunnelCommand、收 TunnelEvent。
//! 协议类型直接复用 df_tunnel::events(编译期一致)。
//!
//! 连接生命周期:hello {kind:"commander", channel_id, token} → (relay 校验) → 双向消息。
//! 断线重连:指数退避(1/2/4/8/16/30s 封顶,与桌面端 df-tunnel 同款策略)。
//!
//! 注:relay 对同 channel 多 Commander 的投递行为(P0 必验项 ①)待实测——
//! 过渡期约定同 channel 单活跃控制端。
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{info, warn};
use df_tunnel::events::{Hello, HelloKind, RelayControlError};
use df_tunnel::{TunnelCommand, TunnelEvent, TunnelMessage};
/// 指数退避序列(秒,封顶 30)
fn backoff_secs(attempt: u32) -> u64 {
[1, 2, 4, 8, 16, 30]
.get(attempt.min(5) as usize)
.copied()
.unwrap_or(30)
}
/// 启动 Commander 连接循环(阻塞直到 shutdown)。重连在内部循环,指令/事件双向透传。
///
/// * `relay_url` — ws://host:9180/ws/commander
/// * `channel_id` — 配对的桌面端 channel
/// * `token` — U_RELAY_TOKEN 同源
/// * `ev_tx` — 事件出口(桌面端 → 网关核心)
/// * `cmd_rx` — 指令入口(网关核心 → 桌面端)
/// * `shutdown` — 关闭信号
pub async fn run_commander(
relay_url: String,
channel_id: String,
token: String,
ev_tx: mpsc::Sender<TunnelEvent>,
mut cmd_rx: mpsc::Receiver<TunnelCommand>,
shutdown: tokio::sync::watch::Receiver<bool>,
) -> anyhow::Result<()> {
let mut attempt: u32 = 0;
loop {
if *shutdown.borrow() {
info!("[commander] 收到关闭信号,退出");
return Ok(());
}
match connect_once(&relay_url, &channel_id, &token, &ev_tx, &mut cmd_rx, shutdown.clone()).await {
Ok(()) => {
info!("[commander] 连接正常关闭");
return Ok(());
}
Err(e) => {
let wait = backoff_secs(attempt);
attempt += 1;
warn!("[commander] 连接断开({e}),{}s 后重连(第 {attempt} 次)", wait);
let mut shutdown_w = shutdown.clone();
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(wait)) => {}
_ = shutdown_w.changed() => return Ok(()),
}
}
}
}
}
/// 单次连接会话(hello 握手 + 消息循环),断开/出错返回 Err 触发外层重连
async fn connect_once(
relay_url: &str,
channel_id: &str,
token: &str,
ev_tx: &mpsc::Sender<TunnelEvent>,
cmd_rx: &mut mpsc::Receiver<TunnelCommand>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> anyhow::Result<()> {
let (ws, _resp) = tokio_tungstenite::connect_async(relay_url).await?;
let (mut sink, mut stream) = ws.split();
// hello 握手(kind=commander;字段与 u-relay::relay::Hello 严格一致)
let hello = Hello {
kind: HelloKind::Commander,
channel_id: channel_id.to_string(),
token: token.to_string(),
proto: 1,
};
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
// 等待 hello_ack(首条文本帧;control error 帧则报错)
let ack = tokio::time::timeout(Duration::from_secs(10), stream.next()).await
.map_err(|_| anyhow::anyhow!("hello_ack 10s 超时"))?
.ok_or_else(|| anyhow::anyhow!("连接在握手期关闭"))?;
match ack? {
Message::Text(t) => {
// relay 控制帧:{"kind":"control","error":"..."} → 握手被拒
if let Ok(ctrl_err) = serde_json::from_str::<RelayControlError>(&t) {
if ctrl_err.kind == "control" {
anyhow::bail!("握手被拒: {}", ctrl_err.error);
}
}
info!("[commander] hello_ack: channel={channel_id} 握手成功");
}
other => anyhow::bail!("hello_ack 非文本帧: {other:?}"),
}
// 心跳:30s ping(relay 90s 读死线)
let mut ping = tokio::time::interval(Duration::from_secs(30));
ping.tick().await; // 首个立即返回,跳过
// 消息循环
loop {
tokio::select! {
_ = shutdown.changed() => {
let _ = sink.close().await;
return Ok(());
}
_ = ping.tick() => {
sink.send(Message::Ping(vec![])).await?;
}
cmd = cmd_rx.recv() => {
let Some(cmd) = cmd else {
// 核心侧通道关闭:正常收尾
let _ = sink.close().await;
return Ok(());
};
let frame = TunnelMessage::Command(cmd);
sink.send(Message::Text(serde_json::to_string(&frame)?)).await?;
}
msg = stream.next() => {
match msg {
Some(Ok(Message::Text(t))) => {
match serde_json::from_str::<TunnelMessage>(&t) {
Ok(TunnelMessage::Event(e)) => {
if ev_tx.send(e).await.is_err() {
return Err(anyhow::anyhow!("核心事件通道关闭"));
}
}
Ok(TunnelMessage::Command(_)) => {
warn!("[commander] commander 收到 Command 帧(协议错向,忽略)");
}
Err(_) => warn!("[commander] 无法解析帧(非 TunnelMessage,忽略): {} 字节", t.len()),
}
}
Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; }
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e.into()),
None => return Err(anyhow::anyhow!("stream 结束")),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_sequence() {
assert_eq!(backoff_secs(0), 1);
assert_eq!(backoff_secs(1), 2);
assert_eq!(backoff_secs(2), 4);
assert_eq!(backoff_secs(3), 8);
assert_eq!(backoff_secs(4), 16);
assert_eq!(backoff_secs(5), 30);
assert_eq!(backoff_secs(99), 30, "封顶 30s");
}
#[test]
fn tunnel_message_roundtrip() {
let cmd = TunnelMessage::Command(TunnelCommand::Send {
conv_id: "c1".into(),
content: "你好".into(),
});
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\""));
let back: TunnelMessage = serde_json::from_str(&json).unwrap();
match back {
TunnelMessage::Command(TunnelCommand::Send { conv_id, content }) => {
assert_eq!(conv_id, "c1");
assert_eq!(content, "你好");
}
_ => panic!("roundtrip 失败"),
}
}
}
-143
View File
@@ -1,143 +0,0 @@
//! 网关配置(env 加载,凭证不进 git)
//!
//! 配置来源:环境变量 > 默认值(gateway.toml 文件加载为后续增强,当前未实现)。
//! u-relay token 与桌面端 df-tunnel 同源(env U_RELAY_TOKEN)。
use serde::Deserialize;
/// 网关全局配置
#[derive(Debug, Clone, Deserialize)]
pub struct GatewayConfig {
/// u-relay WS 地址(生产 u-relay.1216.top,本地 http://127.0.0.1:9180)
pub relay_url: String,
/// relay 配对 token(env U_RELAY_TOKEN 同源)
pub relay_token: String,
/// channel 路由表:IM 侧默认走 default_channel;按前缀路由的多桌面端配置(预留项 ⑥)
#[serde(default)]
pub channels: Vec<ChannelRoute>,
/// 飞书应用凭证(未配置则该平台禁用)
#[serde(default)]
pub feishu: Option<FeishuConfig>,
/// TextDelta 节流窗口毫秒(默认 800,IM 频控友好)
#[serde(default = "default_throttle_ms")]
pub throttle_ms: u64,
/// 用户白名单(空=放行所有,生产部署务必显式配置)
///
/// 注:历史注释「空=拒绝所有」与 main.rs 实现(空=放行)矛盾,走查 2026-10-04 定为
/// 空=放行——否则首次启动零配置时飞书/Web 全部拒绝,连 banner 警告都没有,反而
/// 更难发现配置缺失。生产风险由 main.rs 启动 banner 常驻警告 + 白名单缺失提示兜底。
#[serde(default)]
pub whitelist: Vec<String>,
}
/// channel 路由项:IM 指令前缀 → 桌面端 channel
#[derive(Debug, Clone, Deserialize)]
pub struct ChannelRoute {
/// 指令前缀(如 "/dev2 xxx" 路由到 chan-home);空串=默认 channel
pub prefix: String,
/// u-relay channel_id(桌面端 df-tunnel 配置的同一 channel)
pub channel_id: String,
}
/// 飞书企业自建应用凭证
#[derive(Debug, Clone, Deserialize)]
pub struct FeishuConfig {
pub app_id: String,
pub app_secret: String,
/// 长连接模式事件订阅(免公网回调;当前恒 true,保留显式开关供回调模式扩展)
#[allow(dead_code)]
pub long_connection: bool,
}
fn default_throttle_ms() -> u64 {
800
}
impl GatewayConfig {
/// 从 env + 可选 toml 文件构建。缺失关键项(relay_url/token)返回 Err。
pub fn load() -> anyhow::Result<Self> {
let relay_url = std::env::var("GATEWAY_RELAY_URL")
.unwrap_or_else(|_| "ws://127.0.0.1:9180/ws/commander".to_string());
let relay_token = std::env::var("GATEWAY_RELAY_TOKEN")
.or_else(|_| std::env::var("U_RELAY_TOKEN"))
.map_err(|_| anyhow::anyhow!("缺少 GATEWAY_RELAY_TOKEN / U_RELAY_TOKEN 环境变量"))?;
let feishu = match (
std::env::var("FEISHU_APP_ID"),
std::env::var("FEISHU_APP_SECRET"),
) {
(Ok(app_id), Ok(app_secret)) => Some(FeishuConfig {
app_id,
app_secret,
long_connection: true,
}),
_ => None,
};
let whitelist = std::env::var("GATEWAY_WHITELIST")
.map(|v| v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
.unwrap_or_default();
let default_channel = std::env::var("GATEWAY_DEFAULT_CHANNEL").unwrap_or_default();
let channels = if default_channel.is_empty() {
Vec::new()
} else {
vec![ChannelRoute { prefix: String::new(), channel_id: default_channel }]
};
Ok(Self {
relay_url,
relay_token,
channels,
feishu,
throttle_ms: default_throttle_ms(),
whitelist,
})
}
/// 查指令应路由到的 channel(前缀匹配,空前缀=兜底默认)
pub fn route(&self, command_text: &str) -> Option<&str> {
// 精确前缀优先,空前缀兜底
self.channels
.iter()
.find(|c| !c.prefix.is_empty() && command_text.starts_with(&c.prefix))
.map(|c| c.channel_id.as_str())
.or_else(|| self.channels.iter().find(|c| c.prefix.is_empty()).map(|c| c.channel_id.as_str()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_prefix_priority() {
let cfg = GatewayConfig {
relay_url: String::new(),
relay_token: String::new(),
channels: vec![
ChannelRoute { prefix: "/dev2".into(), channel_id: "chan-home".into() },
ChannelRoute { prefix: String::new(), channel_id: "chan-work".into() },
],
feishu: None,
throttle_ms: 800,
whitelist: vec![],
};
assert_eq!(cfg.route("/dev2 修个 bug"), Some("chan-home"));
assert_eq!(cfg.route("普通指令"), Some("chan-work"));
assert_eq!(cfg.route("/dev2"), Some("chan-home"));
}
#[test]
fn route_empty_when_no_channels() {
let cfg = GatewayConfig {
relay_url: String::new(),
relay_token: String::new(),
channels: vec![],
feishu: None,
throttle_ms: 800,
whitelist: vec![],
};
assert_eq!(cfg.route("任何"), None);
}
}
-410
View File
@@ -1,410 +0,0 @@
//! 飞书适配器(P2):长连接事件 + 归一化消息 + 卡片审批
//!
//! 依赖 larksuite-oapi-sdk-rs features=["channel"](自动连带 ws+messaging)。
//! 长连接:飞书←→网关出站直连,不经 u-relay(u-relay 仅穿 NAT 段到桌面端)。
//!
//! 验证记录(2026-10-04,SDK 0.3.12 源码核对):
//! - 卡片回调:ChannelBuilder::on_card_action(NormalizedCardAction)
//! 内部走 dispatcher.on_card_action_trigger(channel/handler.rs:112),
//! 即新版 card.action.trigger 事件通道,长连接可收(遗留验证项 ✅)。
//! - 发卡片:SendInput.card(String) → INTERACTIVE 类型(messaging.rs:752)。
//! - 流式更新:channel_messaging().send() 拿 message_id 后 edit_text() 覆写全文。
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use larksuite_oapi_sdk_rs::card::v1::{
Action, ActionComponent, Button, ButtonType, Card, CardDocument, Div, Element, Header,
Markdown,
};
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy};
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption};
use tokio::sync::mpsc::Sender;
use crate::adapters::{InboundEvent, OutboundAction, PlatformAdapter};
use crate::config::FeishuConfig;
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
#[derive(Clone)]
pub struct FeishuAdapter {
/// 常驻 client(&'static,构造期一次性 Box::leak)。D1(u-claw 教训:热重建不关旧
/// 连接→僵尸资源):leak 只在 new()/offline() 各发生一次;run() 重连重入只重建
/// Channel 不再重复 leak,防频繁断线场景 LarkClient 内存阶梯上涨。出站 dispatch
/// 与 Channel 构建共用同一份引用。
client: &'static LarkClient,
/// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写)
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
}
impl FeishuAdapter {
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
let client: &'static LarkClient = Box::leak(Box::new(
LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?,
));
Ok(Self {
client,
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
})
}
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
pub fn offline() -> Self {
let client: &'static LarkClient = Box::leak(Box::new(
LarkClient::builder("offline", "offline")
.build()
.expect("占位凭证构造 LarkClient 不发网络请求,必成功"),
));
Self {
client,
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
}
}
}
#[async_trait]
impl PlatformAdapter for FeishuAdapter {
fn name(&self) -> &str {
"feishu"
}
/// 兜底清理流式残留(走查 #3):桌面端异常中止时无 Completed 事件,
/// streams entry 永驻导致下轮首帧错覆写旧消息。核心循环在新建会话映射时调用。
async fn reset_stream(&self, chat_id: &str) {
self.streams.lock().await.remove(chat_id);
}
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
let dispatcher = EventDispatcher::new("", "");
let client = self.client;
// 入站事件通道复制两份,分别给消息/卡片回调闭包
let events_for_msg = events.clone();
let events_for_card = events;
let channel: Channel<'static> = {
// client 已是 &'static(构造期一次性 leak);run() 重连重入只重建 Channel,
// 不再重复 Box::leak(D1:防每次重入泄漏一份 LarkClient)
Channel::builder(client, dispatcher)
.policy(ChannelPolicy::default().require_mention(false))
.on_message(move |msg| {
let events = events_for_msg.clone();
async move {
handle_inbound_message(events, msg).await;
Ok(())
}
})
.on_card_action(move |action| {
let events = events_for_card.clone();
async move {
handle_card_action(events, action).await;
Ok(())
}
})
.build()
};
tracing::info!("[feishu] 长连接启动(免公网回调)");
channel.start().await.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"))?;
Ok(())
}
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
let opt = &RequestOption::default();
match action {
OutboundAction::StreamText { chat_id, text, done } => {
// 两段式:锁内只取/写 message_id(纯内存操作),网络 IO 在锁外执行。
// 修复走查 #2:同 chat 仍串行(顺序语义),不同 chat 不再被同一把锁挡住。
let existing_id: Option<String> = {
let streams = self.streams.lock().await;
streams.get(&chat_id).cloned()
};
if let Some(message_id) = existing_id {
let res = self.client.channel_messaging().edit_text(&message_id, &text, opt).await;
if let Err(e) = res {
tracing::warn!("[feishu] 流式更新失败(消息可能已被撤回/超时): {e},回退新发");
self.streams.lock().await.remove(&chat_id);
}
} else {
// 首帧:发新文本消息,记录 message_id 供后续覆写
let input = larksuite_oapi_sdk_rs::channel::SendInput {
receive_id: Some(chat_id.clone()),
text: Some(text),
..Default::default()
};
let send_result = self.client.channel_messaging().send(&input, opt).await;
match send_result {
Ok(sr) if !sr.message_id.is_empty() => {
self.streams.lock().await.insert(chat_id.clone(), sr.message_id);
}
Ok(_) => {
tracing::warn!("[feishu] 发送流式首帧未返回 message_id");
}
Err(e) => {
tracing::error!("[feishu] 发送流式首帧失败: {e}");
}
}
}
if done {
self.streams.lock().await.remove(&chat_id);
}
Ok(())
}
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
let card = build_approval_card(&approval_id, &title, &detail);
let document = CardDocument::new(card)
.map_err(|e| anyhow::anyhow!("审批卡片校验失败: {e}"))?;
// encoded_content 是 SDK pub(crate);直接序列化 card(校验已过)
let content = serde_json::to_string(document.card())
.map_err(|e| anyhow::anyhow!("卡片序列化失败: {e}"))?;
let input = larksuite_oapi_sdk_rs::channel::SendInput {
receive_id: Some(chat_id),
card: Some(content),
..Default::default()
};
self.client
.channel_messaging()
.send(&input, opt)
.await
.map_err(|e| anyhow::anyhow!("审批卡片发送失败: {e}"))?;
Ok(())
}
OutboundAction::Notice { chat_id, text } => {
let input = larksuite_oapi_sdk_rs::channel::SendInput {
receive_id: Some(chat_id),
text: Some(text),
..Default::default()
};
self.client
.channel_messaging()
.send(&input, opt)
.await
.map_err(|e| anyhow::anyhow!("通知发送失败: {e}"))?;
Ok(())
}
}
}
}
/// 入站消息:群聊需 @机器人(mentioned_bot)或单聊直发
async fn handle_inbound_message(events: Sender<InboundEvent>, msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage) {
// 只处理文本(text 优先,回退 content)
if msg.message_type != "text" {
tracing::debug!("[feishu] 忽略非文本消息 type={}", msg.message_type);
return;
}
// 群聊必须 @机器人;单聊(p2p)直发
if msg.chat_type == "group" && !msg.mentioned_bot {
tracing::debug!("[feishu] 群聊未 @机器人,忽略");
return;
}
let text = msg.text.clone().unwrap_or_default().trim().to_string();
if text.is_empty() {
return;
}
// 指令:/new 重置会话
if text == "/new" {
let _ = events
.send(InboundEvent::NewSession {
platform: "feishu".into(),
chat_id: msg.chat_id.clone(),
})
.await;
return;
}
let user_id = msg
.sender
.user_id
.as_ref()
.and_then(|u| u.open_id.clone())
.unwrap_or_else(|| "unknown".into());
let _ = events
.send(InboundEvent::Message {
platform: "feishu".into(),
chat_id: msg.chat_id,
user_id,
text,
})
.await;
}
/// 卡片按钮回调:action_value 携带 approval_id + approved
async fn handle_card_action(events: Sender<InboundEvent>, action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction) {
// 按钮约定的 value 形态:{"approval_id": "...", "approved": true|false}
let approval_id = action
.action_value
.get("approval_id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let approved = action
.action_value
.get("approved")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if approval_id.is_empty() {
tracing::warn!("[feishu] 卡片回调缺 approval_id,忽略");
return;
}
let chat_id = action.open_chat_id.clone().unwrap_or_default();
let _ = events
.send(InboundEvent::Approval {
platform: "feishu".into(),
chat_id,
approval_id,
approved,
})
.await;
}
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", md_to_lark_friendly(detail));
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue {
serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆"))
.expect("按钮 value 结构兼容")
};
let actions = Action::new()
.action(ActionComponent::Button(
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("✅ 同意"))
.button_type(ButtonType::Primary)
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": true}))),
))
.action(ActionComponent::Button(
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("❌ 拒绝"))
.button_type(ButtonType::Danger)
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": false}))),
));
Card::new()
.header(Header::new(title))
.element(Element::Div(Div::new(
larksuite_oapi_sdk_rs::card::v1::Text::lark_md(detail_md),
)))
.element(Element::Markdown(Markdown::new("---")))
.element(Element::Action(actions))
}
/// 出站 Markdown 净化(飞书卡片 lark_md 语法适配)。
///
/// AI 产出常带标准 Markdown 结构,lark_md 不支持标题与表格语法,原样注入按字面量渲染。
/// 规则(与 src-tauri feishu_channel.rs 侧 md_to_lark_friendly 同规则互引,双端出站口径一致):
/// 标题行(行首一个以上 #)转粗体行;表格行(| 开头)转列表行,表头分隔行整行跳过;
/// 代码块围栏内外均保留原样(转义或改写破坏代码展示);其余行原样透传(lark_md 原生
/// 支持粗体/链接/行内代码,不额外转义)。
fn md_to_lark_friendly(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_code = false;
for line in s.lines() {
if line.trim_start().starts_with("```") {
in_code = !in_code;
out.push_str(line);
out.push('\n');
continue;
}
if in_code {
out.push_str(line);
out.push('\n');
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
let title = trimmed.trim_start_matches('#').trim();
if !title.is_empty() {
out.push_str(&format!("**{title}**\n"));
}
continue;
}
if trimmed.starts_with('|') {
let cells: Vec<&str> = trimmed.trim_matches('|').split('|').map(str::trim).collect();
// 表头分隔行(|---|:---|)无内容语义,整行跳过
if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':')) {
continue;
}
let shown: Vec<&str> = cells.into_iter().filter(|c| !c.is_empty()).collect();
out.push_str(&format!("- {}\n", shown.join(" · ")));
continue;
}
out.push_str(line);
out.push('\n');
}
// lines() 逐行补换行,源文本不以换行结尾时尾部多出一个,与源对齐
if !s.ends_with('\n') {
out.pop();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::FeishuConfig;
/// 构造冒烟:new/offline 均不发网络请求,验证适配器可实例化
#[test]
fn feishu_adapter_constructs() {
let cfg = FeishuConfig {
app_id: "cli_smoke".into(),
app_secret: "secret".into(),
long_connection: true,
};
let adapter = FeishuAdapter::new(&cfg).expect("适配器构造应成功");
assert_eq!(adapter.name(), "feishu");
let offline = FeishuAdapter::offline();
assert_eq!(offline.name(), "feishu");
}
/// 审批卡片构造 + 协议校验(CardDocument::new 是发送前的强校验门槛)
#[test]
fn approval_card_passes_protocol_validation() {
let card = build_approval_card("appr-123", "工具审批请求", "run_command: cargo test");
CardDocument::new(card).expect("审批卡片应通过 Card JSON 1.0 校验");
}
/// 卡片构造包含同意/拒绝按钮及 approval_id 载荷
#[test]
fn approval_card_has_buttons_and_payload() {
let card = build_approval_card("appr-456", "标题", "详情");
let json = serde_json::to_value(&card).unwrap();
let elements = json["elements"].as_array().expect("elements 数组");
// 最后一个元素是 Action 行,内含 2 个按钮
let action = elements.last().unwrap();
let buttons = action["actions"].as_array().expect("actions 数组");
assert_eq!(buttons.len(), 2);
let values: Vec<String> = buttons
.iter()
.map(|b| b["value"]["approval_id"].as_str().unwrap_or_default().to_string())
.collect();
assert!(values.iter().all(|v| v == "appr-456"));
let approved_flags: Vec<bool> = buttons
.iter()
.map(|b| b["value"]["approved"].as_bool().unwrap_or(false))
.collect();
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
}
/// 出站净化:标题转粗体行,表格行转列表行,表头分隔行跳过
#[test]
fn lark_friendly_converts_headings_and_tables() {
let md = "# 标题\n| a | b |\n|---|---|\n| 1 | 2 |";
assert_eq!(md_to_lark_friendly(md), "**标题**\n- a · b\n- 1 · 2");
}
/// 出站净化:代码围栏内外均保留原样,围栏外的标题照常改写
#[test]
fn lark_friendly_preserves_code_fence() {
let md = "前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n## 后文";
assert_eq!(
md_to_lark_friendly(md),
"前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n**后文**"
);
}
/// 出站净化:普通行原样透传(lark_md 原生支持粗体/链接,不额外转义)
#[test]
fn lark_friendly_passes_plain_lines() {
let md = "**粗** 与 [链接](http://e)\n普通行";
assert_eq!(md_to_lark_friendly(md), md);
}
}
-330
View File
@@ -1,330 +0,0 @@
//! df-im-gateway 主入口:装配 config + store + throttle + commander + adapters
//!
//! 骨架版(P0):Web 适配器真实可跑,事件流全链路打通。
//! 用法:
//! ```text
//! df-im-gateway.exe # 默认配置(env)
//! GATEWAY_RELAY_URL=... GATEWAY_RELAY_TOKEN=... \
//! GATEWAY_DEFAULT_CHANNEL=chan-work df-im-gateway.exe
//! ```
mod adapters;
mod audit;
mod commander;
mod config;
mod feishu;
mod store;
mod throttle;
use std::path::PathBuf;
use tokio::sync::mpsc;
use adapters::{InboundEvent, OutboundAction, PlatformAdapter};
use config::GatewayConfig;
use store::SessionStore;
use throttle::DeltaThrottle;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cfg = GatewayConfig::load()?;
if cfg.channels.is_empty() {
anyhow::bail!("未配置任何 channel 路由(GATEWAY_DEFAULT_CHANNEL),无法启动");
}
let data_dir = std::env::var("GATEWAY_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("data"));
std::fs::create_dir_all(&data_dir)?;
let store = SessionStore::open(&data_dir.join("sessions.db"))?;
// 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录,写失败不阻断主链路
let audit = audit::AuditLog::open(&data_dir.join("audit.db"))?;
info_banner(&cfg);
// ── 核心事件总线:适配器 → 核心 ─────────────────────────────
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundEvent>(256);
// Web 适配器(四端首个,P0)
let web_adapter = adapters::web::WebAdapter::new();
{
let web_run = web_adapter.clone();
let ev_tx = inbound_tx.clone();
tokio::spawn(async move {
if let Err(e) = web_run.run(ev_tx).await {
tracing::error!("[web] 适配器退出: {e}");
}
});
}
// 飞书适配器(P2):FEISHU_APP_ID/SECRET 配置时启用长连接
let feishu_adapter = cfg.feishu.as_ref().map(|f| match feishu::FeishuAdapter::new(f) {
Ok(adapter) => {
let feishu_run = adapter.clone();
let ev_tx = inbound_tx.clone();
tokio::spawn(async move {
if let Err(e) = feishu_run.run(ev_tx).await {
tracing::error!("[feishu] 适配器退出: {e}");
}
});
tracing::info!("[feishu] 适配器已启动(长连接)");
adapter
}
Err(e) => {
tracing::error!("[feishu] 适配器构造失败(跳过): {e}");
feishu::FeishuAdapter::offline()
}
});
// ── Commander 连接(u-relay)────────────────────────────────
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let default_channel = cfg
.channels
.iter()
.find(|c| c.prefix.is_empty())
.map(|c| c.channel_id.clone())
.unwrap_or_else(|| cfg.channels[0].channel_id.clone());
// 事件流:commander 任务 → 核心循环
let (ev_tx, mut ev_rx) = mpsc::channel::<df_tunnel::events::TunnelEvent>(256);
// 指令流:核心循环 → commander 任务(透传到 WS)
let (cmd_tx, cmd_rx) = mpsc::channel::<df_tunnel::TunnelCommand>(256);
{
let relay_url = cfg.relay_url.clone();
let token = cfg.relay_token.clone();
let channel = default_channel.clone();
tokio::spawn(async move {
if let Err(e) = commander::run_commander(relay_url, channel, token, ev_tx, cmd_rx, shutdown_rx).await {
tracing::error!("[commander] 连接循环退出: {e}");
}
});
}
// ── 核心分发循环(单 select,持有全部状态,无跨线程共享)─────
let mut throttle = DeltaThrottle::new(cfg.throttle_ms);
// 审批幂等表:approval_id → 已处理标记(P0 必验项 ②:防 pending→approved 二次点击重复转发)
let mut handled_approvals: std::collections::HashSet<String> = std::collections::HashSet::new();
// 平台注册表(走查 #4):显式 platform → 下标映射,替代硬编码 "feishu"=>1 与
// adapters vec 构建顺序的隐式耦合。新增平台(企微/钉钉)只在此处注册一次。
let mut adapters: Vec<Box<dyn adapters::PlatformAdapter>> = vec![Box::new(web_adapter.clone())];
let mut platform_index: std::collections::HashMap<String, usize> =
std::collections::HashMap::from([("web".to_string(), 0usize)]);
if let Some(fs) = feishu_adapter {
platform_index.insert("feishu".to_string(), adapters.len());
adapters.push(Box::new(fs));
}
let platform_of = |platform_or_conv: &str| -> usize {
// conv_id 形如 "platform:chat_id",前缀即平台;未知平台回退 web(0)
let platform = platform_or_conv.split(':').next().unwrap_or("web");
*platform_index.get(platform).unwrap_or(&0)
};
// 停止信号
let shutdown_ctrl = tokio::signal::ctrl_c();
tokio::pin!(shutdown_ctrl);
let cfg_loop = cfg.clone();
loop {
tokio::select! {
_ = &mut shutdown_ctrl => {
tracing::info!("收到 Ctrl+C,优雅退出");
let _ = shutdown_tx.send(true);
break;
}
// ── TunnelEvent(桌面端 → 网关)──
ev = ev_rx.recv() => {
let Some(ev) = ev else { break };
match ev {
df_tunnel::TunnelEvent::TextDelta { conv_id, delta } => {
// 出站按 conv_id 前缀(platform:chat_id)路由回原平台
let flush = throttle.feed(&conv_id, &delta);
if let Some(text) = flush {
let idx = platform_of(&conv_id);
let _ = adapters[idx]
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text, done: false })
.await;
}
}
df_tunnel::TunnelEvent::Completed { conv_id, .. } => {
let tail = throttle.finish(&conv_id);
let full = tail.unwrap_or_default();
let idx = platform_of(&conv_id);
audit.record(audit::AuditEntry {
direction: audit::Direction::Outbound,
platform: String::new(), chat_id: conv_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "completed".into(), request_id: String::new(),
payload: full.clone(), outcome: "ok".into(),
});
let _ = adapters[idx]
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
.await;
}
df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => {
audit.record(audit::AuditEntry {
direction: audit::Direction::Outbound,
platform: String::new(), chat_id: conv_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "approval_card".into(), request_id: approval_id.clone(),
payload: description.clone(), outcome: "ok".into(),
});
let idx = platform_of(&conv_id);
let _ = adapters[idx]
.dispatch(OutboundAction::ApprovalCard {
chat_id: conv_id,
approval_id,
title: "工具审批请求".into(),
detail: description,
})
.await;
}
df_tunnel::TunnelEvent::ToolCall { .. } => {
// P0 骨架不转发 ToolCall 到 IM(卡片噪音),仅日志
tracing::debug!("[core] ToolCall 事件(骨架忽略)");
}
}
}
// ── InboundEvent(IM → 网关)──
inbound = inbound_rx.recv() => {
let Some(ev) = inbound else { break };
match ev {
InboundEvent::Message { platform, chat_id, user_id, text } => {
// 回复适配器:按来源平台回路由
let reply_idx = platform_of(&platform);
if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: String::new(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "denied".into(),
});
let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
.await;
continue;
}
let Some(channel_id) = cfg_loop.route(&text) else {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: String::new(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "error".into(),
});
let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
.await;
continue;
};
// 会话映射:无则新建(conv_id = platform:chat_id,Web 端 chat_id 同源)
let conv_id = match store.get_conv(&platform, &chat_id) {
Some(m) => m.conv_id,
None => {
let conv = format!("{platform}:{chat_id}");
let _ = store.set_conv(&platform, &chat_id, &conv, channel_id);
// 走查 #3:新建会话时清理流式残留(上轮异常中止无
// Completed 事件,streams entry 永驻会错覆写旧消息)
adapters[reply_idx].reset_stream(&chat_id).await;
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv.clone(),
action: "new_session".into(), request_id: String::new(),
payload: String::new(), outcome: "ok".into(),
});
conv
}
};
// 入站消息审计:request_id 用 conv+毫秒派生,出站 Completed 事件无法
// 回带,此处以 (direction=inbound,message) 为链头,command/outbound
// 侧用同一 conv_id 关联(recent 按时间倒序即还原会话时间线)。
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv_id.clone(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "ok".into(),
});
audit.record(audit::AuditEntry {
direction: audit::Direction::Command,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv_id.clone(),
action: "send_cmd".into(), request_id: String::new(),
payload: text.clone(), outcome: "ok".into(),
});
let _ = cmd_tx
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
.await;
}
InboundEvent::NewSession { platform, chat_id } => {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: String::new(), conv_id: String::new(),
action: "new_session_reset".into(), request_id: String::new(),
payload: String::new(), outcome: "ok".into(),
});
let _ = store.remove(&platform, &chat_id);
let reply_idx = platform_of(&platform);
let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "🔄 已重置会话".into() })
.await;
}
InboundEvent::Approval { platform, chat_id, approval_id, approved } => {
// 幂等:同 approval_id 只处理首次(P0 必验项 ②)
if handled_approvals.contains(&approval_id) {
tracing::debug!("[core] 审批 {approval_id} 已处理,忽略重复点击");
continue;
}
handled_approvals.insert(approval_id.clone());
let conv_id = store
.get_conv(&platform, &chat_id)
.map(|m| m.conv_id)
.unwrap_or_else(|| format!("{platform}:{chat_id}"));
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "approval_click".into(), request_id: approval_id.clone(),
payload: if approved { "approved".into() } else { "rejected".into() },
outcome: "ok".into(),
}); audit.record(audit::AuditEntry {
direction: audit::Direction::Command,
platform, chat_id, user_id: String::new(), conv_id: conv_id.clone(),
action: "approve_cmd".into(), request_id: approval_id.clone(), payload: if approved { "approved".into() } else { "rejected".into() },
outcome: "ok".into(),
});
let _ = cmd_tx
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
.await;
}
}
}
}
}
Ok(())
}
fn info_banner(cfg: &GatewayConfig) {
println!("╔══════════════════════════════════════════╗");
println!("║ df-im-gateway v0.1.0 (P0 骨架) ║");
println!("╚══════════════════════════════════════════╝");
println!(" relay : {}", cfg.relay_url);
println!(" channels : {} 条路由", cfg.channels.len());
println!(" throttle : {}ms", cfg.throttle_ms);
println!(" whitelist : {} 用户", cfg.whitelist.len());
if cfg.whitelist.is_empty() {
println!(" ⚠️ 白名单为空:任何人可用本网关(开发默认)。生产部署请设置 GATEWAY_WHITELIST!");
}
println!(" feishu : {}", if cfg.feishu.is_some() { "已配置" } else { "未配置(跳过)" });
println!(" web ws : 0.0.0.0:9191");
}
-151
View File
@@ -1,151 +0,0 @@
//! 会话映射存储(IM chat_id ↔ DevFlow conv_id,SQLite + WAL)
//!
//! 建表即带 owner_user_id/platform 列(P0 预留项 ⑤,当前恒默认值,
//! S2 多人场景免迁表)。
//!
//! 表结构:
//! ```sql
//! CREATE TABLE IF NOT EXISTS session_map (
//! platform TEXT NOT NULL, -- feishu/wecom/dingtalk/web
//! chat_id TEXT NOT NULL, -- IM 侧会话(单聊 user_id / 群聊 chat_id)
//! conv_id TEXT NOT NULL, -- DevFlow 会话 ID
//! owner_user_id TEXT NOT NULL DEFAULT '',-- 预留 S2 多人归属(当前恒 '')
//! channel_id TEXT NOT NULL DEFAULT '',-- 路由到的桌面端 channel(预留项 ⑥)
//! created_at INTEGER NOT NULL,
//! updated_at INTEGER NOT NULL,
//! PRIMARY KEY (platform, chat_id)
//! );
//! ```
use std::path::Path;
use std::sync::Arc;
use rusqlite::Connection;
/// 单条会话映射(字段预留 S2 多人/多桌面端,当前核心只读 conv_id)
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SessionMap {
pub platform: String,
pub chat_id: String,
pub conv_id: String,
pub owner_user_id: String,
pub channel_id: String,
}
#[derive(Clone)]
pub struct SessionStore {
conn: Arc<std::sync::Mutex<Connection>>,
}
impl SessionStore {
/// 打开(或创建)库文件并确保表存在。WAL 模式(与桌面端一致)。
pub fn open(path: &Path) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS session_map (
platform TEXT NOT NULL,
chat_id TEXT NOT NULL,
conv_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL DEFAULT '',
channel_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (platform, chat_id)
);",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
/// 内存库(测试/演示)
#[allow(dead_code)]
pub fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS session_map (
platform TEXT NOT NULL, chat_id TEXT NOT NULL, conv_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
PRIMARY KEY (platform, chat_id)
);",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
/// 查 IM 会话映射的 conv_id(未映射返回 None)
pub fn get_conv(&self, platform: &str, chat_id: &str) -> Option<SessionMap> {
let conn = self.conn.lock().ok()?;
conn.query_row(
"SELECT platform, chat_id, conv_id, owner_user_id, channel_id FROM session_map \
WHERE platform = ?1 AND chat_id = ?2",
[platform, chat_id],
|row| {
Ok(SessionMap {
platform: row.get(0)?,
chat_id: row.get(1)?,
conv_id: row.get(2)?,
owner_user_id: row.get(3)?,
channel_id: row.get(4)?,
})
},
)
.ok()
}
/// 写/更新映射(upsert)
pub fn set_conv(&self, platform: &str, chat_id: &str, conv_id: &str, channel_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
conn.execute(
"INSERT INTO session_map (platform, chat_id, conv_id, owner_user_id, channel_id, created_at, updated_at) \
VALUES (?1, ?2, ?3, '', ?4, ?5, ?5) \
ON CONFLICT(platform, chat_id) DO UPDATE SET conv_id = ?3, channel_id = ?4, updated_at = ?5",
rusqlite::params![platform, chat_id, conv_id, channel_id, now],
)?;
Ok(())
}
/// 删除映射(桌面端会话已删,悬空清理)
pub fn remove(&self, platform: &str, chat_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
conn.execute("DELETE FROM session_map WHERE platform = ?1 AND chat_id = ?2", [platform, chat_id])?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upsert_and_get() {
let store = SessionStore::open_in_memory().unwrap();
store.set_conv("feishu", "oc_123", "conv_abc", "chan-work").unwrap();
let m = store.get_conv("feishu", "oc_123").unwrap();
assert_eq!(m.conv_id, "conv_abc");
assert_eq!(m.channel_id, "chan-work");
assert_eq!(m.owner_user_id, "");
// upsert 覆盖
store.set_conv("feishu", "oc_123", "conv_new", "chan-home").unwrap();
let m = store.get_conv("feishu", "oc_123").unwrap();
assert_eq!(m.conv_id, "conv_new");
// 删除后悬空
store.remove("feishu", "oc_123").unwrap();
assert!(store.get_conv("feishu", "oc_123").is_none());
}
#[test]
fn platform_isolated() {
let store = SessionStore::open_in_memory().unwrap();
store.set_conv("feishu", "c1", "conv1", "ch").unwrap();
store.set_conv("wecom", "c1", "conv2", "ch").unwrap();
assert_eq!(store.get_conv("feishu", "c1").unwrap().conv_id, "conv1");
assert_eq!(store.get_conv("wecom", "c1").unwrap().conv_id, "conv2");
}
}
-103
View File
@@ -1,103 +0,0 @@
//! TextDelta 节流聚合器(IM 频控防护)
//!
//! IM 平台消息 API 频控(飞书 ~50 QPS 应用级、企微 200 次/分钟),逐 delta 刷卡片必被限流。
//! 策略:per-conv 聚合缓冲 + 时间窗(默认 800ms)批量 flush;Completed 时强制冲刷尾包。
//!
//! 设计要点:
//! - 每个 key 一个缓冲,互不干扰
//! - 纯函数式:feed() 返回 Option<全量文本>(窗口到期才返回),不持有回调/不 await
//! - flush 语义:全量文本(IM 卡片更新是「全量内容」语义,非增量)
//! - finish() 冲刷尾包并清缓冲;abort() 丢弃缓冲(错误/停止,不输出残文)
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// 单 key 缓冲
struct ConvBuffer {
/// 累积全量文本
full_text: String,
/// 上次 flush 时刻
last_flush: Instant,
}
/// 节流聚合器
pub struct DeltaThrottle {
window: Duration,
buffers: HashMap<String, ConvBuffer>,
}
impl DeltaThrottle {
pub fn new(window_ms: u64) -> Self {
Self { window: Duration::from_millis(window_ms), buffers: HashMap::new() }
}
/// 喂入 delta。窗口到期则返回 Some(全量文本)(调用方负责外发),否则 None。
pub fn feed(&mut self, key: &str, delta: &str) -> Option<String> {
let now = Instant::now();
let buf = self.buffers.entry(key.to_string()).or_insert_with(|| ConvBuffer {
full_text: String::new(),
last_flush: now,
});
buf.full_text.push_str(delta);
if buf.last_flush.elapsed() >= self.window {
buf.last_flush = now;
Some(buf.full_text.clone())
} else {
None
}
}
/// 会话完成:强制冲刷尾包 + 清缓冲。返回 Some(全量文本) 或(空会话)None。
pub fn finish(&mut self, key: &str) -> Option<String> {
self.buffers.remove(key).map(|buf| buf.full_text)
}
/// 丢弃缓冲(错误/停止,不输出残文)
///
/// 注:当前 TunnelEvent 无 Error/Stopped 变体,核心循环暂无调用点;
/// df-tunnel Phase2 扩展后接入。
#[allow(dead_code)]
pub fn abort(&mut self, key: &str) {
self.buffers.remove(key);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn throttle_window_batch() {
let mut t = DeltaThrottle::new(60_000); // 60s 窗口:模拟窗口永不到期
assert_eq!(t.feed("k", "你"), None);
assert_eq!(t.feed("k", "好"), None);
assert_eq!(t.feed("k", "!"), None);
assert_eq!(t.finish("k"), Some("你好!".to_string()), "finish 冲刷全量");
}
#[test]
fn window_elapsed_flushes() {
let mut t = DeltaThrottle::new(0); // 0ms 窗口:每次 feed 都到期
assert_eq!(t.feed("k", "a"), Some("a".to_string()));
assert_eq!(t.feed("k", "b"), Some("ab".to_string()));
// 缓冲仍在 finish 前的最后 flush 版本;finish 拿走剩余
assert_eq!(t.finish("k"), Some("ab".to_string()));
}
#[test]
fn abort_drops() {
let mut t = DeltaThrottle::new(60_000);
t.feed("k", "残文");
t.abort("k");
assert_eq!(t.finish("k"), None, "abort 后缓冲不存在");
}
#[test]
fn keys_isolated() {
let mut t = DeltaThrottle::new(60_000);
t.feed("a", "1");
t.feed("b", "2");
assert_eq!(t.finish("a"), Some("1".to_string()));
assert_eq!(t.finish("b"), Some("2".to_string()));
}
}
-398
View File
@@ -1,398 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>DevFlow AI Chat</title>
<style>
:root {
--bg: #0f1115; --panel: #171a21; --panel2: #1d212b; --border: #2a2f3a;
--text: #e6e9ef; --muted: #8a93a6; --accent: #4f8cff; --accent2: #3a6fd8;
--ok: #3fb96f; --warn: #e5a03c; --err: #e05252; --radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font: 14px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
display: flex; overflow: hidden;
}
/* ── 侧栏:会话列表 ── */
#sidebar {
width: 220px; min-width: 220px; background: var(--panel);
border-right: 1px solid var(--border); display: flex; flex-direction: column;
}
#sidebar header { padding: 14px 14px 8px; font-weight: 600; font-size: 13px; color: var(--muted); letter-spacing: .05em; }
#conv-list { flex: 1; overflow-y: auto; padding: 4px 8px; }
.conv-item {
padding: 8px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 2px;
display: flex; align-items: center; gap: 8px; color: var(--muted);
}
.conv-item:hover { background: var(--panel2); }
.conv-item.active { background: var(--panel2); color: var(--text); }
.conv-item .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
.conv-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.conv-item .del { opacity: 0; color: var(--muted); font-size: 12px; padding: 0 2px; }
.conv-item:hover .del { opacity: 1; }
.conv-item .del:hover { color: var(--err); }
#sidebar footer { padding: 10px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
/* ── 主区 ── */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#topbar {
padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel);
display: flex; align-items: center; gap: 10px; font-size: 13px;
}
#conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--err); flex: none; }
#conn-dot.on { background: var(--ok); }
#conn-label { color: var(--muted); }
#topbar .spacer { flex: 1; }
#topbar button {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px;
}
#topbar button:hover { border-color: var(--accent); }
#msgs { flex: 1; overflow-y: auto; padding: 18px 16px; scroll-behavior: smooth; }
.msg { margin-bottom: 14px; display: flex; }
.msg.user { justify-content: flex-end; }
.msg .bubble {
max-width: 78%; padding: 9px 13px; border-radius: var(--radius);
background: var(--panel2); white-space: pre-wrap; word-break: break-word;
}
.msg.user .bubble { background: var(--accent2); }
.msg.ai .bubble { background: var(--panel2); }
.msg .bubble.sys { background: transparent; color: var(--muted); font-size: 12px; max-width: 100%; }
.approval-card {
border: 1px solid var(--warn); border-radius: var(--radius); padding: 10px 13px;
max-width: 78%; background: rgba(229,160,60,.08);
}
.approval-card .t { color: var(--warn); font-weight: 600; font-size: 13px; margin-bottom: 4px; }
.approval-card .d { color: var(--muted); font-size: 12px; margin-bottom: 9px; white-space: pre-wrap; word-break: break-word; }
.approval-card .row { display: flex; gap: 8px; }
.approval-card button {
flex: 1; padding: 6px 0; border-radius: 6px; border: none; cursor: pointer; font-size: 13px;
}
.approval-card .yes { background: var(--ok); color: #fff; }
.approval-card .no { background: var(--panel2); color: var(--muted); border: 1px solid var(--border); }
.approval-card.answered { opacity: .55; }
.approval-card.answered .row { display: none; }
.approval-card .verdict { font-size: 12px; color: var(--muted); }
/* ── 输入区 ── */
#inputbar { display: flex; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); }
#inp {
flex: 1; background: var(--panel2); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 9px 12px; font: inherit; resize: none; outline: none; height: 44px;
}
#inp:focus { border-color: var(--accent); }
#send, #stop {
border: none; border-radius: 8px; padding: 0 18px; cursor: pointer; font: inherit; font-size: 13px;
}
#send { background: var(--accent); color: #fff; }
#send:disabled { opacity: .45; cursor: not-allowed; }
#stop { background: var(--panel2); color: var(--err); border: 1px solid var(--border); display: none; }
@media (max-width: 640px) {
#sidebar { position: fixed; z-index: 10; height: 100%; transform: translateX(-100%); transition: transform .2s; }
#sidebar.open { transform: translateX(0); box-shadow: 0 0 30px rgba(0,0,0,.5); }
#menu-btn { display: inline-block !important; }
.msg .bubble, .approval-card { max-width: 92%; }
}
#menu-btn { display: none; background: none; border: none; color: var(--text); font-size: 18px; cursor: pointer; }
/* 简单 markdown 粗体/代码 */
.bubble code { background: rgba(79,140,255,.12); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
</style>
</head>
<body>
<nav id="sidebar">
<header>会话</header>
<div id="conv-list"></div>
<footer><button id="new-conv" style="flex:1;background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px;cursor:pointer">+ 新会话</button></footer>
</nav>
<div id="main">
<div id="topbar">
<button id="menu-btn">☰</button>
<span id="conn-dot"></span><span id="conn-label">连接中…</span>
<span class="spacer"></span>
<button id="clear-btn">🗑 清空当前会话记录</button>
</div>
<div id="msgs"></div>
<div id="inputbar">
<textarea id="inp" placeholder="发消息给 DevFlow AI…(Enter 发送 / Shift+Enter 换行)"></textarea>
<button id="send">发送</button>
<button id="stop">停止</button>
</div>
</div>
<script>
'use strict';
/* ===== 常量与状态 ===== */
var LS_KEY = 'devflow.webchat.v1'; // localStorage: {convs:[{id,name}], activeId}
var state = {
convs: [], // [{id, name, msgs:[{role:'user'|'ai'|'sys', text, ts}], pending:{approval_id, title, detail, el} }]
activeId: null, // 当前会话 chat_id
ws: null, wsReady: false, reconnectTimer: null, closedByUs: false,
generating: false, // 当前会话生成中(流式未完)
};
var $ = function (s) { return document.querySelector(s); };
var msgsEl = $('#msgs'), inpEl = $('#inp'), sendBtn = $('#send'), stopBtn = $('#stop');
/* ===== 本地持久化 ===== */
function loadStore() {
try { state.convs = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch (e) { state.convs = []; }
}
function saveStore() {
try { localStorage.setItem(LS_KEY, JSON.stringify(state.convs)); } catch (e) { /* 配额满忽略 */ }
}
function activeConv() {
return state.convs.find(function (c) { return c.id === state.activeId; }) || null;
}
/* ===== 会话管理 ===== */
function newConvId() { return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }
function newConv(activate) {
var id = newConvId();
var c = { id: id, name: '新会话', msgs: [], pending: null };
state.convs.unshift(c);
if (activate) switchConv(id);
saveStore(); renderConvList();
return c;
}
function switchConv(id) {
if (state.activeId === id) { closeSidebar(); return; }
state.activeId = id;
saveStore(); renderConvList(); renderMsgs(); closeSidebar();
// WS 已就绪则重绑到新 chat_id(断开重连,首帧 hello 携带新 id)
if (state.wsReady) reconnect();
}
function deleteConv(id) {
var idx = state.convs.findIndex(function (c) { return c.id === id; });
if (idx < 0) return;
state.convs.splice(idx, 1);
if (state.activeId === id) {
state.activeId = state.convs.length ? state.convs[0].id : null;
if (!state.activeId) newConv(false);
renderMsgs();
if (state.wsReady) reconnect();
}
saveStore(); renderConvList();
}
function newSessionOnServer() {
var c = activeConv();
if (c && state.wsReady) wsSend({ type: 'new_session' });
}
function ensureConv() { return activeConv() || newConv(true); }
/* ===== 渲染 ===== */
function esc(s) {
return String(s).replace(/[&<>"']/g, function (m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
});
}
function fmtText(s) {
// 轻量格式化:转义后 `code` 行内代码
return esc(s).replace(/`([^`\n]+)`/g, '<code>$1</code>');
}
function renderConvList() {
var el = $('#conv-list'); el.innerHTML = '';
state.convs.forEach(function (c) {
var d = document.createElement('div');
d.className = 'conv-item' + (c.id === state.activeId ? ' active' : '');
d.innerHTML = '<span class="dot"></span><span class="name">' + esc(c.name) + '</span><span class="del" title="删除">✕</span>';
d.onclick = function (e) {
if (e.target.classList.contains('del')) {
if (confirm('删除会话「' + c.name + '」?(仅删除本页记录,桌面端对话保留)')) deleteConv(c.id);
return;
}
switchConv(c.id);
};
el.appendChild(d);
});
}
function msgNode(m) {
if (m.role === 'sys') {
var d = document.createElement('div'); d.className = 'msg';
var b = document.createElement('div'); b.className = 'bubble sys'; b.innerHTML = fmtText(m.text);
d.appendChild(b); return d;
}
if (m.role === 'approval') {
var wrap = document.createElement('div'); wrap.className = 'msg';
var card = document.createElement('div'); card.className = 'approval-card';
card.innerHTML = '<div class="t">🛡 ' + esc(m.title || '工具审批请求') + '</div>'
+ '<div class="d">' + fmtText(m.detail || '') + '</div>'
+ '<div class="row"><button class="yes">同意</button><button class="no">拒绝</button></div>';
var done = function (approved) {
card.classList.add('answered');
var v = document.createElement('div'); v.className = 'verdict';
v.textContent = (approved ? '✓ 已同意' : '✕ 已拒绝') + '(未知新帧到达前以此为准)';
card.appendChild(v);
wsSend({ type: 'approval', approval_id: m.approval_id, approved: approved });
};
card.querySelector('.yes').onclick = function () { done(true); };
card.querySelector('.no').onclick = function () { done(false); };
if (m.answered) { card.classList.add('answered'); var v = document.createElement('div'); v.className = 'verdict'; v.textContent = m.answered === 'yes' ? '✓ 已同意' : '✕ 已拒绝'; card.appendChild(v); }
wrap.appendChild(card); return wrap;
}
var d = document.createElement('div'); d.className = 'msg ' + (m.role === 'user' ? 'user' : 'ai');
var b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = fmtText(m.text || '');
d.appendChild(b); return d;
}
function renderMsgs() {
msgsEl.innerHTML = '';
var c = activeConv(); if (!c) return;
c.msgs.forEach(function (m) { msgsEl.appendChild(msgNode(m)); });
scrollBottom();
}
function scrollBottom() { msgsEl.scrollTop = msgsEl.scrollHeight; }
/* ===== WS 连接 ===== */
function wsSend(obj) {
if (state.ws && state.ws.readyState === 1) { state.ws.send(JSON.stringify(obj)); return true; }
return false;
}
function connect() {
clearTimeout(state.reconnectTimer);
var c = ensureConv();
state.closedByUs = false;
var proto = location.protocol === 'https:' ? 'wss' : 'ws';
var ws;
try { ws = new WebSocket(proto + '://' + location.host + '/ws'); } catch (e) { scheduleReconnect(); return; }
state.ws = ws;
ws.onopen = function () {
ws.send(JSON.stringify({ type: 'hello', chat_id: state.activeId, user_id: 'web-user' }));
setConn(true);
};
ws.onmessage = function (e) {
var v; try { v = JSON.parse(e.data); } catch (err) { return; }
handleFrame(v);
};
ws.onclose = function () { setConn(false); if (!state.closedByUs) scheduleReconnect(); };
ws.onerror = function () { try { ws.close(); } catch (err2) {} };
}
function reconnect() {
state.closedByUs = true;
try { state.ws && state.ws.close(); } catch (e) {}
setConn(false);
setTimeout(connect, 60);
}
function scheduleReconnect() {
setConn(false);
clearTimeout(state.reconnectTimer);
state.reconnectTimer = setTimeout(connect, 2500);
}
function setConn(on) {
state.wsReady = on;
$('#conn-dot').className = on ? 'on' : '';
$('#conn-label').textContent = on ? '已连接' : '重连中…(桌面端或网关未在线)';
}
/* ===== 帧处理(网关 → 浏览器) ===== */
function handleFrame(v) {
var c = activeConv(); if (!c) return;
switch (v.type) {
case 'stream_delta': {
var m = lastAiStreaming(c);
if (!m) { m = { role: 'ai', text: '' }; c.msgs.push(m); setGenerating(true); }
m.text = v.text || ''; // 网关推的是全量聚合文本,直接覆盖
appendLive(m);
break;
}
case 'stream_done': {
var m2 = lastAiStreaming(c);
if (m2) { m2.text = v.text || m2.text || ''; m2.done = true; }
else { c.msgs.push({ role: 'ai', text: v.text || '', done: true }); }
setGenerating(false);
renderMsgs();
break;
}
case 'approval': {
var ap = { role: 'approval', approval_id: v.approval_id, title: v.title, detail: v.detail };
c.msgs.push(ap); setGenerating(true); // 等审批期间视为生成中(停止按钮可用)
renderMsgs();
break;
}
case 'notice': {
c.msgs.push({ role: 'sys', text: v.text || '' });
renderMsgs();
break;
}
}
saveStore();
}
function lastAiStreaming(c) {
for (var i = c.msgs.length - 1; i >= 0; i--) {
var m = c.msgs[i];
if (m.role === 'ai' && !m.done) return m;
if (m.role === 'ai') return null; // 最新 ai 消息已完成 → 开新条
}
return null;
}
var liveEl = null;
function appendLive(m) {
if (!liveEl || !liveEl._m) {
// 首个 delta:占位气泡(增量期间只更新最后一条,不全量重渲染,防抖动)
msgsEl.appendChild(msgNode(m));
liveEl = msgsEl.lastChild;
liveEl._m = m;
}
liveEl.lastChild.innerHTML = fmtText(m.text);
scrollBottom();
}
/* ===== 生成态(发送/停止按钮) ===== */
function setGenerating(on) {
state.generating = on;
sendBtn.style.display = on ? 'none' : '';
stopBtn.style.display = on ? '' : 'none';
sendBtn.disabled = on;
}
/* ===== 发送/停止 ===== */
function doSend() {
var text = inpEl.value.trim(); if (!text) return;
var c = ensureConv();
if (c.msgs.length === 0) { c.name = text.slice(0, 18); renderConvList(); } // 首条消息作会话名
c.msgs.push({ role: 'user', text: text });
saveStore(); renderMsgs();
inpEl.value = ''; inpEl.style.height = 'auto';
if (!wsSend({ type: 'message', text: text })) {
c.msgs.push({ role: 'sys', text: '⚠ 未连接,消息未发出(连接恢复后请重发)' }); renderMsgs();
}
}
sendBtn.onclick = doSend;
stopBtn.onclick = function () {
var c = activeConv();
// 本地止血:标记完成 + 提示(协议无 Stop 指令,桌面端继续跑完本轮,网关会继续推帧,本页忽略)
if (c) {
var m = lastAiStreaming(c);
if (m) m.done = true;
c.msgs.push({ role: 'sys', text: '■ 已停止显示(桌面端本轮可能仍在执行,结果以桌面端为准)' });
saveStore(); renderMsgs();
}
setGenerating(false);
};
/* ===== 输入框 ===== */
inpEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
});
inpEl.addEventListener('input', function () {
inpEl.style.height = 'auto';
inpEl.style.height = Math.min(inpEl.scrollHeight, 120) + 'px';
});
/* ===== 顶栏按钮 ===== */
$('#new-conv').onclick = function () { newConv(true); };
$('#clear-btn').onclick = function () {
var c = activeConv(); if (!c) return;
if (!confirm('清空当前会话的页面记录?(不删桌面端对话)')) return;
c.msgs = []; saveStore(); renderMsgs();
};
$('#menu-btn').onclick = function () { $('#sidebar').classList.toggle('open'); };
function closeSidebar() { $('#sidebar').classList.remove('open'); }
/* ===== 启动 ===== */
loadStore();
if (!state.convs.length) newConv(false);
if (!state.activeId) state.activeId = state.convs[0].id;
renderConvList(); renderMsgs();
connect();
</script>
</body>
</html>
-7
View File
@@ -11,9 +11,6 @@ df-types = { path = "../df-types" }
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层 # 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。 # advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
df-nodes = { path = "../df-nodes" } df-nodes = { path = "../df-nodes" }
# df-project: bind_directory 复用技术栈探测(detect_stack)与路径规范化(normalize_path),
# 对齐 GUI tool_registry.rs::bind_dir_to_project,避免在本 crate 重复实现镜像。
df-project = { path = "../df-project" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -21,10 +18,6 @@ anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
uuid.workspace = true uuid.workspace = true
futures = "0.3" futures = "0.3"
# HTTP (streamable HTTP) transport 层:axum Router(workspace 已锁 0.7.9,零新依赖树)
axum = { version = "0.7" }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full", "test-util"] } tokio = { workspace = true, features = ["full", "test-util"] }
# 单测用 tower::ServiceExt::oneshot 直接打 Router
tower = { version = "0.5", features = ["util"] }
-184
View File
@@ -1,184 +0,0 @@
// df-mcp E2E 实测脚本:spawn 真实二进制,按行喂 JSON-RPC,断言修复项 + 全链路。
// 用法: node df-mcp-e2e.mjs <exe路径> [--db <临时库>]
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const exe = process.argv[2];
if (!exe) { console.error('用法: node df-mcp-e2e.mjs <exe路径>'); process.exit(1); }
const dbArg = process.argv[4] ?? join(mkdtempSync(join(tmpdir(), 'dfmcp-')), 'test.db');
let pass = 0, fail = 0;
const failures = [];
function assert(cond, name, extra) {
if (cond) { pass++; console.log(` ✅ ${name}`); }
else { fail++; failures.push(name); console.log(` ❌ ${name}${extra ? ` — ${extra}` : ''}`); }
}
function session(dbPath, readOnly = false) {
const args = ['mcp-server', '--db', dbPath];
if (readOnly) args.push('--read-only');
const child = spawn(exe, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const rl = createInterface({ input: child.stdout });
const pending = [];
const waiters = [];
let buf = '';
rl.on('line', (line) => {
const resolved = waiters.shift();
if (resolved) resolved(line);
});
child.stderr.on('data', (d) => process.stderr.write(` [server] ${d}`));
let idSeq = 0;
return {
async call(req) {
const payload = { jsonrpc: '2.0', id: ++idSeq, ...req };
const p = new Promise((res) => waiters.push(res));
child.stdin.write(JSON.stringify(payload) + '\n');
const line = await p;
return JSON.parse(line);
},
async raw(line) {
const p = new Promise((res) => waiters.push(res));
child.stdin.write(line + '\n');
const out = await p;
return JSON.parse(out);
},
close() { child.stdin.end(); },
};
}
// 从 CallToolResult 取文本
function txt(res) {
try { return res.result.content[0].text; } catch { return JSON.stringify(res); }
}
function json(res) { return JSON.parse(txt(res)); }
async function main() {
console.log(`\n═══ df-mcp E2E 实测 exe=${exe} db=${dbArg} ═══`);
// ── 实例 A:读写模式 ──────────────────────────────
const s = session(dbArg, false);
console.log('\n【1】握手与工具清单');
const init = await s.call({ method: 'initialize', params: {} });
assert(init.result.serverInfo.name === 'devflow-mcp', 'initialize 返回 serverInfo.name', init.result?.serverInfo?.name);
assert(init.result.capabilities.tools.listChanged === false, 'capabilities.tools 声明');
const tl = await s.call({ method: 'tools/list', params: {} });
const names = tl.result.tools.map(t => t.name);
const low = ['list_projects','get_project','list_tasks','list_ideas','evaluate_idea','list_trash'];
const med = ['create_project','update_project','bind_directory','create_task','update_task','advance_task','create_idea','update_idea','score_idea','restore_project'];
const high = ['delete_project','delete_task','delete_idea','run_workflow'];
assert(low.every(n => names.includes(n)), 'Low 工具全部可见');
assert(med.every(n => names.includes(n)), 'Medium 工具全部可见');
assert(!high.some(n => names.includes(n)), 'High 工具默认不可见');
assert(names.length === low.length + med.length, `工具数 = ${low.length + med.length}`, `实际 ${names.length}`);
console.log('\n【2】修复项:parse error 带 id:null(修复#1)');
const perr = await s.raw('{"jsonrpc":"2.0","id":999,"method":"bogus", BAD JSON');
assert(perr.error && perr.error.code === -32700, '非法 JSON → PARSE_ERROR(-32700)');
assert(perr.id === null, 'parse error 响应 id 必须为 null', `实际 ${JSON.stringify(perr.id)}`);
console.log('\n【3】修复项:tools/call 缺 name 明确报错(修复#7)');
const noname = await s.call({ method: 'tools/call', params: { arguments: {} } });
assert(noname.error && noname.error.code === -32601, '缺 name → METHOD_NOT_FOUND');
assert(noname.error.message.includes('name'), '错误消息含 name 提示', noname.error.message);
assert(!noname.error.message.includes('未知工具: '), '不是空名「未知工具: 」', noname.error.message);
console.log('\n【4】未知工具');
const unk = await s.call({ method: 'tools/call', params: { name: 'nope', arguments: {} } });
assert(txt(unk).includes('未知工具'), '未知工具报错', txt(unk));
console.log('\n【5】修复项:update_project 非法状态不再静默降级(修复#3)');
const cp = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目', description: '实测' } } });
assert(!cp.result.isError, 'create_project 成功', txt(cp));
const proj = json(cp);
const pid = proj.id;
const badSt = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, status: 'bogus_state' } } });
assert(badSt.result.isError === true, 'update_project 非法 status 报错');
assert(txt(badSt).includes('非法状态值'), '错误信息含"非法状态值"', txt(badSt));
console.log('\n【6】update_project 部分更新保留原值');
const upd = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, name: 'E2E项目改' } } });
assert(!upd.result.isError, '部分更新成功');
const updJ = json(upd);
assert(updJ.project.name === 'E2E项目改' && updJ.project.status === 'planning', 'name 更新 + status 保留 planning', `status=${updJ.project.status}`);
console.log('\n【7】create_task 带 parent_id(外部新增能力)');
const cproj = await s.call({ method: 'tools/call', params: { name: 'list_projects', arguments: {} } });
const listJ = json(cproj);
const parentT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '父任务', description: '' } } });
const parentId = json(parentT).id;
const childT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '子任务', description: '', parent_id: parentId } } });
assert(!childT.result.isError, '创建子任务成功', txt(childT));
const grandchild = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '孙任务', description: '', parent_id: json(childT).id } } });
assert(grandchild.result.isError === true && txt(grandchild).includes('父任务不能是子任务'), '1 级嵌套限制:孙任务被拒', txt(grandchild));
const badP = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: 'x', description: '', parent_id: 'ghost' } } });
assert(badP.result.isError === true && txt(badP).includes('父任务不存在'), '父任务不存在被拒', txt(badP));
console.log('\n【8】advance_task 状态机');
const adv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'in_progress' } } });
assert(!adv.result.isError && json(adv).task.status === 'in_progress', 'todo→in_progress 成功');
const badAdv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'done' } } });
assert(badAdv.result.isError === true, 'todo→done 非法跳态被拒', txt(badAdv));
console.log('\n【9】idea 闭环:evaluate(Low只读) → score(Medium写库)');
const ci = await s.call({ method: 'tools/call', params: { name: 'create_idea', arguments: { title: '核心功能', description: '需要立即重构关键模块以解除阻塞' } } });
const iid = json(ci).id;
const ev = await s.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
assert(!ev.result.isError && json(ev).scores.overall >= 0, 'evaluate_idea 返回分数');
const evJ = json(ev);
assert(evJ.idea.scores == null, 'evaluate_idea 不写库(scores 仍 null)');
const sc = await s.call({ method: 'tools/call', params: { name: 'score_idea', arguments: { id: iid } } });
const scJ = json(sc);
assert(!sc.result.isError && scJ.idea.scores != null, 'score_idea 写库成功');
console.log('\n【10】bind_directory:冲突/穿越/不存在路径');
const bd1 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid, path: 'C:/Temp/nonexist_dir' } } });
assert(!bd1.result.isError, '绑定不存在目录成功(规范化为绝对路径)');
const bdConflict = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目2', description: '' } } });
const pid2 = json(bdConflict).id;
const bd2 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'c:/temp/nonexist_dir' } } });
assert(bd2.result.isError === true && txt(bd2).includes('路径已被项目'), '路径冲突检测(大小写不敏感)', txt(bd2));
const bdTraverse = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'C:/temp/../Windows' } } });
assert(bdTraverse.result.isError === true && txt(bdTraverse).includes('..'), '.. 段拒绝', txt(bdTraverse));
console.log('\n【11】回收站 list_trash / restore_project');
const lt = await s.call({ method: 'tools/call', params: { name: 'list_trash', arguments: {} } });
assert(!lt.result.isError, 'list_trash 成功');
const rest = await s.call({ method: 'tools/call', params: { name: 'restore_project', arguments: { id: 'ghost' } } });
assert(rest.result.isError === true, '恢复不存在的项目报错', txt(rest));
console.log('\n【12】跨实体校验');
const cross = await s.call({ method: 'tools/call', params: { name: 'update_task', arguments: { id: iid, title: 'x' } } });
assert(cross.result.isError === true && txt(cross).includes('属于 idea'), 'update_task 传 idea id → 跨实体报错', txt(cross));
console.log('\n【13】High 工具即便绕过 tools/list 也被拒');
const del = await s.call({ method: 'tools/call', params: { name: 'delete_project', arguments: { id: pid } } });
assert(del.result.isError === true && txt(del).includes('DevFlow'), 'delete_project 默认拒绝', txt(del));
s.close();
// ── 实例 B:read-only 模式 ─────────────────────────
console.log('\n════ read-only 模式 ════');
const ro = session(dbArg, true);
const roList = await ro.call({ method: 'tools/list', params: {} });
const roNames = roList.result.tools.map(t => t.name);
assert(roNames.length === low.length, `read-only 仅 Low(${low.length} 个)`, `实际 ${roNames.length}`);
assert(roNames.every(n => low.includes(n)), 'read-only 只含 Low 工具');
const roCall = await ro.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'x', description: '' } } });
assert(roCall.result.isError === true && txt(roCall).includes('只读模式'), 'read-only 拒绝 Medium', txt(roCall));
const roEval = await ro.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
assert(!roEval.result.isError, 'read-only 放行 evaluate_idea(Low)');
ro.close();
try { await new Promise(r => setTimeout(r, 300)); rmSync(join(dbArg, '..'), { recursive: true, force: true }); } catch {}
console.log(`\n═══ 结果: ${pass} 通过 / ${fail} 失败 ═══`);
if (failures.length) { console.log('失败项:'); failures.forEach(f => console.log(` - ${f}`)); process.exit(1); }
console.log('✅ 全部通过');
}
main().catch(e => { console.error('脚本异常:', e); process.exit(1); });
+1 -2
View File
@@ -14,7 +14,6 @@
pub mod protocol; pub mod protocol;
pub mod server; pub mod server;
pub mod server_http;
pub mod tools; pub mod tools;
pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY}; pub use server::run_server;
-3
View File
@@ -118,9 +118,6 @@ impl McpMethod {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_owned(); .to_owned();
if name.is_empty() {
return McpMethod::Unknown("tools/call missing required 'name' parameter".to_owned());
}
let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null); let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null);
McpMethod::ToolsCall { name, arguments } McpMethod::ToolsCall { name, arguments }
} }
+26 -454
View File
@@ -8,13 +8,10 @@
//! //!
//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。 //! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。
//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。 //! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。
//! 生命周期:空闲超时(默认 60s 无请求)自动退出,防客户端强杀后进程残留;写操作(risk != Low)可选回调(stdio 预留接线点)。
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use df_storage::crud::SettingsRepo;
use df_storage::db::Database; use df_storage::db::Database;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -26,76 +23,35 @@ use crate::protocol::{
use crate::tools::{self, Ctx, RiskLevel}; use crate::tools::{self, Ctx, RiskLevel};
/// 协议版本(MCP 2025-06-18) /// 协议版本(MCP 2025-06-18)
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18"; const PROTOCOL_VERSION: &str = "2025-06-18";
pub(crate) const SERVER_NAME: &str = "devflow-mcp"; const SERVER_NAME: &str = "devflow-mcp";
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// app_settings KV key:stdio MCP 写库后落的变更标记,供 GUI 侧轮询兜底消费。 /// 启动 MCP server。
///
/// 独立进程形态的 stdio MCP 无 AppHandle(`on_write_call=None`),写库后 GUI 无从经
/// `df-data-changed` 事件感知,造成双端数据断层(P0-2)。此标记是跨进程的落库信令:
/// 每次成功写工具覆盖写入,值为 `{"at": <毫秒时间戳>, "tool": "<工具名>"}`。
/// GUI 侧轮询读此 key,`at` 变化即知库被外部改动;再按 `tool` 复用 df-data-changed
/// 的 entity 映射刷新对应列表(entity 映射留在 GUI 侧,避免本 crate 引入 df-ai 依赖)。
pub const MCP_DATA_CHANGED_KEY: &str = "mcp.data_changed";
/// MCP server 运行配置。
///
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
/// [`Default`] 即常用生产配置(可写 + 空闲超时 60s + 无写回调)。
pub struct ServerConfig {
/// 只读模式:true 则仅暴露 list/get 工具,Medium/High 写操作一律拒绝。
pub read_only: bool,
/// 空闲超时:连续 N 时长无 stdin 输入则自动退出进程(防客户端强杀后孤儿残留)。
/// `None` = 永不因空闲退出(常驻)。默认 60s。
pub idle_timeout: Option<Duration>,
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
read_only: false,
idle_timeout: Some(Duration::from_secs(60)),
on_write_call: None,
}
}
}
/// 启动 MCP server(stdin/stdout 接实际进程句柄)。
/// ///
/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式) /// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式)
/// - `config`:运行配置(只读/空闲超时/写操作回调),见 [`ServerConfig`] /// - `read_only`:true 则仅暴露 list/get 工具
/// ///
/// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`), /// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`),
/// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。 /// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。
pub async fn run_server(db_path: &Path, config: ServerConfig) -> anyhow::Result<()> { pub async fn run_server(db_path: &Path, read_only: bool) -> anyhow::Result<()> {
let db = Arc::new(Database::open(db_path).await?); let db = Arc::new(Database::open(db_path).await?);
let ctx = Ctx::new(db); let ctx = Ctx::new(db);
let stdin = tokio::io::stdin(); let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout(); let stdout = tokio::io::stdout();
main_loop(stdin, stdout, &ctx, &config).await main_loop(stdin, stdout, &ctx, read_only).await
} }
/// 可单测的主循环(参数化 stdin/stdout)。 /// 可单测的主循环(参数化 stdin/stdout)。
/// ///
/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。 /// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。
/// Notification(id=None)不回响应。 /// Notification(id=None)不回响应。
///
/// 生命周期:
/// - 空闲超时:仅在**等待下一个请求**(`read_line`)时计时;正在处理的请求不受影响,
/// 超时即 break 优雅退出(防客户端强杀后进程残留)。
/// - 写操作回调:成功执行的写工具(risk != Low)在响应写回后触发,供外部(GUI)感知数据变更。
pub async fn main_loop<R, W>( pub async fn main_loop<R, W>(
stdin: R, stdin: R,
stdout: W, stdout: W,
ctx: &Ctx, ctx: &Ctx,
config: &ServerConfig, read_only: bool,
) -> anyhow::Result<()> ) -> anyhow::Result<()>
where where
R: tokio::io::AsyncRead + Unpin, R: tokio::io::AsyncRead + Unpin,
@@ -107,17 +63,7 @@ where
loop { loop {
line.clear(); line.clear();
// 空闲超时:只包裹「等待下一请求」,不包裹 dispatch/写响应,处理期间绝不误杀。 let n = reader.read_line(&mut line).await?;
let n = match config.idle_timeout {
Some(dur) => match tokio::time::timeout(dur, reader.read_line(&mut line)).await {
Ok(n) => n,
Err(_elapsed) => {
tracing::info!(target: "df_mcp", idle_secs = dur.as_secs(), "空闲超时无新请求,自动退出");
break;
}
}?,
None => reader.read_line(&mut line).await?,
};
if n == 0 { if n == 0 {
// EOF(stdin 关闭),优雅退出 // EOF(stdin 关闭),优雅退出
break; break;
@@ -133,7 +79,7 @@ where
Err(e) => { Err(e) => {
// 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR // 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR
tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败"); tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败");
let resp = Response::err(Some(Value::Null), crate::protocol::PARSE_ERROR, "Parse error", None); let resp = Response::err(None, crate::protocol::PARSE_ERROR, "Parse error", None);
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
continue; continue;
} }
@@ -155,53 +101,17 @@ where
continue; continue;
} }
// 写回调需在 dispatch 后判定,先预取工具名(仅 tools/call 需要,避免 clone 整包请求) let resp = dispatch(ctx, read_only, req.id.clone(), method).await;
let tool_name = match &method {
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
_ => None,
};
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
if let Some(name) = tool_name {
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
// 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
record_data_change(ctx, &name).await;
if let Some(cb) = &config.on_write_call {
cb(&name);
}
}
}
} }
Ok(()) Ok(())
} }
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
///
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
///
/// stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
if resp.error.is_some() {
return false;
}
match &resp.result {
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
None => false,
}
}
/// 方法分发 → 构造 Response。 /// 方法分发 → 构造 Response。
/// ///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 /// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
/// `pub(crate)`:stdio(main_loop)与 HTTP(server_http)transport 共用。 async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
match method { match method {
McpMethod::Initialize { .. } => { McpMethod::Initialize { .. } => {
let result = InitializeResult { let result = InitializeResult {
@@ -225,10 +135,9 @@ pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, meth
} }
McpMethod::ToolsList => { McpMethod::ToolsList => {
let tools: Vec<_> = tools::all_tools() let tools: Vec<_> = tools::all_tools()
.iter() .into_iter()
.filter(|t| visible(read_only, t.risk)) .filter(|t| visible(read_only, t.risk))
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null)) .map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
.filter(|v| !v.is_null())
.collect(); .collect();
Response::ok(id, json!({ "tools": tools })) Response::ok(id, json!({ "tools": tools }))
} }
@@ -242,30 +151,22 @@ pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, meth
.unwrap_or(Value::Null), .unwrap_or(Value::Null),
); );
}; };
// 执行前防御:read-only 拒 Medium/High;High 兜底拒(handler 内也会拒,双保险)。 // read-only 模式:Medium/High 全拒
// 判定收口到 should_execute,与 main_loop 写回调共用同一事实源,避免两份逻辑漂移。 if read_only && spec.risk != RiskLevel::Low {
if !should_execute(read_only, spec.risk) {
let msg = if read_only && spec.risk != RiskLevel::Low {
format!("只读模式拒绝执行 {name}(风险等级 {:?})", spec.risk)
} else {
format!("High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。")
};
let r = CallToolResult::error(msg);
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// Low / Medium:执行前先校验 schema 必填参数(缺必填直接拒,
// 防 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入)
let missing: Vec<String> = required_names(&spec.tool.input_schema)
.into_iter()
.filter(|name| arguments.get(name).map_or(true, |v| v.is_null()))
.collect();
if !missing.is_empty() {
let r = CallToolResult::error(format!( let r = CallToolResult::error(format!(
"缺少必填参数: {}", "只读模式拒绝执行 {name}(风险等级 {:?})",
missing.join(", ") spec.risk
)); ));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null)); return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
} }
// 非 read-only:High 兜底拒绝(handler 内也会拒,双保险)
if spec.risk == RiskLevel::High {
let r = CallToolResult::error(format!(
"High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。"
));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// Low / Medium:执行
let result = (spec.handler)(ctx, arguments).await; let result = (spec.handler)(ctx, arguments).await;
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null)) Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
} }
@@ -278,66 +179,8 @@ pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, meth
} }
} }
/// 工具是否会被执行(dispatch 与 main_loop 写回调共用的判定)。
///
/// read-only 下仅 Low 可执行;非 read-only 下 High 仍兜底拒绝。两条件都过 → 可执行。
fn should_execute(read_only: bool, risk: RiskLevel) -> bool {
!(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High
}
/// 从工具 inputSchema 提取必填参数名列表。
///
/// 兼容 MCP schema required 的两种形态:array 显式列出必填属性;bool true 表示全部
/// properties 必填;false/缺失表示无必填。供 tools/call 执行前校验参数完整性,
/// 缺必填直接拒绝,杜绝 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入。
fn required_names(schema: &Value) -> Vec<String> {
match schema.get("required") {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect(),
Some(Value::Bool(true)) => {
let mut names: Vec<String> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
}
_ => Vec::new(),
}
}
/// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
///
/// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
/// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
fn is_executed_write(name: &str, read_only: bool) -> bool {
match tools::find(name) {
Some(spec) => spec.risk != RiskLevel::Low && should_execute(read_only, spec.risk),
None => false,
}
}
/// 写成功后向 [`MCP_DATA_CHANGED_KEY`] 覆盖写入一条变更标记(多进程兜底信令)。
///
/// 独立进程形态完成写库后,GUI 无法经事件感知;此标记是落库的持久信令,GUI 轮询读
/// 该 key、`at` 变化即知库被外部改动。写失败仅告警不阻断(工具调用已成功,下次成功
/// 写会再次覆盖,标记只作「有变更」的粗粒度提示,非精确事件队列)。
async fn record_data_change(ctx: &Ctx, tool_name: &str) {
let at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let value = json!({ "at": at, "tool": tool_name }).to_string();
if let Err(e) = SettingsRepo::new(&ctx.db).set(MCP_DATA_CHANGED_KEY, &value).await {
tracing::warn!(target: "df_mcp", tool = tool_name, err = %e, "写变更标记失败(不改工具调用结果)");
}
}
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见) /// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
pub(crate) fn visible(read_only: bool, risk: RiskLevel) -> bool { fn visible(read_only: bool, risk: RiskLevel) -> bool {
if read_only { if read_only {
risk == RiskLevel::Low risk == RiskLevel::Low
} else { } else {
@@ -368,8 +211,6 @@ async fn write_response<W: tokio::io::AsyncWrite + Unpin>(
mod tests { mod tests {
use super::*; use super::*;
use crate::tools::RiskLevel; use crate::tools::RiskLevel;
use std::sync::Mutex;
use tokio::io::AsyncWriteExt;
/// 构造内存 DB + Ctx /// 构造内存 DB + Ctx
async fn test_ctx() -> Ctx { async fn test_ctx() -> Ctx {
@@ -446,22 +287,6 @@ mod tests {
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND); assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
} }
#[tokio::test]
async fn tools_call_missing_name_returns_clear_error() {
// tools/call 缺 name 参数:不应回「未知工具: 」(空名),应回明确 METHOD_NOT_FOUND
let input =
r#"{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"arguments":{}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
let msg = v["error"]["message"].as_str().unwrap();
assert!(
msg.contains("name"),
"空 name 应给出明确提示,实际: {msg}"
);
assert!(!msg.contains("未知工具: "), "不应是空名「未知工具: 」: {msg}");
}
#[tokio::test] #[tokio::test]
async fn tools_call_high_risk_is_rejected() { async fn tools_call_high_risk_is_rejected() {
let input = let input =
@@ -506,54 +331,6 @@ mod tests {
assert_eq!(list_v["projects"][0]["name"], "McpProj"); assert_eq!(list_v["projects"][0]["name"], "McpProj");
} }
// ── schema required 校验(dispatch 层缺必填直接拒)────────────────
#[tokio::test]
async fn tools_call_missing_required_arg_returns_error() {
// create_project schema required 含 name:缺 name 应被 dispatch 层拒绝
// (而非 handler 内 arg_str_or 静默兜底把 description 当空串)
let input =
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"description":"缺 name"}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["result"]["isError"], true);
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("缺少必填参数: name"), "实际: {text}");
}
#[test]
fn required_names_from_array() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {} },
"required": ["id", "name"]
});
assert_eq!(required_names(&schema), vec!["id".to_string(), "name".to_string()]);
}
#[test]
fn required_names_from_bool_true_means_all_properties() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {}, "desc": {} },
"required": true
});
// bool true = 全部 properties 必填,返回全部属性名(排序保证确定性)
assert_eq!(
required_names(&schema),
vec!["desc".to_string(), "id".to_string(), "name".to_string()]
);
}
#[test]
fn required_names_false_or_missing_is_empty() {
assert_eq!(required_names(&json!({ "type": "object" })), Vec::<String>::new());
assert_eq!(
required_names(&json!({ "type": "object", "required": false })),
Vec::<String>::new()
);
}
#[tokio::test] #[tokio::test]
async fn visible_predicate() { async fn visible_predicate() {
assert!(visible(false, RiskLevel::Low)); assert!(visible(false, RiskLevel::Low));
@@ -563,209 +340,4 @@ mod tests {
assert!(!visible(true, RiskLevel::Medium)); assert!(!visible(true, RiskLevel::Medium));
assert!(!visible(true, RiskLevel::High)); assert!(!visible(true, RiskLevel::High));
} }
// ── should_execute 判定(dispatch 与写回调共用)────────────────────
#[test]
fn should_execute_predicate() {
assert!(should_execute(false, RiskLevel::Low));
assert!(should_execute(false, RiskLevel::Medium));
assert!(!should_execute(false, RiskLevel::High));
assert!(should_execute(true, RiskLevel::Low));
assert!(!should_execute(true, RiskLevel::Medium));
assert!(!should_execute(true, RiskLevel::High));
}
// ── 空闲超时 / 写操作回调(main_loop 集成)────────────────────────
/// 构造测试配置:只读开关 + 默认空闲超时 + 默认无回调(字段可覆盖)
async fn test_config(read_only: bool) -> ServerConfig {
ServerConfig {
read_only,
idle_timeout: Some(Duration::from_secs(60)),
on_write_call: None,
}
}
#[tokio::test]
async fn main_loop_exits_on_idle_timeout() {
let ctx = test_ctx().await;
// 用 duplex 造一个「开着但永不写数据」的 stdin:read_line 会一直挂起,
// 空闲超时(100ms)触发后应正常 break 退出,而非阻塞或 panic。
let (_tx, rx) = tokio::io::duplex(1024);
let config = ServerConfig {
idle_timeout: Some(Duration::from_millis(100)),
..test_config(false).await
};
let outer = tokio::time::timeout(
Duration::from_secs(2),
main_loop(rx, tokio::io::sink(), &ctx, &config),
)
.await;
let inner = outer.expect("main_loop 应在空闲超时后返回,而非一直阻塞");
assert!(inner.is_ok(), "空闲超时退出应为 Ok,实际: {inner:?}");
}
#[tokio::test]
async fn main_loop_fires_write_callback_for_write_tool() {
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx); // 关 stdin → 处理后 EOF,正常退出
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().contains(&"create_project".to_string()),
"写工具 create_project 应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
#[tokio::test]
async fn main_loop_no_write_callback_for_read_tool() {
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"只读工具 list_projects 不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
#[tokio::test]
async fn main_loop_no_write_callback_when_read_only_denies_write() {
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
read_only: true,
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(true).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"read-only 下 Medium 写被拒,不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
#[tokio::test]
async fn main_loop_no_write_callback_on_business_failure() {
// MC-5业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"业务失败(isError=true)不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
// ── 多进程兜底:stdio 写库后落变更标记 ────────────────────────────
#[tokio::test]
async fn main_loop_records_data_change_marker_for_write_tool() {
// stdio 形态(on_write_call=None)成功写工具后应向 KV 落变更标记,
// 供 GUI 轮询消费——即便无任何回调注入也不丢变更感知(P0-2 兜底)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"MarkProj","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap()
.expect("写工具成功后应落变更标记");
let parsed: Value = serde_json::from_str(&v).unwrap();
assert_eq!(parsed["tool"], "create_project");
assert!(parsed["at"].as_u64().unwrap() > 0, "标记应含毫秒时间戳: {v}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_for_read_tool() {
// 只读工具无副作用,不应落变更标记(否则 GUI 无谓刷新)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "只读工具不应落变更标记,实际: {v:?}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_when_read_only_denies_write() {
// read-only 下写被拒(未真正写库),不应落变更标记。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(true).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "read-only 下写被拒不应落变更标记,实际: {v:?}");
}
} }
-341
View File
@@ -1,341 +0,0 @@
//! HTTP (streamable HTTP, 2025-06-18) transport 层。
//!
//! 桌面进程内嵌 server 的传输层:POST /mcp 单响应 JSON;GET 405;notification 202 无 body。
//! 与 stdio transport 共享同一 dispatch/handler/Ctx,纯 df-mcp 内部实现,零 tauri 依赖。
//!
//! 协议合规(streamable HTTP 2025-06-18):
//! - POST /mcp → `application/json` 单响应(dispatch 层错误随 JSON-RPC 错误体回,HTTP 仍 200)
//! - GET /mcp → 405 + `Allow: POST`
//! - notification(id 缺省)→ 202 Accepted 无 body
//! - batch 数组 body → 400 + INVALID_REQUEST(本 server 不支持 batch)
//! - 非法 JSON body → 400 + PARSE_ERROR
//! - 无状态 server:忽略 Mcp-Session-Id 头(合规)
//!
//! 桌面端注入方式:构造 [`McpHttpState`] 传入共享 db + on_tool_call 回调,
//! [`build_router`] 得 Router,再 `axum::serve(listener, router)` 常驻监听。
use std::sync::Arc;
use axum::{
body::Bytes,
extract::State,
http::{header, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::post,
Router,
};
use df_storage::db::Database;
use serde_json::{json, Value};
use crate::protocol::{McpMethod, Request, INVALID_REQUEST, PARSE_ERROR};
use crate::server::dispatch;
use crate::tools::Ctx;
/// 默认端口(桌面内嵌固定端口;可用 env `DEVFLOW_MCP_PORT` 覆盖)。
pub const DEFAULT_MCP_PORT: u16 = 18765;
/// HTTP MCP 共享状态(axum State)。
pub struct McpHttpState {
pub ctx: Ctx,
pub read_only: bool,
/// 成功 tools/call 回调(工具名)。桌面端注入 → emit df-data-changed;None=不回调。
pub on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
}
impl McpHttpState {
pub fn new(
db: Arc<Database>,
read_only: bool,
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
) -> Self {
Self {
ctx: Ctx::new(db),
read_only,
on_tool_call,
}
}
}
/// 构造 axum Router(/mcp 单路由:POST 处理请求,GET 回 405)。
pub fn build_router(state: McpHttpState) -> Router {
Router::new()
.route("/mcp", post(post_mcp).get(get_mcp))
.with_state(Arc::new(state))
}
/// 在已绑定 listener 上启动 HTTP server(永不返回直至 shutdown)。
///
/// 桌面端用法:bind 127.0.0.1:18765 → spawn(serve_on(listener, state)),
/// 进程内常驻,多 Claude 会话 HTTP 直连同一进程。
pub async fn serve_on(listener: tokio::net::TcpListener, state: McpHttpState) -> anyhow::Result<()> {
axum::serve(listener, build_router(state))
.await
.map_err(|e| anyhow::anyhow!("axum::serve 失败: {e}"))
}
/// POST /mcp:解析 body → dispatch → JSON 单响应 / 202 notification。
async fn post_mcp(State(state): State<Arc<McpHttpState>>, body: Bytes) -> Response {
match handle_body(&state, &body).await {
Outcome::Json(status, value) => (status, axum::Json(value)).into_response(),
Outcome::Accepted => StatusCode::ACCEPTED.into_response(),
}
}
/// GET /mcp → 405 + `Allow: POST`(streamable HTTP 规范)。
async fn get_mcp() -> Response {
let mut resp = StatusCode::METHOD_NOT_ALLOWED.into_response();
resp.headers_mut()
.insert(header::ALLOW, HeaderValue::from_static("POST"));
resp
}
/// 分发结果:单响应 JSON / notification 202。
enum Outcome {
/// 单响应(HTTP 状态码 + JSON-RPC body)
Json(StatusCode, Value),
/// notification:202 Accepted,无 body
Accepted,
}
/// 解析请求体并分发到共享 dispatch。
async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
// ① UTF-8 校验
let text = match std::str::from_utf8(body) {
Ok(t) => t,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, PARSE_ERROR, "Parse error: 请求体不是合法 UTF-8"),
);
}
};
// ② 整体 JSON 解析:数组 = batch,本 server 不支持
let raw: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, PARSE_ERROR, "Parse error: 非法 JSON"),
);
}
};
if raw.is_array() {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, INVALID_REQUEST, "Invalid request: batch 请求不支持"),
);
}
// ③ 解析为 JSON-RPC Request(结构非法 → INVALID_REQUEST)
let req: Request = match serde_json::from_value(raw) {
Ok(r) => r,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, INVALID_REQUEST, "Invalid request"),
);
}
};
let method = McpMethod::from_request(&req);
let is_notification = req.id.is_none();
// ④ notification(id 缺省)→ 202 Accepted 无 body
if is_notification {
match method {
McpMethod::Initialized => {
tracing::debug!(target: "df_mcp", "HTTP: 客户端 initialized 通知已收");
}
_ => {
tracing::debug!(target: "df_mcp", m = ?method, "HTTP: 忽略未识别通知");
}
}
return Outcome::Accepted;
}
// ⑤ 工具名预取(tools/call 成功回调用;method 随后 move 进 dispatch)
let tool_name = match &method {
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
_ => None,
};
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
// (桌面端据此刷新 GUI)。MC-5/业务失败(handler 返 isError=true)不触发,
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
if crate::server::is_success_tool_call(&resp) {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
cb(&name);
}
}
let value = serde_json::to_value(&resp)
.unwrap_or_else(|_| rpc_error(req.id, crate::protocol::INTERNAL_ERROR, "响应序列化失败"));
Outcome::Json(StatusCode::OK, value)
}
/// 构造 JSON-RPC 2.0 错误响应体。
fn rpc_error(id: Option<Value>, code: i32, message: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message }
})
}
// ============================================================
// 单测:tower oneshot 打 Router + open_in_memory DB
// 覆盖:initialize / tools/list / notification 202 / GET 405 / 非法 JSON / batch / on_tool_call
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{header, Request, StatusCode},
};
use std::sync::Mutex;
use tower::ServiceExt;
/// 构造内存 DB + McpHttpState(on_tool_call 可注入)
async fn test_state(
read_only: bool,
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
) -> McpHttpState {
let db = Arc::new(Database::open_in_memory().await.unwrap());
McpHttpState::new(db, read_only, on_tool_call)
}
/// oneshot 打 Router:method + body → (status, json body, headers)
async fn send(
router: Router,
method: &str,
body: &str,
) -> (StatusCode, Value, axum::http::HeaderMap) {
let builder = Request::builder().uri("/mcp").method(method);
let builder = if method == "GET" {
builder
} else {
builder.header(header::CONTENT_TYPE, "application/json")
};
let req = builder.body(Body::from(body.to_string())).unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let value: Value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, value, headers)
}
#[tokio::test]
async fn post_initialize_returns_server_info_and_capabilities() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 1);
assert_eq!(v["result"]["protocolVersion"], crate::server::PROTOCOL_VERSION);
assert_eq!(v["result"]["serverInfo"]["name"], crate::server::SERVER_NAME);
assert!(v["result"]["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn post_tools_list_excludes_high_risk_by_default() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::OK);
let tools = v["result"]["tools"].as_array().unwrap();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
// 默认(非 read-only):Low + Medium 可见,High 不可见
assert!(names.contains(&"list_projects"));
assert!(names.contains(&"create_project")); // Medium
assert!(!names.contains(&"delete_project")); // High
assert!(!names.contains(&"run_workflow")); // High
}
#[tokio::test]
async fn post_notification_returns_202_no_body() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(v, Value::Null);
}
#[tokio::test]
async fn get_mcp_returns_405_allow_post() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, _, headers) = send(router, "GET", "").await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(headers.get(header::ALLOW).unwrap(), "POST");
}
#[tokio::test]
async fn post_invalid_json_returns_400_parse_error() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(router, "POST", "not json").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(v["error"]["code"], PARSE_ERROR);
}
#[tokio::test]
async fn post_batch_returns_400_invalid_request() {
let state = test_state(false, None).await;
let router = build_router(state);
let body = r#"[{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}]"#;
let (status, v, _) = send(router, "POST", body).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(v["error"]["code"], INVALID_REQUEST);
}
#[tokio::test]
async fn tools_call_success_triggers_on_tool_call() {
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>> = Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
}));
let state = test_state(false, on_tool_call).await;
let router = build_router(state);
let body = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"HttpProj","description":"via http"}}}"#;
let (status, v, _) = send(router, "POST", body).await;
assert_eq!(status, StatusCode::OK);
// 成功业务响应:isError 不置位
assert!(
v["result"]["isError"].is_null() || v["result"]["isError"] == Value::Bool(false)
);
assert!(
calls.lock().unwrap().contains(&"create_project".to_string()),
"成功 tools/call 应触发 on_tool_call,实际: {:?}",
*calls.lock().unwrap()
);
}
}
+103 -1368
View File
File diff suppressed because it is too large Load Diff
-207
View File
@@ -1,207 +0,0 @@
//! update_idea/update_project/update_task 部分更新回归测试
//!
//! 回归 P0 bug:LLM 客户端做部分更新(只传 description 不传 title)时,
//! 旧实现用 `arg_str_or(args, "title", "")` 取值,缺省 → 空串覆盖 existing
//! → title 被静默清空 → 数据丢失。
//!
//! 根本修:title/description(name)缺省回退 existing,而非空默认覆盖。
//! 本测试覆盖三个 handler 的「只传一个字段,另一字段保留 existing」语义。
use df_mcp::tools::{find, Ctx};
use df_storage::db::Database;
use serde_json::{json, Value};
/// 从 CallToolResult 取首个 text 块解析为 JSON。
fn result_json(res: &df_mcp::protocol::CallToolResult) -> Value {
assert!(
res.is_error != Some(true),
"工具调用失败(is_error=true): {:?}",
res.content
);
match res.content.first() {
Some(df_mcp::protocol::ContentBlock::Text { text }) => {
serde_json::from_str(text).expect("响应非合法 JSON")
}
other => panic!("预期 Text 块,实际: {other:?}"),
}
}
/// 取嵌套对象 record(title/name/description 等业务字段在其下)。
fn record_of(v: &Value, key: &str) -> Value {
v.get(key)
.cloned()
.unwrap_or_else(|| panic!("响应缺 `{key}` 字段: {v}"))
}
async fn setup() -> Ctx {
let db = Database::open_in_memory().await.expect("open_in_memory");
Ctx::new(std::sync::Arc::new(db))
}
async fn call(ctx: &Ctx, name: &str, args: Value) -> Value {
let spec = find(name).expect("工具已注册");
let res = (spec.handler)(ctx, args).await;
result_json(&res)
}
// ============================================================
// update_idea:只传 description,title 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_idea_keeps_title_when_only_description_sent() {
let ctx = setup().await;
// 先建一条想法:title="原始标题"
let created = call(
&ctx,
"create_idea",
json!({ "title": "原始标题", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
// LLM 只传 description(不传 title)—— 旧实现会把 title 清空为 ""
let updated = call(
&ctx,
"update_idea",
json!({ "id": id, "description": "新描述" }),
)
.await;
let idea = record_of(&updated, "idea");
assert_eq!(idea["title"].as_str(), Some("原始标题"), "title 应保留 existing,不被空默认清空");
assert_eq!(idea["description"].as_str(), Some("新描述"), "description 应更新为新值");
}
#[tokio::test]
async fn update_idea_keeps_description_when_only_title_sent() {
let ctx = setup().await;
let created = call(
&ctx,
"create_idea",
json!({ "title": "原标题", "description": "原描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(&ctx, "update_idea", json!({ "id": id, "title": "新标题" })).await;
let idea = record_of(&updated, "idea");
assert_eq!(idea["title"].as_str(), Some("新标题"));
assert_eq!(idea["description"].as_str(), Some("原描述"), "description 应保留 existing");
}
// ============================================================
// update_project:只传 description,name 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_project_keeps_name_when_only_description_sent() {
let ctx = setup().await;
let created = call(
&ctx,
"create_project",
json!({ "name": "原始项目", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(
&ctx,
"update_project",
json!({ "id": id, "description": "新描述" }),
)
.await;
let project = record_of(&updated, "project");
assert_eq!(project["name"].as_str(), Some("原始项目"), "name 应保留 existing");
assert_eq!(project["description"].as_str(), Some("新描述"));
// path/stack/idea_id 未传也应保留(existing 创建时为 None,这里间接保证不被改)
assert_eq!(project["path"].as_str(), None);
}
#[tokio::test]
async fn update_project_keeps_status_when_not_sent() {
// 状态字段缺省同样应保留 existing(旧实现默认 "planning" 会重置状态)
let ctx = setup().await;
let created = call(
&ctx,
"create_project",
json!({ "name": "P", "description": "D", "status": "in_progress" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(
&ctx,
"update_project",
json!({ "id": id, "description": "改描述" }),
)
.await;
let project = record_of(&updated, "project");
assert_eq!(
project["status"].as_str(),
Some("in_progress"),
"status 应保留 existing,不被默认 planning 重置"
);
}
// ============================================================
// update_task:只传 description,title/project_id 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_task_keeps_title_and_project_when_only_description_sent() {
let ctx = setup().await;
let proj = call(
&ctx,
"create_project",
json!({ "name": "所属项目", "description": "d" }),
)
.await;
let project_id = proj["id"].as_str().expect("project id").to_owned();
let created = call(
&ctx,
"create_task",
json!({ "project_id": project_id, "title": "原始任务标题", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
// 只传 description:旧实现 title 是必填会报错,description 缺省会清空(行为不一)
// 根本修后三者都应保留 existing(或更新为新值)
let updated = call(
&ctx,
"update_task",
json!({ "id": id, "description": "新描述" }),
)
.await;
let task = record_of(&updated, "task");
assert_eq!(task["title"].as_str(), Some("原始任务标题"), "title 应保留 existing");
assert_eq!(task["description"].as_str(), Some("新描述"));
assert_eq!(task["project_id"].as_str(), Some(project_id.as_str()), "project_id 应保留 existing");
// status 走状态机,update_task 不改也应保留
assert_eq!(task["status"].as_str(), Some("todo"));
}
#[tokio::test]
async fn update_task_keeps_description_when_only_title_sent() {
let ctx = setup().await;
let proj = call(
&ctx,
"create_project",
json!({ "name": "P2", "description": "d" }),
)
.await;
let project_id = proj["id"].as_str().expect("project id").to_owned();
let created = call(
&ctx,
"create_task",
json!({ "project_id": project_id, "title": "原标题", "description": "原描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(&ctx, "update_task", json!({ "id": id, "title": "新标题" })).await;
let task = record_of(&updated, "task");
assert_eq!(task["title"].as_str(), Some("新标题"));
assert_eq!(task["description"].as_str(), Some("原描述"), "description 应保留 existing");
}

Some files were not shown because too many files have changed in this diff Show More