Compare commits
39 Commits
conv-state
...
7b5b62d3ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b5b62d3ae | |||
| 50aad375eb | |||
| b72df78462 | |||
| 3c18dea45b | |||
| 8d18918e39 | |||
| cb7e1fce82 | |||
| cc3ef5bc1d | |||
| 996f1d9e5f | |||
| 696e34407c | |||
| 19b647e650 | |||
| 10629f88e8 | |||
| b5e159f190 | |||
| 55542abf41 | |||
| 3fac0a618d | |||
| 9839935782 | |||
| 4a396f4f88 | |||
| 7adaf97377 | |||
| 6ffcba7e4d | |||
| b157bc9077 | |||
| f776336eb1 | |||
| ad1821bc14 | |||
| c4b02b5370 | |||
| fee856129c | |||
| ead490783f | |||
| dcc3f0d230 | |||
| d1b9488853 | |||
| 48c966f6f7 | |||
| 7e3fe1961a | |||
| 8723075360 | |||
| 53e1c1da77 | |||
| e4ceb0015b | |||
| 22362a77b8 | |||
| cf223fc08b | |||
| b7b004dd68 | |||
| b7b54eb2a6 | |||
| 6212002270 | |||
| 7cd833b1e5 | |||
| 5c539fe764 | |||
| 6e1485e4f9 |
@@ -19,7 +19,9 @@
|
||||
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
|
||||
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
|
||||
|
||||
### 层级模型
|
||||
### 层级模型(业务层级)
|
||||
|
||||
DevFlow 的业务抽象分三层,自上而下层层实例化:
|
||||
|
||||
```
|
||||
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
|
||||
@@ -31,6 +33,40 @@
|
||||
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
|
||||
```
|
||||
|
||||
### 层级模型(执行层级)
|
||||
|
||||
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 模板层 (Template) │
|
||||
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
|
||||
│ │
|
||||
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
|
||||
│ 生命周期: 长期存在,跨项目复用 │
|
||||
│ 存储: YAML 文件 / DB 模板库 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 工作流层 (Workflow) │
|
||||
│ "怎么执行" — DAG 实例、状态机、运行时 │
|
||||
│ │
|
||||
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
|
||||
│ 生命周期: 随项目启动/结束,单次执行后归档 │
|
||||
│ 载体: df-workflow (DAG + Executor + StateMachine) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 人设层 (Persona) │
|
||||
│ "谁来做" — Agent 角色、能力边界、行为风格 │
|
||||
│ │
|
||||
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
|
||||
│ 生命周期: 长期存在,跨节点复用 │
|
||||
│ 注入点: AINode 执行时载入对应人设 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
|
||||
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
|
||||
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
|
||||
- 人设 = Agent 的角色卡(system prompt + 工具集 + 行为规则)
|
||||
|
||||
### AI Working 定位体系
|
||||
|
||||
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
|
||||
@@ -497,15 +533,21 @@ CREATE TABLE app_settings (
|
||||
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
|
||||
```
|
||||
|
||||
## 七、阶段模板
|
||||
## 七、三层模型:模板 → 工作流 → 人设
|
||||
|
||||
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。
|
||||
> 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
|
||||
|
||||
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分
|
||||
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
|
||||
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复
|
||||
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证
|
||||
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查
|
||||
### 7.1 三层定义
|
||||
|
||||
| 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
|
||||
|----|-----------|------|---------|---------|
|
||||
| **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
|
||||
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
|
||||
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
|
||||
|
||||
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
|
||||
|
||||
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
|
||||
|
||||
## 八、Phase 规划
|
||||
|
||||
@@ -528,9 +570,10 @@ CREATE TABLE app_settings (
|
||||
- 前端:想法池视图 + 多项目 Tab
|
||||
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
|
||||
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
|
||||
### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
|
||||
- df-nodes (Docker/Git/Human/HTTP)
|
||||
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理)
|
||||
- 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
|
||||
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
|
||||
- 条件分支 + 断点续跑
|
||||
- 验证:跑通标准产研流程模板
|
||||
|
||||
@@ -566,3 +609,6 @@ CREATE TABLE app_settings (
|
||||
8. **本地优先**:SQLite 嵌入,不依赖云服务
|
||||
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
|
||||
10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端
|
||||
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
|
||||
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
|
||||
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
|
||||
|
||||
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -859,6 +859,8 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tree-sitter",
|
||||
"tree-sitter-go",
|
||||
"tree-sitter-java",
|
||||
@@ -879,6 +881,7 @@ dependencies = [
|
||||
"df-types",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"rand",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4201,6 +4204,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -5039,6 +5048,19 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
|
||||
12
PROGRESS.md
12
PROGRESS.md
@@ -1031,6 +1031,18 @@
|
||||
|
||||
---
|
||||
|
||||
### 2026-06-28 — 最新对话 DB & Log 诊断:审批死锁 + Windows 兼容 + 标题 LLM 失败
|
||||
|
||||
> 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。
|
||||
> 7 次工具连续失败后 L1 断路器熔断,1 条 pending 审批导致全链路死锁。
|
||||
|
||||
- [ ] **P0 — 审批 pending 超时自动取消**:`try_continue` 入口清理 >5min 的 pending。
|
||||
- [ ] **P0 — `create_project` 加 auto_create_dir**:消除建项目→建目录→绑定的死锁链。
|
||||
- [ ] **P1 — `run_command` 失败追加 shell 适配提示**:引导 LLM 改正 PowerShell 命令。
|
||||
- [ ] **P1 — PowerShell 反斜杠自动转义**:消除 `os error 123`(Unicode 转义误识别)。
|
||||
- [ ] **P2 — 标题摘要合并连续同 role**:避免标题 LLM 的 GLM 1214 拒绝。
|
||||
- [ ] **P3 — migration V33 + L0-handshake 防抖**。
|
||||
|
||||
## 七、开发约定
|
||||
|
||||
### 构建命令
|
||||
|
||||
@@ -341,7 +341,7 @@ class WsClient {
|
||||
}
|
||||
const text = res.data
|
||||
if (!text) return
|
||||
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
|
||||
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
|
||||
this.lastInboundAt = Date.now()
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
|
||||
@@ -674,7 +674,14 @@ function subscribeWs(): void {
|
||||
}
|
||||
},
|
||||
onControl: (msg: ControlMessage) => {
|
||||
// 心跳响应等,miniapp 仅 console(不维持在线状态 UI)
|
||||
// 心跳 pong 响应:relay 收到 miniapp ping 后直接回 pong(不经 device),
|
||||
// miniapp 据此更新 lastPongTime 看门狗(ws.ts 已维护 lastInboundAt,但该路径不经过
|
||||
// onControl,这里记录到变量供日志诊断)。pong 闭环治移动网络 TCP 半连接挂死。
|
||||
if (msg.control_kind === 'pong') {
|
||||
console.debug('[useAiChat] 收到心跳 pong(relay 响应,连接健康)')
|
||||
return
|
||||
}
|
||||
// 其他 control 消息(hello_ack / pair 等)仅 console 不维持在线状态 UI
|
||||
console.log('[useAiChat] control', msg)
|
||||
},
|
||||
onStatus: (status: WsStatus, detail?: string) => {
|
||||
|
||||
@@ -42,13 +42,71 @@ export const defaultConfig: MiniappConfig = {
|
||||
reconnectMaxDelay: 30000,
|
||||
}
|
||||
|
||||
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
|
||||
let _config: MiniappConfig = { ...defaultConfig }
|
||||
/** storage key(持久化完整 MiniappConfig JSON) */
|
||||
const STORAGE_KEY = 'df-miniapp-config'
|
||||
|
||||
/** 单例配置(null 表示尚未从 storage 加载) */
|
||||
let _config: MiniappConfig | null = null
|
||||
|
||||
/**
|
||||
* 从 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 Partial<MiniappConfig>
|
||||
// 合并默认值,避免旧版本字段缺失导致 undefined
|
||||
return { ...defaultConfig, ...parsed }
|
||||
} 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 {
|
||||
// 首次调用时从 storage 加载并缓存
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
_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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,4 @@ tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
futures = "0.3"
|
||||
eventsource-stream = "0.2"
|
||||
rand = "0.8"
|
||||
|
||||
@@ -341,6 +341,14 @@ pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
|
||||
pub const TOOL_RESULT_HEAD_LINES: usize = 5;
|
||||
/// extract_key_info 保留的尾部行数。
|
||||
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
|
||||
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
|
||||
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
|
||||
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
|
||||
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
|
||||
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
|
||||
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
||||
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
||||
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
|
||||
|
||||
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
||||
///
|
||||
@@ -374,6 +382,54 @@ pub fn should_summarize_tool_result(
|
||||
///
|
||||
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
|
||||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
let mut truncated = false;
|
||||
for (_key, field) in obj.iter_mut() {
|
||||
// 数组截断
|
||||
if let Some(arr) = field.as_array() {
|
||||
if arr.len() > TOOL_RESULT_MAX_ARRAY {
|
||||
*field = serde_json::Value::Array(
|
||||
arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect()
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
// 字符串字段:先按行数截断,若不足再按字符数截断
|
||||
if let Some(s) = field.as_str() {
|
||||
let lines: Vec<&str> = s.lines().collect();
|
||||
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if lines.len() > kept {
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
|
||||
out.push("... (压缩中间内容) ...");
|
||||
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
|
||||
*field = serde_json::Value::String(out.join("\n"));
|
||||
truncated = true;
|
||||
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||||
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 按字符数截断保留头尾,保证压缩至少生效。
|
||||
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();
|
||||
*field = serde_json::Value::String(format!(
|
||||
"{}...(截断,原始 {} 字符)...{}",
|
||||
head, s.chars().count(), tail
|
||||
));
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
|
||||
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
|
||||
}
|
||||
}
|
||||
// JSON 解析成功但无需截断 → 原样返回
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
// 非 JSON 纯文本:按行数截断
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
@@ -383,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
let total = lines.len();
|
||||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if total <= kept_boundary {
|
||||
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 按字符数截断保证压缩至少生效。
|
||||
let char_count = content.chars().count();
|
||||
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||||
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
|
||||
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
|
||||
return format!(
|
||||
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
|
||||
tool_name, char_count, head, tail
|
||||
);
|
||||
}
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
@@ -877,18 +944,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_line_no_newline_unchanged() {
|
||||
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
|
||||
fn extract_key_info_single_line_short_no_newline_unchanged() {
|
||||
// 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
|
||||
let content = "single line no newline";
|
||||
assert_eq!(extract_key_info(content, "read_file"), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_huge_line_no_newline_unchanged() {
|
||||
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
|
||||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
let content = "x".repeat(50_000);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
|
||||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("已压缩"), "应含压缩标记");
|
||||
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
|
||||
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_json_huge_string_field_truncated() {
|
||||
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||
let large = "z".repeat(10_000);
|
||||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
||||
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
/// Agent 协调器
|
||||
///
|
||||
/// TODO: 实现多 Agent 协作逻辑
|
||||
#[deprecated(note = "B 路线占位,勿用于生产")]
|
||||
pub struct AgentCoordinator;
|
||||
|
||||
impl AgentCoordinator {
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
@@ -34,7 +35,8 @@ const BASE_BACKOFF_SECS: u64 = 1;
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
const JITTER_RATIO: f64 = 0.2;
|
||||
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
|
||||
const JITTER_RATIO: f64 = 1.0;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
///
|
||||
@@ -64,22 +66,18 @@ pub fn is_status_retryable(status: u16) -> bool {
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
|
||||
///
|
||||
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
///
|
||||
/// 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 {
|
||||
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
|
||||
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
|
||||
// ±50% jitter,相对 base 时长的浮动比例
|
||||
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 ms = (base_ms as f64 * factor).max(0.0) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
432
crates/df-execute/src/env_snapshot.rs
Normal file
432
crates/df-execute/src/env_snapshot.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
//! 环境感知系统 — 启动时一次性探测 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。
|
||||
let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect())
|
||||
.await
|
||||
.unwrap_or_else(|_| 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 失败时返回最小可用快照(全 None,字段不空)。
|
||||
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(复用 shell.rs 的 pwsh 探测语义)。
|
||||
fn detect_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
|
||||
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "pwsh".to_string();
|
||||
}
|
||||
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "powershell".to_string();
|
||||
}
|
||||
return "cmd".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 args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
|
||||
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
|
||||
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
|
||||
///
|
||||
/// 例: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() {
|
||||
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
|
||||
let a = EnvSnapshot::detect().await as *const _;
|
||||
let b = EnvSnapshot::detect().await as *const _;
|
||||
assert_eq!(a, b, "detect() 应返回同一静态引用");
|
||||
}
|
||||
|
||||
#[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,3 +1,6 @@
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
//! df-execute: 执行运行时 — Shell + 环境感知
|
||||
|
||||
pub mod env_snapshot;
|
||||
pub mod shell;
|
||||
|
||||
pub use env_snapshot::EnvSnapshot;
|
||||
|
||||
@@ -37,22 +37,36 @@ impl Default for ShellType {
|
||||
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||||
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||||
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
|
||||
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
|
||||
if cfg!(windows) {
|
||||
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
|
||||
fn probe_pwsh_cached() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
CACHE.get().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||||
///
|
||||
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||||
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||
fn probe_pwsh() -> bool {
|
||||
///
|
||||
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
|
||||
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
|
||||
async fn probe_pwsh() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
if let Some(cached) = CACHE.get() {
|
||||
return *cached;
|
||||
}
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
let mut cmd = std::process::Command::new("pwsh");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
@@ -63,6 +77,11 @@ fn probe_pwsh() -> bool {
|
||||
}
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
// 多任务竞态时以先到者为准,均等价
|
||||
let _ = CACHE.set(result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
@@ -88,6 +107,10 @@ pub struct ShellRequest {
|
||||
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 mut cmd = match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
||||
use df_types::types::new_id;
|
||||
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -235,10 +235,11 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
let status = arg_str_or(&args, "status", "planning");
|
||||
medium_audit("create_project", &name);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
||||
let rec = ProjectRecord {
|
||||
id: new_id(),
|
||||
name,
|
||||
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
};
|
||||
let name = arg_str_or(&args, "name", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
let status = arg_str_or(&args, "status", "planning");
|
||||
medium_audit("update_project", &id);
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
@@ -280,6 +281,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
||||
let rec = ProjectRecord {
|
||||
id: id.clone(),
|
||||
name,
|
||||
@@ -322,6 +324,10 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
medium_audit("bind_directory", &format!("{id} <- {path}"));
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
// 拒绝原始路径含 `..`(防穿越)
|
||||
if path.contains("..") {
|
||||
return CallToolResult::error(format!("路径不得包含 '..': {}", path));
|
||||
}
|
||||
let norm = normalize_path(&path);
|
||||
// 路径冲突检测
|
||||
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
|
||||
@@ -330,8 +336,8 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
conflict.name, conflict.id
|
||||
));
|
||||
}
|
||||
// 仅更新 path 字段(用 update_field,保留其它)
|
||||
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
|
||||
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
|
||||
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
|
||||
return CallToolResult::error(format!("项目不存在: {id}"));
|
||||
}
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
@@ -355,7 +361,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
list.retain(|t| t.project_id == *pid);
|
||||
}
|
||||
if let Some(st) = &status_filter {
|
||||
list.retain(|t| t.status == *st);
|
||||
list.retain(|t| t.status.as_str() == st.as_str());
|
||||
}
|
||||
json_ok(json!({ "tasks": list, "count": list.len() }))
|
||||
}
|
||||
@@ -384,7 +390,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status: "todo".to_owned(),
|
||||
status: TaskStatus::Todo,
|
||||
priority,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -529,7 +535,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
id: new_id(),
|
||||
title,
|
||||
description,
|
||||
status: "draft".to_owned(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority,
|
||||
score: None,
|
||||
tags: None,
|
||||
|
||||
@@ -273,6 +273,7 @@ mod tests {
|
||||
use df_storage::crud::{ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
use serde_json::json;
|
||||
|
||||
// ============================================================
|
||||
@@ -287,7 +288,7 @@ mod tests {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -302,7 +303,7 @@ mod tests {
|
||||
project_id: "p1".to_string(),
|
||||
title: "t1".to_string(),
|
||||
description: "实现登录接口".to_string(),
|
||||
status: "testing".to_string(),
|
||||
status: TaskStatus::Testing,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
|
||||
@@ -39,6 +39,32 @@ impl Node for ScriptNode {
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
|
||||
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
|
||||
// - 黑名单匹配时:直接拒绝执行
|
||||
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
if let Some(denied) = check_command_policy(&cmd_name) {
|
||||
tracing::warn!(
|
||||
command = %command,
|
||||
reason = %denied,
|
||||
"ScriptNode 命令被策略拒绝"
|
||||
);
|
||||
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
|
||||
}
|
||||
|
||||
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
|
||||
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
|
||||
for &kw in &dangerous_keywords {
|
||||
if command.contains(kw) {
|
||||
tracing::warn!(
|
||||
keyword = %kw,
|
||||
command = %command,
|
||||
"ScriptNode 即将执行包含危险关键词的命令"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("ScriptNode 执行命令: {}", command);
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
|
||||
@@ -93,3 +119,41 @@ impl Node for ScriptNode {
|
||||
"script"
|
||||
}
|
||||
}
|
||||
|
||||
/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。
|
||||
///
|
||||
/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
///
|
||||
/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝
|
||||
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝
|
||||
///
|
||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||
///
|
||||
/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。
|
||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
// 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令)
|
||||
if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") {
|
||||
let blacklist: Vec<String> = blacklist_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if blacklist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)");
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单:非空时命令名必须在白名单中才放行
|
||||
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
|
||||
let whitelist: Vec<String> = whitelist_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令不在白名单中 (DF_SCRIPT_WHITELIST)");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -174,14 +174,15 @@ mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::ProjectRepo;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
fn rec(id: &str, status: &str) -> TaskRecord {
|
||||
fn rec(id: &str, status: TaskStatus) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("t-{id}"),
|
||||
description: "".to_string(),
|
||||
status: status.to_string(),
|
||||
status,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -206,7 +207,7 @@ mod tests {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -221,43 +222,43 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn forward_path_todo_to_done() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 主路径逐级推进
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
assert_eq!(r.status.as_str(), "testing");
|
||||
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.status.as_str(), "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_in_review_to_in_progress_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_testing_to_in_review_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "testing")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_regressions_accumulate() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
|
||||
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
|
||||
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
@@ -269,7 +270,7 @@ mod tests {
|
||||
async fn illegal_skip_rejected() {
|
||||
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, expected } => {
|
||||
@@ -285,7 +286,7 @@ mod tests {
|
||||
async fn terminal_done_no_successor() {
|
||||
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "done")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, .. } => {
|
||||
@@ -301,7 +302,7 @@ mod tests {
|
||||
// 同态属空操作,归 Validation「相同状态,无需推进」;
|
||||
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::Validation(msg) => {
|
||||
@@ -314,7 +315,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn invalid_target_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
|
||||
assert!(matches!(err, df_types::error::Error::Validation(_)));
|
||||
}
|
||||
@@ -332,7 +333,7 @@ mod tests {
|
||||
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
|
||||
// 而是验证读后改路径在 from=当前库内 status 时正常推进。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
|
||||
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
|
||||
repo.advance_status_atomic("t1", "todo", "in_progress", false)
|
||||
@@ -340,13 +341,13 @@ mod tests {
|
||||
.unwrap();
|
||||
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cas_returns_none_when_status_mismatch() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "in_review", false)
|
||||
@@ -365,12 +366,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn blocked_round_trip_does_not_bump() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
|
||||
assert_eq!(r.status, "blocked");
|
||||
assert_eq!(r.status.as_str(), "blocked");
|
||||
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
||||
}
|
||||
|
||||
@@ -460,24 +461,24 @@ mod tests {
|
||||
async fn callback_completed_advance_lands_in_db() {
|
||||
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("c1", "todo")).await.unwrap();
|
||||
repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap();
|
||||
let to = callback_advance_target("completed", "in_progress").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
|
||||
|
||||
// testing: in_review → testing(②-3 testing 模板自审+核对通过)
|
||||
repo.insert(rec("c2", "in_review")).await.unwrap();
|
||||
repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap();
|
||||
let to = callback_advance_target("completed", "testing").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
assert_eq!(r.status.as_str(), "testing");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
|
||||
// done: testing → done(②-3 done 模板最终核对通过)
|
||||
repo.insert(rec("c3", "testing")).await.unwrap();
|
||||
repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap();
|
||||
let to = callback_advance_target("completed", "done").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.status.as_str(), "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
@@ -487,32 +488,32 @@ mod tests {
|
||||
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
|
||||
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("f1", "testing")).await.unwrap();
|
||||
repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap();
|
||||
let to = callback_advance_target("failed", "testing").unwrap();
|
||||
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
|
||||
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
|
||||
|
||||
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
|
||||
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
|
||||
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
|
||||
repo.insert(rec("f2", "in_review")).await.unwrap();
|
||||
repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap();
|
||||
let to = callback_advance_target("failed", "in_review").unwrap();
|
||||
assert_eq!(to, "in_progress");
|
||||
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 1);
|
||||
|
||||
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
|
||||
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
|
||||
// 验证:callback 返回 None,不调 advance_task_atomic。
|
||||
repo.insert(rec("f3", "in_progress")).await.unwrap();
|
||||
repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap();
|
||||
let to = callback_advance_target("failed", "in_progress");
|
||||
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
|
||||
// 任务状态未被改动(仍是 in_progress)
|
||||
let still = repo.get_by_id("f3").await.unwrap().unwrap();
|
||||
assert_eq!(still.status, "in_progress", "None 退回不应改动 status");
|
||||
assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status");
|
||||
}
|
||||
|
||||
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。
|
||||
|
||||
@@ -32,13 +32,12 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
|
||||
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
|
||||
use crate::error::{RelayError, Result};
|
||||
|
||||
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。
|
||||
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
|
||||
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
||||
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
|
||||
|
||||
/// 读取期望 token(env 优先,fallback 硬编码)
|
||||
fn expected_token() -> String {
|
||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| DEFAULT_TOKEN.to_string())
|
||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
|
||||
panic!("必须设置环境变量 DF_RELAY_TOKEN")
|
||||
})
|
||||
}
|
||||
|
||||
/// 客户端首消息:身份宣告(简单协议)
|
||||
@@ -347,7 +346,35 @@ async fn handle_inbound_text(
|
||||
) -> Result<()> {
|
||||
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
|
||||
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
|
||||
let payload: serde_json::Value = serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
|
||||
|
||||
// 心跳协议:miniapp 发 {control_kind: "ping"} → relay 直接回 pong(不经 device 透传)。
|
||||
// miniapp 用 pong 更新 lastPongTime 看门狗(防移动网络 TCP 半连接挂死)。
|
||||
// device 端收到 control 消息也仅 console.log,不影响业务。
|
||||
if let Some(control_kind) = payload
|
||||
.get("control_kind")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if control_kind == "ping" && kind == ClientKind::Miniapp {
|
||||
let pong_payload = serde_json::json!({"control_kind": "pong"});
|
||||
let pong_msg = BroadcastMessage {
|
||||
device_id: device_id.to_string(),
|
||||
kind: crate::broadcast::MessageKind::Control,
|
||||
source: conn_id,
|
||||
from: ClientKind::Device, // pong 来自 relay(代理 device),让 miniapp 识别为合法响应
|
||||
payload: pong_payload,
|
||||
ts: now_ms(),
|
||||
};
|
||||
let _ = state.route(&pong_msg).await;
|
||||
tracing::trace!(
|
||||
conn_id = conn_id.0,
|
||||
device_id = %device_id,
|
||||
"miniapp ping → relay pong(本地响应)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
let (msg_kind, from) = match kind {
|
||||
|
||||
@@ -84,6 +84,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
pinned: row.get::<_, i32>("pinned")? != 0,
|
||||
prompt_tokens: row.get("prompt_tokens")?,
|
||||
completion_tokens: row.get("completion_tokens")?,
|
||||
pinned_goals: row.get("pinned_goals")?,
|
||||
pending_approvals: row.get("pending_approvals")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -159,24 +161,24 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.created_at, rec.updated_at
|
||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11",
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13",
|
||||
params![
|
||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.updated_at, rec.id
|
||||
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
|
||||
use df_types::types::IdeaStatus;
|
||||
|
||||
use super::impl_repo;
|
||||
use super::{now_millis_str, storage_err, validate_column_name};
|
||||
@@ -93,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
|
||||
id: row.get("id")?,
|
||||
title: row.get("title")?,
|
||||
description: row.get("description")?,
|
||||
status: row.get("status")?,
|
||||
status: {
|
||||
let s: String = row.get("status")?;
|
||||
IdeaStatus::from_db_str(&s).unwrap_or_default()
|
||||
},
|
||||
priority: row.get("priority")?,
|
||||
score: row.get("score")?,
|
||||
tags: row.get("tags")?,
|
||||
@@ -200,7 +204,7 @@ impl_repo!(
|
||||
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
rec.id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
rec.scores, rec.related_ids, rec.created_at, rec.updated_at
|
||||
],
|
||||
@@ -210,7 +214,7 @@ impl_repo!(
|
||||
conn.execute(
|
||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
|
||||
params![
|
||||
rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
rec.scores, rec.related_ids, rec.updated_at, rec.id
|
||||
],
|
||||
@@ -297,10 +301,11 @@ impl IdeaRepo {
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
|
||||
let pat = format!("%{escaped}%");
|
||||
let p1 = params_vec.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
@@ -541,7 +546,8 @@ impl KnowledgeRepo {
|
||||
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
|
||||
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let pattern = format!("%{}%", query);
|
||||
let escaped = query.replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
let kind = kind.map(|s| s.to_owned());
|
||||
let limit_i = limit as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -549,7 +555,7 @@ impl KnowledgeRepo {
|
||||
let mut results = Vec::new();
|
||||
if let Some(k) = &kind {
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -559,7 +565,7 @@ impl KnowledgeRepo {
|
||||
}
|
||||
} else {
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') ORDER BY reuse_count DESC LIMIT ?3"))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -1403,7 +1409,7 @@ mod tests {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
description: String::new(),
|
||||
status: "draft".to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: 1,
|
||||
score: None,
|
||||
tags: None,
|
||||
|
||||
@@ -128,6 +128,57 @@ impl AiMessageRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按对话加载最近 N 条消息(分页懒加载,治长对话渲染卡顿)。
|
||||
///
|
||||
/// 从尾部取最近 `limit` 条(ORDER BY seq DESC LIMIT),返回时反转为 ASC 顺序(与
|
||||
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
|
||||
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
|
||||
///
|
||||
/// 典型用法:
|
||||
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
|
||||
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
|
||||
pub async fn list_recent(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
limit: usize,
|
||||
before_seq: Option<i64>,
|
||||
) -> Result<Vec<AiMessageRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conversation_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0)
|
||||
let limit = limit.max(1) as i64;
|
||||
let sql = if before_seq.is_some() {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at
|
||||
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
|
||||
} else {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at
|
||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
|
||||
let rows = match before_seq {
|
||||
Some(seq) => stmt
|
||||
.query_map(params![conv_id, seq, limit], ai_message_from_row)
|
||||
.map_err(storage_err)?,
|
||||
None => stmt
|
||||
.query_map(params![conv_id, limit], ai_message_from_row)
|
||||
.map_err(storage_err)?,
|
||||
};
|
||||
let mut results: Vec<AiMessageRecord> = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
// DESC → 反转为 ASC(与 list_by_conversation 一致顺序)
|
||||
results.reverse();
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
|
||||
///
|
||||
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。
|
||||
|
||||
@@ -22,6 +22,7 @@ mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_event_repo;
|
||||
mod project_module_repo;
|
||||
mod project_repo;
|
||||
mod project_service_repo;
|
||||
mod settings;
|
||||
@@ -33,6 +34,7 @@ pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_module_repo::*;
|
||||
pub use project_repo::*;
|
||||
pub use project_service_repo::*;
|
||||
pub use settings::*;
|
||||
@@ -305,6 +307,7 @@ mod baseline_tests {
|
||||
let _ = TaskLinkRepo::new(&db);
|
||||
let _ = ProjectEventRepo::new(&db);
|
||||
let _ = ProjectServiceRepo::new(&db);
|
||||
let _ = ProjectModuleRepo::new(&db);
|
||||
let _ = BranchRepo::new(&db);
|
||||
let _ = ReleaseRepo::new(&db);
|
||||
let _ = WorkflowRepo::new(&db);
|
||||
|
||||
@@ -219,6 +219,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
use df_types::types::ProjectStatus;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectEventRepo) {
|
||||
@@ -232,7 +233,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! 工程系统 — project_modules 表 CRUD(V34,项目多工程,每个工程独立代码仓库)
|
||||
//!
|
||||
//! 对标 [`project_service_repo`] 的风格(手写 Repo,与设计 §五工程系统 IPC 对齐)。
|
||||
//!
|
||||
//! 关键设计:
|
||||
//! - **不存 Git 状态**:分支/改动/最近提交是实时派生的(查 git 命令),不进表;
|
||||
//! 表只存工程元数据(路径/Git 地址/技术栈)。
|
||||
//! - **硬删**:`delete` / `delete_by_project` 物理删除(工程不需要软删审计追溯)。
|
||||
//! - **sort_order 排序**:`list_by_project` 按 `sort_order ASC` 返回,前端工程列表稳定顺序。
|
||||
//! - **auto_detected**:技术栈自动检测标志(扫描目录结构推断),人工覆盖时置 false。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ProjectModuleRecord;
|
||||
|
||||
use super::{now_millis_str, storage_err};
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn project_module_from_row(row: &Row<'_>) -> std::result::Result<ProjectModuleRecord, rusqlite::Error> {
|
||||
Ok(ProjectModuleRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
name: row.get("name")?,
|
||||
path: row.get("path")?,
|
||||
git_url: row.get("git_url")?,
|
||||
stack: row.get("stack")?,
|
||||
auto_detected: row.get("auto_detected")?,
|
||||
sort_order: row.get("sort_order")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Repo 实现(手写,参考 project_service_repo.rs 风格)
|
||||
// ============================================================
|
||||
|
||||
/// 工程系统 CRUD(project_modules,V34,可改非审计)。
|
||||
///
|
||||
/// 与 [`ProjectServiceRepo`](super::ProjectServiceRepo) 同款手写 Repo:
|
||||
/// 表有 `updated_at`,但工程无字段级白名单收口需求(无敏感字段、无枚举类型),
|
||||
/// 故不强制走 `impl_repo!` 宏的 `update_field` 路径——`update_full` 整体替换足够。
|
||||
pub struct ProjectModuleRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ProjectModuleRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
/// 插入一条工程记录。`created_at` / `updated_at` 由本方法内部取当前毫秒覆盖。
|
||||
/// 返回插入是否成功(affected > 0)。
|
||||
pub async fn insert(&self, record: ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let now = now_millis_str();
|
||||
let mut rec = record;
|
||||
rec.created_at = now.clone();
|
||||
rec.updated_at = now;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn
|
||||
.execute(
|
||||
"INSERT INTO project_modules \
|
||||
(id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
r.id, r.project_id, r.name, r.path,
|
||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||
r.created_at, r.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 查询工程记录。
|
||||
pub async fn get_by_id(&self, id: &str) -> Result<Option<ProjectModuleRecord>, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM project_modules WHERE id = ?1")
|
||||
.map_err(storage_err)?;
|
||||
let row = stmt
|
||||
.query_row(params![id], project_module_from_row)
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目查询全部工程(命中 idx_project_modules_project,按 sort_order ASC 稳定排序)。
|
||||
pub async fn list_by_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<ProjectModuleRecord>, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \
|
||||
FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id], project_module_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 整体更新工程记录(全可变字段,保留 id 与 created_at,updated_at 内部覆盖)。
|
||||
/// 返回是否命中(affected > 0)。
|
||||
pub async fn update_full(&self, record: &ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let mut rec = record.clone();
|
||||
rec.updated_at = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE project_modules SET \
|
||||
project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \
|
||||
auto_detected = ?6, sort_order = ?7, updated_at = ?8 \
|
||||
WHERE id = ?9",
|
||||
params![
|
||||
r.project_id, r.name, r.path,
|
||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||
r.updated_at, r.id,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 硬删工程(工程不需要软删审计追溯)。
|
||||
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目删除全部工程(删项目时级联清理,硬删)。
|
||||
pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// 引入 OptionalExtension 用于 query_row().optional()(避免每次写完整路径)
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — ProjectModuleRepo CRUD(内存 DB,对标 project_service_repo 测试风格)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
/// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。
|
||||
async fn setup() -> (Database, ProjectModuleRepo, String) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let project_id = "proj-test".to_string();
|
||||
db.conn()
|
||||
.blocking_lock()
|
||||
.execute(
|
||||
"INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \
|
||||
VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')",
|
||||
params![project_id, "Test Project"],
|
||||
)
|
||||
.expect("insert placeholder project");
|
||||
let repo = ProjectModuleRepo::new(&db);
|
||||
(db, repo, project_id)
|
||||
}
|
||||
|
||||
fn mrec(project_id: &str, name: &str, path: &str) -> ProjectModuleRecord {
|
||||
ProjectModuleRecord {
|
||||
id: format!("mod-{name}"),
|
||||
project_id: project_id.to_string(),
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
git_url: None,
|
||||
stack: None,
|
||||
auto_detected: false,
|
||||
sort_order: 0,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_and_get_by_id_reads_back_full_fields() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let mut rec = mrec(&pid, "frontend", "/p/frontend");
|
||||
rec.git_url = Some("https://example.com/f.git".to_string());
|
||||
rec.stack = Some(r#"{"lang":"ts"}"#.to_string());
|
||||
rec.auto_detected = true;
|
||||
rec.sort_order = 2;
|
||||
let ok = repo.insert(rec.clone()).await.expect("insert");
|
||||
assert!(ok);
|
||||
let got = repo.get_by_id(&rec.id).await.expect("get").expect("found");
|
||||
assert_eq!(got.name, "frontend");
|
||||
assert_eq!(got.path, "/p/frontend");
|
||||
assert_eq!(got.git_url.as_deref(), Some("https://example.com/f.git"));
|
||||
assert_eq!(got.stack.as_deref(), Some(r#"{"lang":"ts"}"#));
|
||||
assert!(got.auto_detected);
|
||||
assert_eq!(got.sort_order, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_project_orders_by_sort_order_asc() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let mut a = mrec(&pid, "a", "/p/a");
|
||||
a.sort_order = 5;
|
||||
let mut b = mrec(&pid, "b", "/p/b");
|
||||
b.sort_order = 1;
|
||||
let mut c = mrec(&pid, "c", "/p/c");
|
||||
c.sort_order = 3;
|
||||
repo.insert(a).await.unwrap();
|
||||
repo.insert(b).await.unwrap();
|
||||
repo.insert(c).await.unwrap();
|
||||
let list = repo.list_by_project(&pid).await.expect("list");
|
||||
assert_eq!(list.len(), 3);
|
||||
// sort_order ASC:b(1) → c(3) → a(5)
|
||||
assert_eq!(list[0].name, "b");
|
||||
assert_eq!(list[1].name, "c");
|
||||
assert_eq!(list[2].name, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_changes_fields_and_preserves_created_at() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let rec = mrec(&pid, "frontend", "/p/old");
|
||||
repo.insert(rec.clone()).await.unwrap();
|
||||
let original = repo.get_by_id(&rec.id).await.unwrap().unwrap();
|
||||
// 整体更新:path 变了,sort_order 变了
|
||||
let mut updated = original.clone();
|
||||
updated.path = "/p/new".to_string();
|
||||
updated.sort_order = 9;
|
||||
let hit = repo.update_full(&updated).await.expect("update");
|
||||
assert!(hit);
|
||||
let got = repo.get_by_id(&rec.id).await.unwrap().unwrap();
|
||||
assert_eq!(got.path, "/p/new");
|
||||
assert_eq!(got.sort_order, 9);
|
||||
// created_at 保留不变(update_full 不覆盖 created_at)
|
||||
assert_eq!(got.created_at, original.created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_record() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let rec = mrec(&pid, "frontend", "/p/f");
|
||||
repo.insert(rec.clone()).await.unwrap();
|
||||
let hit = repo.delete(&rec.id).await.expect("delete");
|
||||
assert!(hit);
|
||||
assert!(repo.get_by_id(&rec.id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_by_project_removes_all() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
|
||||
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
|
||||
let hit = repo.delete_by_project(&pid).await.expect("delete_by_project");
|
||||
assert!(hit);
|
||||
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
use df_types::types::ProjectStatus;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{
|
||||
@@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
description: row.get("description")?,
|
||||
status: row.get("status")?,
|
||||
status: {
|
||||
let s: String = row.get("status")?;
|
||||
ProjectStatus::from_db_str(&s).unwrap_or_default()
|
||||
},
|
||||
idea_id: row.get("idea_id")?,
|
||||
path: row.get("path")?,
|
||||
stack: row.get("stack")?,
|
||||
@@ -162,7 +166,7 @@ impl_repo!(
|
||||
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
rec.id, rec.name, rec.description, rec.status, rec.idea_id,
|
||||
rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
||||
rec.path, rec.stack, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
@@ -171,7 +175,7 @@ impl_repo!(
|
||||
conn.execute(
|
||||
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
|
||||
params![
|
||||
rec.name, rec.description, rec.status, rec.idea_id,
|
||||
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
||||
rec.path, rec.stack, rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
@@ -238,8 +242,9 @@ impl ProjectRepo {
|
||||
if let Some(kw) = &q.keyword {
|
||||
let trimmed = kw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
|
||||
let pattern = format!("%{trimmed}%");
|
||||
let escaped = trimmed.replace('%', "\\%").replace('_', "\\_");
|
||||
sql.push_str(" AND (name LIKE ? OR description LIKE ?) ESCAPE '\\'");
|
||||
let pattern = format!("%{escaped}%");
|
||||
params_vec.push(Box::new(pattern.clone()));
|
||||
params_vec.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
use df_types::types::ProjectStatus;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
|
||||
@@ -308,7 +309,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
@@ -328,6 +328,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::TaskRepo;
|
||||
use crate::models::TaskRecord;
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
|
||||
fn trec(id: &str, project_id: &str) -> TaskRecord {
|
||||
@@ -336,7 +337,7 @@ mod tests {
|
||||
project_id: project_id.to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: "todo".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -369,7 +370,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
use df_types::types::TaskStatus;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::TaskRecord;
|
||||
@@ -24,7 +25,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
project_id: row.get("project_id")?,
|
||||
title: row.get("title")?,
|
||||
description: row.get("description")?,
|
||||
status: row.get("status")?,
|
||||
status: {
|
||||
let s: String = row.get("status")?;
|
||||
TaskStatus::from_db_str(&s).unwrap_or_default()
|
||||
},
|
||||
priority: row.get("priority")?,
|
||||
branch_name: row.get("branch_name")?,
|
||||
assignee: row.get("assignee")?,
|
||||
@@ -122,7 +126,7 @@ impl_repo!(
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
@@ -134,7 +138,7 @@ impl_repo!(
|
||||
conn.execute(
|
||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
|
||||
params![
|
||||
rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
@@ -374,10 +378,11 @@ impl TaskRepo {
|
||||
}
|
||||
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
|
||||
let pat = format!("%{escaped}%");
|
||||
let p1 = params_vec.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
@@ -566,20 +571,21 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
|
||||
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
|
||||
trec_full(id, queue, parent_id, "todo")
|
||||
trec_full(id, queue, parent_id, TaskStatus::Todo)
|
||||
}
|
||||
|
||||
/// 全参 fixture(聚合测试需自定义 status 时用)。
|
||||
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord {
|
||||
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: TaskStatus) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: status.to_string(),
|
||||
status,
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -605,7 +611,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -778,13 +784,13 @@ mod tests {
|
||||
async fn set_status_for_aggregation_writes_status() {
|
||||
let repo = setup().await;
|
||||
// 父任务初始 todo(queue=todo, parent_id=None 容器模型)
|
||||
repo.insert(trec_full("parent", "todo", None, "todo"))
|
||||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
|
||||
let ok = repo.set_status_for_aggregation("parent", "in_progress");
|
||||
assert!(ok, "应命中写入");
|
||||
let after = repo.get_by_id("parent").await.unwrap().unwrap();
|
||||
assert_eq!(after.status, "in_progress", "status 应被聚合写入更新");
|
||||
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
|
||||
assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
|
||||
}
|
||||
|
||||
@@ -792,7 +798,7 @@ mod tests {
|
||||
async fn set_status_for_aggregation_skips_soft_deleted() {
|
||||
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
|
||||
let repo = setup().await;
|
||||
repo.insert(trec_full("parent", "todo", None, "todo"))
|
||||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.soft_delete("parent").await.unwrap();
|
||||
|
||||
@@ -43,7 +43,9 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
|
||||
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 34] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -75,6 +77,9 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(29, migrate_v29),
|
||||
(30, migrate_v30),
|
||||
(31, migrate_v31),
|
||||
(32, migrate_v32),
|
||||
(33, migrate_v33),
|
||||
(34, migrate_v34),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -902,6 +907,94 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||
///
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
||||
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
|
||||
)?;
|
||||
tracing::info!("v32: ai_conversations 加 pinned_goals 列");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?;
|
||||
tracing::info!("迁移 v32 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_v33(conn: &Connection) -> Result<()> {
|
||||
// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)
|
||||
let has_col: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM pragma_table_info('ai_conversations') WHERE name = 'pending_approvals'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_col {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN pending_approvals TEXT DEFAULT '{}';"
|
||||
)?;
|
||||
tracing::info!("v33: ai_conversations 加 pending_approvals 列(审批重启恢复)");
|
||||
} else {
|
||||
tracing::info!("v33: pending_approvals 列已存在,跳过");
|
||||
}
|
||||
// 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。
|
||||
// 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。
|
||||
let has_updated_at: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_updated_at {
|
||||
// workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御)
|
||||
// pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。
|
||||
match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") {
|
||||
Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"),
|
||||
Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e),
|
||||
}
|
||||
} else {
|
||||
tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
|
||||
tracing::info!("迁移 v33 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。
|
||||
/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。
|
||||
///
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
fn migrate_v34(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS 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,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?;
|
||||
tracing::info!("迁移 v34 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 数据模型定义 — 与数据库表对应的 Rust 结构体
|
||||
|
||||
use df_ai_core::model::{deserialize_model_configs, ModelConfig};
|
||||
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
@@ -13,7 +14,7 @@ pub struct IdeaRecord {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub status: IdeaStatus,
|
||||
pub priority: i32,
|
||||
pub score: Option<f64>,
|
||||
pub tags: Option<String>, // JSON 数组
|
||||
@@ -53,7 +54,7 @@ pub struct ProjectRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub status: ProjectStatus,
|
||||
pub idea_id: Option<String>,
|
||||
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
|
||||
pub path: Option<String>,
|
||||
@@ -74,7 +75,7 @@ pub struct TaskRecord {
|
||||
pub project_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub status: TaskStatus,
|
||||
pub priority: i32,
|
||||
pub branch_name: Option<String>,
|
||||
pub assignee: Option<String>,
|
||||
@@ -134,14 +135,14 @@ pub struct TaskRecord {
|
||||
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
|
||||
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
|
||||
/// - `remark`:可选备注。
|
||||
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
|
||||
/// 对标设计 D8:task_links 数据量小,检测成本低)。跨项目依赖允许。
|
||||
/// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
|
||||
/// 对标设计 D8:task_links 数据量小,检测成本高)。跨项目依赖允许。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskLinkRecord {
|
||||
pub id: String,
|
||||
pub source_id: String,
|
||||
pub target_id: String,
|
||||
pub link_type: String,
|
||||
pub link_type: LinkType,
|
||||
pub remark: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -265,7 +266,7 @@ pub struct NodeExecutionRecord {
|
||||
pub id: String,
|
||||
pub workflow_id: String,
|
||||
pub node_id: String,
|
||||
pub node_type: String,
|
||||
pub node_type: NodeType,
|
||||
pub status: String,
|
||||
pub input_json: Option<String>,
|
||||
pub output_json: Option<String>,
|
||||
@@ -279,7 +280,9 @@ pub struct NodeExecutionRecord {
|
||||
// ============================================================
|
||||
|
||||
/// AI 提供商配置记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
///
|
||||
/// 自定义 Debug: api_key 脱敏为 `"sk-****"`, model_configs / config 脱敏为 `"***"`。
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AiProviderRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -307,6 +310,27 @@ pub struct AiProviderRecord {
|
||||
pub weight: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AiProviderRecord {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AiProviderRecord")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("provider_type", &self.provider_type)
|
||||
.field("api_key", &"sk-****")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("default_model", &self.default_model)
|
||||
.field("models", &self.models)
|
||||
.field("model_configs", &"***")
|
||||
.field("is_default", &self.is_default)
|
||||
.field("config", &"***")
|
||||
.field("created_at", &self.created_at)
|
||||
.field("updated_at", &self.updated_at)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("weight", &self.weight)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// AiProviderRecord.enabled 的 serde 默认(true)。老库/缺字段 JSON → enabled。
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
@@ -336,6 +360,8 @@ pub struct AiConversationRecord {
|
||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
||||
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -438,3 +464,22 @@ pub struct KnowledgeEventRecord {
|
||||
pub context_json: Option<String>, // JSON: 因 event_type 而异
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectModuleRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub git_url: Option<String>,
|
||||
pub stack: Option<String>, // 技术栈 JSON 字符串
|
||||
pub auto_detected: bool,
|
||||
pub sort_order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
// ---------- fixtures ----------
|
||||
|
||||
@@ -20,7 +21,7 @@ fn project(id: &str) -> ProjectRecord {
|
||||
id: id.to_string(),
|
||||
name: format!("proj-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -35,7 +36,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
|
||||
project_id: project_id.to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "todo".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -268,7 +269,7 @@ async fn update_field_rejects_tasks_status() {
|
||||
|
||||
// 对照:status 未被改写,仍为初始值(task fixture 的初始 status)
|
||||
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
|
||||
assert_ne!(rec.status, "done", "白名单拒绝后 status 不应被改写");
|
||||
assert_ne!(rec.status.as_str(), "done", "白名单拒绝后 status 不应被改写");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -160,6 +160,9 @@ pub enum Augmentation {
|
||||
/// 脱敏后的项目路径(远程 provider 仅 basename / 本地全路径);无目录绑定时为 None。
|
||||
#[serde(rename = "path", default, skip_serializing_if = "Option::is_none")]
|
||||
path: Option<SanitizedPath>,
|
||||
/// Phase 4:项目关联信息(前 N 条任务/灵感/知识)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
extra: Vec<String>,
|
||||
},
|
||||
/// @任务 注入体
|
||||
Task {
|
||||
@@ -174,6 +177,9 @@ pub enum Augmentation {
|
||||
/// 所属项目名(join 展示用);游离任务为 None。
|
||||
#[serde(rename = "project_name", default, skip_serializing_if = "Option::is_none")]
|
||||
project_name: Option<String>,
|
||||
/// Phase 4:任务关联信息(关联任务/所属项目上下文)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
extra: Vec<String>,
|
||||
},
|
||||
/// @灵感 注入体
|
||||
Idea {
|
||||
@@ -185,6 +191,9 @@ pub enum Augmentation {
|
||||
status: IdeaStatus,
|
||||
#[serde(rename = "description")]
|
||||
description: String,
|
||||
/// Phase 4:灵感关联信息(已立项时所属项目)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
extra: Vec<String>,
|
||||
},
|
||||
/// /技能 注入体
|
||||
Skill {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::NodeId;
|
||||
use crate::types::{ExecutionId, NodeId};
|
||||
|
||||
/// 人工审批选择类型(F-260615-01)
|
||||
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
|
||||
@@ -24,7 +24,7 @@ pub enum SelectType {
|
||||
/// 旧调用方仍可只填 decision,HumanNode 下游兼容两者。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HumanApprovalResponse {
|
||||
pub execution_id: String,
|
||||
pub execution_id: ExecutionId,
|
||||
pub node_id: NodeId,
|
||||
pub decision: String,
|
||||
#[serde(default)]
|
||||
@@ -81,7 +81,7 @@ pub enum WorkflowEvent {
|
||||
/// #[serde(default)] 向后兼容: 老事件/老 DB 反序列化(无 execution_id)填空串不炸。
|
||||
WorkflowCompleted {
|
||||
#[serde(default)]
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
total_duration_ms: u64,
|
||||
},
|
||||
/// 工作流执行失败
|
||||
@@ -89,13 +89,13 @@ pub enum WorkflowEvent {
|
||||
/// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。
|
||||
WorkflowFailed {
|
||||
#[serde(default)]
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
error: String,
|
||||
failed_node: NodeId,
|
||||
},
|
||||
/// 人工审批请求
|
||||
HumanApprovalRequest {
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
node_id: NodeId,
|
||||
title: String,
|
||||
description: String,
|
||||
@@ -106,7 +106,7 @@ pub enum WorkflowEvent {
|
||||
},
|
||||
/// 人工审批响应
|
||||
HumanApprovalResponse {
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
node_id: NodeId,
|
||||
decision: String,
|
||||
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
|
||||
|
||||
@@ -26,6 +26,12 @@ pub type PluginId = String;
|
||||
pub type ExecutionId = String;
|
||||
/// 决策 ID
|
||||
pub type DecisionId = String;
|
||||
/// 节点类型(如 ai / human / ai_self_review)
|
||||
pub type NodeType = String;
|
||||
/// 任务链接类型(如 depends_on / blocks / relates_to)
|
||||
pub type LinkType = String;
|
||||
/// 工具调用类型(如 function)
|
||||
pub type ToolCallType = String;
|
||||
|
||||
// ============================================================
|
||||
// 时间工具
|
||||
|
||||
@@ -11,7 +11,7 @@ edition = "2021"
|
||||
# 开启时:节点收集 inputs 前,对带 condition 的入边以 source output 为 context 求值;
|
||||
# 求值 false 则该前驱 input 不收集,且若该节点所有入边(含 condition)均被过滤,
|
||||
# 则跳过该节点执行(保持 Pending),实现条件路由。
|
||||
default = []
|
||||
default = ["conditions-eval"]
|
||||
conditions-eval = []
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -165,9 +165,10 @@ impl Default for Dag {
|
||||
///
|
||||
/// 规则:
|
||||
/// - 两端均为 Object:递归合并(同 key 时节点级覆盖全局级,新增 key 各自保留)。
|
||||
/// - 非两端 Object:节点级直接覆盖全局级(包括 `Null`,符合 JSON 显式空语义)。
|
||||
/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null);
|
||||
/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局)。
|
||||
/// - 非 Object/Null 两端:节点级直接覆盖全局级。
|
||||
/// - 节点级为 `Null` 时跳过(保留全局配置),对应语义「节点未指定该配置」。
|
||||
/// 若调用方希望「显式置空」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局),
|
||||
/// 而非传 JSON null。
|
||||
///
|
||||
/// 用途:DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`,
|
||||
/// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。
|
||||
@@ -187,8 +188,15 @@ pub fn deep_merge(global: &serde_json::Value, node: &serde_json::Value) -> serde
|
||||
}
|
||||
Value::Object(merged)
|
||||
}
|
||||
// 非两端 Object:节点级覆盖全局级(含 Null、标量、数组)
|
||||
_ => node.clone(),
|
||||
// 节点级为 Null → 保留全局配置(不覆盖)
|
||||
(_, Value::Null) => global.clone(),
|
||||
// 显式覆盖剩余变体,防止新变体被 _ 吞没
|
||||
(Value::Number(_), _)
|
||||
| (Value::String(_), _)
|
||||
| (Value::Array(_), _)
|
||||
| (Value::Bool(_), _)
|
||||
| (Value::Null, _)
|
||||
| (Value::Object(_), _) => node.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,11 +234,10 @@ mod deep_merge_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_node_config_treated_as_noop() {
|
||||
// DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global),此处覆盖语义对照:
|
||||
// 若强行把缺失视作 Null,应覆盖(显式空语义),与「缺失」不同(缺失走 if let Some 分支)。
|
||||
fn null_node_config_keeps_global() {
|
||||
// 节点级为 Null → 保留全局(不覆盖),区别于「缺失 key 不调 deep_merge」。
|
||||
let global = json!({"a": 1});
|
||||
assert_eq!(deep_merge(&global, &json!(null)), json!(null));
|
||||
assert_eq!(deep_merge(&global, &json!(null)), global);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -30,8 +30,10 @@ impl EventBus {
|
||||
|
||||
/// 发送事件
|
||||
pub async fn send(&self, event: WorkflowEvent) {
|
||||
// broadcast::send 是同步的,忽略接收者已关闭的错误
|
||||
let _ = self.sender.send(event);
|
||||
// broadcast::send 是同步的;接收者已关闭时记录警告
|
||||
if let Err(e) = self.sender.send(event) {
|
||||
tracing::warn!("EventBus 发送事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅事件
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use df_types::events::WorkflowEvent;
|
||||
use df_types::types::NodeId;
|
||||
use df_types::types::{ExecutionId, NodeId};
|
||||
|
||||
use crate::conditions::ConditionEngine;
|
||||
use crate::dag::Dag;
|
||||
@@ -18,7 +18,7 @@ pub struct DagExecutor {
|
||||
/// 节点状态机
|
||||
state_machine: StateMachine,
|
||||
/// 工作流执行 ID(由调用方传入,下沉到每个 NodeContext)
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
}
|
||||
|
||||
impl DagExecutor {
|
||||
@@ -26,7 +26,7 @@ impl DagExecutor {
|
||||
///
|
||||
/// `execution_id` 为本次工作流执行的唯一标识,会下沉到每个节点的 NodeContext,
|
||||
/// 用于节点内的事件关联、审计追踪等。
|
||||
pub fn new(event_bus: EventBus, execution_id: String) -> Self {
|
||||
pub fn new(event_bus: EventBus, execution_id: ExecutionId) -> Self {
|
||||
Self {
|
||||
event_bus,
|
||||
state_machine: StateMachine::new(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_types::types::NodeId;
|
||||
use df_types::types::{ExecutionId, NodeId};
|
||||
|
||||
use super::eventbus::EventBus;
|
||||
use super::state::StateMachine;
|
||||
@@ -18,7 +18,7 @@ pub struct NodeContext {
|
||||
/// 节点配置参数
|
||||
pub config: serde_json::Value,
|
||||
/// 工作流执行 ID
|
||||
pub execution_id: String,
|
||||
pub execution_id: ExecutionId,
|
||||
/// 事件总线
|
||||
pub event_bus: EventBus,
|
||||
/// 节点状态机(用于检查取消状态)
|
||||
|
||||
@@ -15,6 +15,17 @@ use std::sync::{Arc, Mutex};
|
||||
use df_types::types::{NodeId, NodeStatus};
|
||||
|
||||
/// 节点状态机
|
||||
///
|
||||
/// # ⚠️ 并发安全警告
|
||||
///
|
||||
/// `state_machine` 方法(`get`、`transition`、`set_running`、`set_completed`、
|
||||
/// `set_failed`、`set_cancelled`、`snapshot`、`is_cancelled`)内部持锁
|
||||
/// (`self.states.lock()`),**不得在 `.await` 期间持锁**。
|
||||
/// 若在异步上下文中调用,请确保获取结果后立即释放锁(即不要将锁跨越
|
||||
/// `.await` 点)。当前所有方法均为同步且及时释放,符合此规则。
|
||||
///
|
||||
/// 共享语义:内部用 `Arc<Mutex<HashMap>>`,`clone()` 为浅拷贝(Arc 引用计数 +1),
|
||||
/// 所有 clone 共享同一底层 HashMap。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateMachine {
|
||||
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
| [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 |
|
||||
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
|
||||
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
|
||||
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
|
||||
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | — | 📐 设计 | 消息级溯源 Phase 2:切读切换到 ai_messages 视图 |
|
||||
|
||||
---
|
||||
|
||||
@@ -59,20 +61,29 @@
|
||||
| 文档 | 状态 | 核心内容 |
|
||||
|---|---|---|
|
||||
| [Agent架构说明-2026-06-14.md](./专项设计/Agent架构说明-2026-06-14.md) | 📐 现状盘点 | Agent 引擎单链 ReAct 能力边界(查实的事实,非构想) |
|
||||
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 📐 设计待落地 | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec |
|
||||
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 🗄 归档(被其他机制覆盖) | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec。**核验(2026-06-28):0 行代码,其核心价值(规格与实现一致性检查)已被 L1 求助协议 + ai_self_review gate 覆盖,归档不实施** |
|
||||
| [AiNode自审实施方案-2026-06-16.md](./专项设计/AiNode自审实施方案-2026-06-16.md) | ✅ ②-⑤已落地 | AiNode 自审闸门 + verdict DAG 阻断 |
|
||||
| [secret下沉与provider注入方案-2026-06-16.md](./专项设计/secret下沉与provider注入方案-2026-06-16.md) | ✅ 方案 B 全量落地 | secret 纯密钥下沉 df-storage |
|
||||
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | 📐 设计待实施(P0) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 |
|
||||
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 + 三模式(old_text/replace_lines/anchor)。**仅文本(不支持二进制 encoding,合理设计)** |
|
||||
| [generating状态机加固-2026-06-15.md](./专项设计/generating状态机加固-2026-06-15.md) | 📐 设计 | generating 生命周期状态机(guard 收敛) |
|
||||
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | 📐 设计未实施 | 空 api_key 不覆盖未迁移态明文密钥 |
|
||||
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | 📐 设计(R-PD-3) | ConditionEngine 接线 + 条件边求值 |
|
||||
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | 📐 设计(R-PD-2) | ScriptNode 任意 shell 执行安全边界 |
|
||||
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影 |
|
||||
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | 空 api_key 不覆盖未迁移态明文密钥(provider.rs:104-136 即时迁移补密钥 + 阻断保存) |
|
||||
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor 集成 + 前端边条件编辑入口 |
|
||||
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ScriptNode 命令执行安全边界:白名单/黑名单(env 配置)+ 危险关键词告警 |
|
||||
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影。**核验:查询能力补全已落地(task/project/idea 均 keyword/order_by/limit/offset),性能优化待评审** |
|
||||
| [任务推进链实施路径-2026-06-16.md](./专项设计/任务推进链实施路径-2026-06-16.md) | 📐 规划定稿(D-01~04已决) | advance_task 走 df-nodes Node trait,4 阶段路径 |
|
||||
| [推进链阶段2实施路径-2026-06-16.md](./专项设计/推进链阶段2实施路径-2026-06-16.md) | 📐 设计(F-260616-06) | 工作流联动任务推进:task_id + 完成回调 + DAG 模板 |
|
||||
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) |
|
||||
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 |
|
||||
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 |
|
||||
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ai_messages 表 + V21 全量迁移 + 读写全部切到消息表(旧 JSON 列仅作老库兼容回退) |
|
||||
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 + P2 切读全部完成 |
|
||||
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 🟡 部分落地(基建就位,消费者未接) | pub-sub + request-reply + 流式 reply 统一总线。**核验(2026-06-28):基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入(空转)。阶段 2-6 待真实痛点驱动** |
|
||||
| [三层模型-流程模板与人设体系-2026-06-28.md](./专项设计/三层模型-流程模板与人设体系-2026-06-28.md) | 📐 设计 | 模板→工作流→人设三层架构:流程模板YAML定义、人设AgentPersona数据结构、三层实例化流程 |
|
||||
| [aichat体验与agent能力系统化重构-2026-06-21.md](./专项设计/aichat体验与agent能力系统化重构-2026-06-21.md) | 📐 设计 | AI Chat 体验 + Agent 能力统一重构:消息模型 / 上下文管理 / 工具路由 |
|
||||
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | ✅ 已落地(2026-06-28 核验) | 查询接口能力补全:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE |
|
||||
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | ✅ Phase1 已落地(2026-06-28 核验) | AST 符号解析:read_symbol 工具已注册(tool_registry.rs:1506-1547)+ 基线测试守护 |
|
||||
| [插件机制-设计-2026-06-24.md](./专项设计/插件机制-设计-2026-06-24.md) | 📐 设计 | 插件系统:动态加载 / 生命周期 / 权限沙箱 |
|
||||
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
|
||||
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
|
||||
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
||||
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | 🚧 实施中(Batch 9) | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具 |
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +108,7 @@
|
||||
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
|
||||
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
|
||||
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
|
||||
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验 | AI 跑题改进试验:系统 prompt 加固 / 上下文钳制 / 行为约束 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AST 符号解析 — Code Intelligence 设计
|
||||
|
||||
> 日期:2026-06-24 | 状态:设计深化(2026-06-24:信息密度三态 + 主题压缩),Phase1 待实施
|
||||
> 日期:2026-06-24 | 状态:✅ Phase1 已落地(2026-06-28 核验:read_symbol 工具已注册 tool_registry.rs:1506-1547 + 基线测试守护)
|
||||
> 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
|
||||
|
||||
## Context(为什么)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
|
||||
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
|
||||
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
|
||||
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Patch File 工具设计
|
||||
|
||||
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0
|
||||
> 创建: 2026-06-15 | 状态: ✅ 已落地(2026-06-28 核验) | 优先级: P0
|
||||
> 关联 todo: F-260615-06 [P0]
|
||||
|
||||
---
|
||||
|
||||
393
docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md
Normal file
393
docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# 三层模型:流程模板 → 工作流 → 人设体系
|
||||
|
||||
> 创建: 2026-06-28 | 状态: 设计阶段
|
||||
> 关联: [Agent架构说明-2026-06-14.md](./Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
|
||||
> 关联: [ARCHITECTURE.md](../../../ARCHITECTURE.md)(项目架构总纲,本文为专项展开)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与问题
|
||||
|
||||
DevFlow 的工作流引擎(df-workflow)已具备 DAG 定义、拓扑排序、节点调度、状态流转等核心能力。AI Chat 已具备单链 ReAct、工具调用、审批机制。
|
||||
|
||||
但现有架构缺少两个关键抽象:
|
||||
|
||||
| 缺失 | 导致的问题 |
|
||||
|------|-----------|
|
||||
| **流程模板**(Template) | 工作流定义与具体项目绑定,无法复用标准流程。每个项目需从零搭建 DAG |
|
||||
| **人设**(Persona) | 所有 AINode 使用通用 LLM 调用,无角色分工。编码、审查、测试节点行为无差异 |
|
||||
|
||||
**解决方案**:引入三层模型——**模板层定义蓝图、工作流层执行实例、人设层注入角色**。
|
||||
|
||||
---
|
||||
|
||||
## 二、三层模型总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 模板层 (Template) │
|
||||
│ "应该做什么" — 可复用的阶段蓝图 │
|
||||
│ │
|
||||
│ ├─ 节点 DAG 定义(拓扑 + 类型 + 数据流) │
|
||||
│ ├─ 建议人设标注(persona_hint,实例化时可覆盖) │
|
||||
│ ├─ 质量门禁(condition + on_fail) │
|
||||
│ ├─ 产出物规范(artifacts 声明) │
|
||||
│ └─ 存储:YAML 文件 / DB 模板库 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ 工作流层 (Workflow) │
|
||||
│ "怎么执行" — 模板的运行时实例 │
|
||||
│ │
|
||||
│ ├─ 模板实例化(绑定具体项目参数、分支名、路径) │
|
||||
│ ├─ DAG 执行(拓扑排序 + 并行调度 + 状态流转) │
|
||||
│ ├─ 数据绑定(inputs/outputs 按 ID 映射) │
|
||||
│ ├─ 条件分支(condition 求值 → 动态路由) │
|
||||
│ ├─ 断点续跑(状态快照 + 恢复) │
|
||||
│ └─ 载体:df-workflow(现有,✅ 核心完成) │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ 人设层 (Persona) │
|
||||
│ "谁来做" — Agent 角色卡 │
|
||||
│ │
|
||||
│ ├─ system prompt(角色定位、行为规范) │
|
||||
│ ├─ allowed_tools(该角色可调用的工具集) │
|
||||
│ ├─ output_format(输出约束) │
|
||||
│ ├─ suggested_tier(推荐模型层级) │
|
||||
│ ├─ behavior rules(如"每次输出前先检查...") │
|
||||
│ └─ 注入点:AINode 执行时从 NodeContext 读取 persona_id │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 关键原则
|
||||
|
||||
1. **三层独立演化**:模板添加新节点类型、工作流优化调度算法、人设新增角色——互不阻塞
|
||||
2. **交汇点单一**:三层只在 AINode 执行时交汇——工作流传递 persona_id,Node 端根据 id 载入人设配置
|
||||
3. **模板标注建议而非绑定**:模板只写 `persona_hint: coder`,实例化时可改为 `coder-rust` 或 `coder-py`
|
||||
4. **人设可跨模板复用**:`reviewer` 人设既可用于"功能开发模板"的审查节点,也可用于"Bug 修复模板"的审查节点
|
||||
|
||||
---
|
||||
|
||||
## 三、流程模板(Template Layer)
|
||||
|
||||
### 3.1 数据结构
|
||||
|
||||
```yaml
|
||||
# templates/feature-dev.yaml
|
||||
id: feature-dev # 模板唯一标识
|
||||
name: 功能开发模板 # 显示名称
|
||||
description: 标准功能开发全流程 # 描述
|
||||
template_version: 1 # 模板版本(用于升级检测)
|
||||
tags: ["feature", "standard"] # 分类标签
|
||||
|
||||
nodes:
|
||||
- id: analysis # 节点 ID
|
||||
type: ai # 节点类型(ai / script / human / subflow)
|
||||
persona_hint: analyst # 建议人设(实例化时可覆盖)
|
||||
prompt: "分析需求:{{inputs.requirement}}" # 提示词
|
||||
inputs: # 数据输入映射
|
||||
requirement: "$ctx.requirement" # $ctx = 工作流上下文参数
|
||||
outputs: # 输出声明
|
||||
prd: text
|
||||
config: # 节点级配置(覆盖默认)
|
||||
temperature: 0.3
|
||||
|
||||
- id: design
|
||||
type: ai
|
||||
persona_hint: architect
|
||||
prompt: "基于 PRD 设计架构:{{inputs.analysis.prd}}"
|
||||
inputs:
|
||||
analysis: "$nodes.analysis" # $nodes = 上游节点输出
|
||||
outputs:
|
||||
arch_doc: text
|
||||
|
||||
- id: coding
|
||||
type: ai
|
||||
persona_hint: coder
|
||||
prompt: "实现:{{inputs.design.arch_doc}}"
|
||||
inputs:
|
||||
design: "$nodes.design"
|
||||
outputs:
|
||||
code: text
|
||||
|
||||
- id: review
|
||||
type: ai
|
||||
persona_hint: reviewer
|
||||
prompt: "审查代码:{{inputs.coding.code}}"
|
||||
inputs:
|
||||
coding: "$nodes.coding"
|
||||
|
||||
- id: test
|
||||
type: script
|
||||
prompt: "" # Script 节点用 command
|
||||
command: "cargo test"
|
||||
timeout_secs: 300
|
||||
|
||||
- id: release
|
||||
type: human
|
||||
prompt: "确认发布到生产?"
|
||||
|
||||
edges: # 显式边定义(可选,缺省按 nodes 顺序连接)
|
||||
- from: analysis
|
||||
to: design
|
||||
- from: design
|
||||
to: coding
|
||||
- from: coding
|
||||
to: review
|
||||
- from: review
|
||||
to: test
|
||||
- from: test
|
||||
to: release
|
||||
|
||||
conditions: # 条件分支
|
||||
- node: review
|
||||
if: "output.verdict != 'pass'"
|
||||
goto: coding # 审查不通过,回编码节点
|
||||
|
||||
quality_gates: # 质量门禁
|
||||
- node: review
|
||||
condition: "output.verdict == 'pass'"
|
||||
on_fail: "block" # block / goto / warn
|
||||
- node: test
|
||||
condition: "output.exit_code == 0"
|
||||
on_fail: "goto coding"
|
||||
|
||||
artifacts: # 产出物声明
|
||||
prd: "$nodes.analysis.prd"
|
||||
arch: "$nodes.design.arch_doc"
|
||||
code: "$nodes.coding.code"
|
||||
review_report: "$nodes.review.text"
|
||||
```
|
||||
|
||||
### 3.2 模板实例化流程
|
||||
|
||||
```
|
||||
① 用户选择模板(如"功能开发模板")
|
||||
② 填写实例化参数:
|
||||
├─ project_id: 绑定到哪个项目
|
||||
├─ requirement: 需求描述(注入 $ctx.requirement)
|
||||
├─ persona_overrides: 按节点覆盖人设
|
||||
│ └─ coding → coder-rust(该项目是 Rust 后端)
|
||||
└─ branch: feature/search(绑定 Git 分支)
|
||||
|
||||
③ 实例化引擎执行:
|
||||
├─ 复制 DAG 拓扑
|
||||
├─ 绑定数据映射(替换 $ctx / $nodes 占位符)
|
||||
├─ 应用人设覆盖
|
||||
├─ 创建 WorkflowRun(状态 = pending)
|
||||
└─ 写入 DB(workflow_runs + workflow_nodes 表)
|
||||
|
||||
④ 工作流引擎调度执行
|
||||
```
|
||||
|
||||
### 3.3 内置模板清单
|
||||
|
||||
| 模板 ID | 名称 | 适用场景 | 节点链 |
|
||||
|---------|------|---------|--------|
|
||||
| `feature-dev` | 功能开发 | 新增功能 | 需求分析 → 架构设计 → 编码 → 审查 → 测试 → 发布 |
|
||||
| `bug-fix` | Bug 修复 | 缺陷修复 | 问题复现 → 根因分析 → 修复编码 → 回归测试 → 发布 |
|
||||
| `algorithm-dev` | 算法开发 | 算法类功能 | 需求分析 → 算法设计 → 实现 → 基准测试 → 验证 → 发布 |
|
||||
| `refactor` | 代码重构 | 重构优化 | 代码分析 → 重构计划 → 编码 → 审查 → 回归测试 |
|
||||
|
||||
> 模板为内置预设,用户可自定义模板(复制内置模板修改后存为用户模板)。
|
||||
|
||||
---
|
||||
|
||||
## 四、人设层(Persona Layer)
|
||||
|
||||
### 4.1 数据结构
|
||||
|
||||
```rust
|
||||
/// 智能体人设 — Agent 角色卡
|
||||
pub struct AgentPersona {
|
||||
/// 人设标识(如 "coder-rust"、"reviewer")
|
||||
pub id: PersonaId,
|
||||
/// 人设名称
|
||||
pub name: String,
|
||||
/// 人设描述
|
||||
pub description: String,
|
||||
/// 系统提示词(核心——定义 Agent 的角色、行为规范)
|
||||
pub system_prompt: String,
|
||||
/// 可用工具列表(空 = 继承自父级配置)
|
||||
pub allowed_tools: Vec<ToolName>,
|
||||
/// 推荐模型层级
|
||||
pub suggested_tier: ModelTier,
|
||||
/// 输出格式约束
|
||||
pub output_format: OutputFormat,
|
||||
/// 行为规则
|
||||
pub rules: Vec<BehaviorRule>,
|
||||
/// Few-shot 样例
|
||||
pub examples: Vec<PersonaExample>,
|
||||
}
|
||||
|
||||
/// 行为规则
|
||||
pub struct BehaviorRule {
|
||||
pub rule_type: RuleType, // PreCheck / PostCheck / Constraint
|
||||
pub description: String,
|
||||
pub check_prompt: String, // AI 检查提示
|
||||
}
|
||||
|
||||
/// 输出格式约束
|
||||
pub enum OutputFormat {
|
||||
FreeText,
|
||||
Markdown,
|
||||
Json { schema: Value },
|
||||
Code { language: String },
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 内置人设清单
|
||||
|
||||
| 人设 ID | 名称 | 核心 system_prompt 要点 | 建议工具 |
|
||||
|---------|------|------------------------|---------|
|
||||
| `analyst` | 需求分析师 | 拆解用户故事、识别歧义、输出 PRD | read_file, search_knowledge |
|
||||
| `architect` | 系统架构师 | 模块划分、接口设计、技术选型 | read_file, search_knowledge, write_file |
|
||||
| `coder-rust` | Rust 工程师 | 类型安全、错误处理、性能优先 | read_file, write_file, search_code, list_directory, run_command |
|
||||
| `coder-ts` | TS/前端工程师 | 组件复用、类型定义、响应式 | read_file, write_file, search_code, list_directory, run_command |
|
||||
| `reviewer` | 代码审查员 | 安全漏洞、性能问题、CRITICAL/MAJOR/MINOR | read_file, search_code, git_diff |
|
||||
| `tester` | 测试工程师 | 边界条件、覆盖率、测试隔离 | read_file, write_file, run_command |
|
||||
| `algorithm` | 算法工程师 | 复杂度分析、精度对比、优化策略 | read_file, write_file, run_command, benchmark |
|
||||
| `devops` | DevOps 工程师 | 容器化、CI/CD、监控告警 | read_file, write_file, run_command |
|
||||
|
||||
### 4.3 人设的内部结构(persona.rs 设计)
|
||||
|
||||
```rust
|
||||
// crates/df-ai/src/persona.rs(新建)
|
||||
|
||||
pub struct PersonaRegistry {
|
||||
builtins: HashMap<PersonaId, AgentPersona>,
|
||||
customs: HashMap<PersonaId, AgentPersona>,
|
||||
}
|
||||
|
||||
impl PersonaRegistry {
|
||||
pub fn new() -> Self { /* 载入内置人设 */ }
|
||||
pub fn get(&self, id: &PersonaId) -> Option<&AgentPersona>;
|
||||
pub fn register(&mut self, persona: AgentPersona); // 注册自定义人设
|
||||
pub fn list(&self) -> Vec<&AgentPersona>;
|
||||
pub fn get_system_prompt(&self, id: &PersonaId) -> Option<&str>;
|
||||
pub fn filter_tools(&self, id: &PersonaId, all_tools: &[ToolDef]) -> Vec<ToolDef>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 人设注入时机
|
||||
|
||||
人设在两个入口注入,覆盖不同的使用场景:
|
||||
|
||||
```
|
||||
场景 A: Workflow AINode 执行
|
||||
DAG Executor → AiNode::execute()
|
||||
→ NodeContext 中有 persona_id(来自模板实例化)
|
||||
→ AiNode 调用 PersonaRegistry::get(persona_id)
|
||||
→ 将 persona.system_prompt 附加到 LLM prompt 头部
|
||||
→ 将 persona.allowed_tools 传入工具选择器
|
||||
→ 执行 LLM complete()
|
||||
|
||||
场景 B: AI Chat Agentic Loop
|
||||
run_agentic_loop()
|
||||
→ 根据 intent 识别结果自动选人设
|
||||
→ Code Intent → 自动绑定 coder 人设
|
||||
→ 绑定后: system_prompt = coder.system_prompt
|
||||
→ 绑定后: 工具面板 = coder.allowed_tools
|
||||
→ 执行 ReAct 循环(带人设约束)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、三层在现有代码中的落地映射
|
||||
|
||||
### 5.1 新增与修改文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `crates/df-ai/src/persona.rs` | **新建** | AgentPersona 结构体 + PersonaRegistry + 内置人设 |
|
||||
| `crates/df-ai/src/lib.rs` | 修改 | 导出 `pub mod persona` |
|
||||
| `crates/df-ai/src/coordinator.rs` | 修改 | 从空壳变为人设调度器——给子任务分配人设 |
|
||||
| `crates/df-nodes/src/ai_node.rs` | 修改 | execute() 中读取 `NodeContext` 的 `persona_id`,加载人设配置 |
|
||||
| `crates/df-workflow/src/node.rs` | 修改 | `NodeContext` 新增 `persona_id: Option<PersonaId>` 字段 |
|
||||
| `crates/df-types/src/types.rs` | 修改 | 新增 `PersonaId` 类型 |
|
||||
| `src-tauri/src/commands/ai/agentic.rs` | 修改 | `run_agentic_loop` 根据 intent 自动选人设 |
|
||||
| `src-tauri/src/commands/ai/tool_registry.rs` | 修改 | 工具注册表支持按人设过滤 |
|
||||
|
||||
### 5.2 现有三条路径如何汇合
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 模板(YAML) │
|
||||
│ persona_hint: coder │
|
||||
└──────────┬───────────┘
|
||||
│ 实例化
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 工作流实例 │
|
||||
│ NodeContext { │
|
||||
│ persona_id: "coder"│
|
||||
│ } │
|
||||
└──────────┬───────────┘
|
||||
│ 执行到 AINode
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ AiNode::execute() │
|
||||
│ ├─ 从 ctx.persona_id 查到人设配置 │
|
||||
│ ├─ 拼接 system_prompt → LLM │
|
||||
│ ├─ 限制工具集 → allowed_tools │
|
||||
│ └─ 输出格式约束 → output │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、与现有 Agent 架构的关系
|
||||
|
||||
current [Agent架构说明-2026-06-14.md](./Agent架构说明-2026-06-14.md) 提到的四个"无"中,人设层直接回应了以下问题:
|
||||
|
||||
| 原缺口 | 人设层如何解决 |
|
||||
|--------|---------------|
|
||||
| coordinator 空壳 | 人设调度是 coordinator 的第一个实现步骤:子任务按类型分配人设 |
|
||||
| 单链 ReAct 无角色区分 | 人设注入后,同一 loop 按绑定的人设输出不同风格的响应 |
|
||||
| Agent 能力边界模糊 | 人设的 `allowed_tools` 显式声明能力边界,"能做什么"由人设而非通用配置决定 |
|
||||
|
||||
> 人设层不解决所有缺口(如执行类工具、MCP 外部工具),但它是多 Agent 协作的第一步。
|
||||
|
||||
---
|
||||
|
||||
## 七、Phase 落地建议
|
||||
|
||||
### Phase A:数据结构 + 内置人设(单独推进,不阻塞其他任务)
|
||||
|
||||
```
|
||||
目标: 定义 AgentPersona 结构体 + 5 个内置人设 + PersonaRegistry
|
||||
文件: crates/df-ai/src/persona.rs
|
||||
验证: PersonaRegistry::get("coder") 返回正确的人设配置
|
||||
```
|
||||
|
||||
### Phase B:AINode 接入人设
|
||||
|
||||
```
|
||||
目标: AINode 执行时从 NodeContext 读 persona_id,拼接 system_prompt
|
||||
文件: crates/df-workflow/src/node.rs + crates/df-nodes/src/ai_node.rs
|
||||
验证: 带 persona_id 的 AiNode 输出带有人设风格的文本
|
||||
```
|
||||
|
||||
### Phase C:流程模板系统
|
||||
|
||||
```
|
||||
目标: YAML 模板定义 + 实例化引擎(模板 → 工作流 DAG)
|
||||
文件: df-workflow 新增模板加载逻辑
|
||||
验证: 加载 feature-dev.yaml → 实例化为带 persona_hint 的 DAG
|
||||
```
|
||||
|
||||
### Phase D:AI Chat 接入人设
|
||||
|
||||
```
|
||||
目标: run_agentic_loop 根据 intent 自动选人设,工具面板按人设过滤
|
||||
文件: src-tauri/commands/ai/agentic.rs + tool_registry.rs
|
||||
验证: Code Intent 下只暴露编码相关工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、设计决策
|
||||
|
||||
| 决策 | 选项 | 结论 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 人设定义位置 | 编译期 vs 运行时 | 编译期内置 + 运行时扩展 | 内置人设保证基线质量,扩展性留给插件机制 |
|
||||
| 模板格式 | YAML vs JSON vs Rust DSL | YAML | 人类可读写,适合非开发者定义模板 |
|
||||
| 人设与 model 的关系 | 人设绑定 model vs 分离 | 分离(人设只建议 `suggested_tier`) | 模型选择由调用方决定,人设不越界 |
|
||||
| 模板实例化时机 | 启动时 vs 使用时 | 使用时(lazy instantiation) | 启动时加载数百模板影响冷启动 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# 全局事件数据总线设计
|
||||
|
||||
> 2026-06-21 · 专项设计 · 状态:构想定稿待评审
|
||||
> 2026-06-21 · 专项设计 · 状态:🟡 部分落地(2026-06-28 核验:基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入空转。阶段 2-6 待真实痛点驱动)
|
||||
> 关联:[[cross-end-rust-backend]] 三层跨端 / [[devflow-product-positioning]] ai-working 可扩展 / F-260620-01 跨端小程序
|
||||
|
||||
## 背景与动机
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
|
||||
>
|
||||
> 背景:R-PD-1(全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`,**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。
|
||||
> 状态:📐 **设计完成,未实施** | 创建:2026-06-15 | 来源:全局代码 review 2026-06-15
|
||||
> 状态:✅ **已落地**(2026-06-28 核验:provider.rs:104-136 即时迁移补密钥 + 阻断保存 + secret.rs 启动迁移) | 创建:2026-06-15 | 来源:全局代码 review 2026-06-15
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
|
||||
> 日期:2026-06-15
|
||||
> 状态:设计待核对(推荐方案已定,落地前需用户确认力度)
|
||||
> 状态:✅ 已落地(2026-06-28 核验:ScriptNode 命令执行安全边界 — env 白名单/黑名单 + 危险关键词告警,script_node.rs:40-155)
|
||||
|
||||
---
|
||||
|
||||
|
||||
189
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
189
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
|
||||
|
||||
> 创建: 2026-06-29 | 状态: 实施中(Batch 9)
|
||||
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
DevFlow 的项目(Project)当前只能绑定一个目录(projects.path)。现实中的项目经常包含多个工程(Module):Monorepo 多仓库、微服务多服务、前后端分离等。每个工程是独立的代码仓库(各自的 .git),有自己的 Git 远程地址和技术栈。
|
||||
|
||||
引入工程(Module)概念,使 AI 和用户都能以工程为粒度操作代码仓库。
|
||||
|
||||
---
|
||||
|
||||
## 二、两级结构
|
||||
|
||||
```
|
||||
项目(Project) ← 业务实体("支付平台")
|
||||
├── 工程(Module) ← 代码仓库("后端 API" / "前端 Web")
|
||||
│ ├── 工程目录(path)
|
||||
│ ├── Git 地址(git_url)
|
||||
│ ├── 技术栈(stack)
|
||||
│ └── Git 状态(实时派生,不存表)
|
||||
├── 工程(Module)
|
||||
└── 工程(Module)
|
||||
```
|
||||
|
||||
单仓库项目 = 项目下只有一个工程(退化场景,UI 不显式展示工程层级)。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型
|
||||
|
||||
### 3.1 project_modules 表(V34 迁移)
|
||||
|
||||
```sql
|
||||
CREATE TABLE project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL, -- "后端 API"
|
||||
path TEXT NOT NULL, -- 工程目录(绝对路径)
|
||||
git_url TEXT, -- Git 远程地址(可选)
|
||||
stack TEXT, -- 技术栈 JSON(["rust","tokio","postgresql"])
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE, -- 自动探测创建(重新探测时覆盖,用户编辑后不回写)
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_modules_project ON project_modules(project_id);
|
||||
```
|
||||
|
||||
### 3.2 设计原则
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|---|---|---|
|
||||
| name | 用户/探测 | 工程名称 |
|
||||
| path | 用户 | 工程目录(必填,等于 .git 所在目录) |
|
||||
| git_url | 用户 | Git 远程地址(可选,单机可不填) |
|
||||
| stack | 探测+用户可改 | 技术栈 JSON(探测 Cargo.toml/package.json 填充默认,用户可覆盖) |
|
||||
| auto_detected | 系统 | true=自动探测创建;重新探测时只覆盖 auto_detected=true 的行 |
|
||||
| sort_order | 系统 | 列表排序 |
|
||||
|
||||
**Git 状态实时派生(不存表)**:分支/改动文件/最近提交 → 查询时跑 git 命令返回。
|
||||
|
||||
---
|
||||
|
||||
## 四、Git 状态查询
|
||||
|
||||
### 4.1 get_module_git_status(module_id) 返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"branch": "main",
|
||||
"remote": "origin",
|
||||
"changes": {
|
||||
"modified": 3,
|
||||
"added": 1,
|
||||
"untracked": 2,
|
||||
"total": 6
|
||||
},
|
||||
"files": [
|
||||
{ "path": "src/main.rs", "status": "M" },
|
||||
{ "path": "Cargo.toml", "status": "M" },
|
||||
{ "path": "README.md", "status": "??" }
|
||||
],
|
||||
"last_commit": {
|
||||
"hash": "abc1234",
|
||||
"message": "feat: add login",
|
||||
"author": "lxy",
|
||||
"date": "2026-06-29T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 实现方式
|
||||
|
||||
在工程目录(module.path)执行:
|
||||
- `git branch --show-current` → 当前分支
|
||||
- `git remote get-url origin` → 远程地址(与 module.git_url 互补)
|
||||
- `git status --porcelain` → 改动文件列表(解析为结构化)
|
||||
- `git log -1 --format=...` → 最近提交
|
||||
|
||||
所有 git 命令包 10s timeout。无 .git 目录返回空状态。
|
||||
|
||||
---
|
||||
|
||||
## 五、文件浏览器(IPC + UI)
|
||||
|
||||
### 5.1 get_module_file_tree IPC
|
||||
|
||||
```rust
|
||||
get_module_file_tree(module_id, sub_path?, depth?) → FileTreeResponse
|
||||
```
|
||||
|
||||
返回文件列表 + Git 状态合并(一次查询)。文件树懒加载:默认展开 2 层,点击展开加载子目录。
|
||||
|
||||
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
|
||||
|
||||
### 5.2 UI 结构
|
||||
|
||||
```
|
||||
项目详情页 → 文件 Tab
|
||||
┌──────────────┬──────────────────────────────────┐
|
||||
│ 文件树 │ 文件内容预览 │
|
||||
│ │ │
|
||||
│ 📁 src/ │ 1 use std::...; │
|
||||
│ main.rs M │ 2 fn main() { │
|
||||
│ config.rs │ 3 println!("hello"); │
|
||||
│ 📁 tests/ │ 4 } │
|
||||
│ Cargo.toml A │ │
|
||||
│ │ main.rs · 2.1 KB · 已修改 │
|
||||
└──────────────┴──────────────────────────────────┘
|
||||
```
|
||||
|
||||
Git 状态标记:M(橙色=已修改) / A(绿色=已新增) / ??(灰色=未跟踪)。
|
||||
|
||||
### 5.3 单工程退化
|
||||
|
||||
项目只有 1 个工程时不显工程选择器,直接展示文件浏览器。
|
||||
|
||||
---
|
||||
|
||||
## 六、创建项目适配
|
||||
|
||||
create_project 时自动建一个工程:
|
||||
- name = 项目名
|
||||
- path = 绑定目录
|
||||
- stack = 探测结果(复用现有 detect_stack)
|
||||
- git_url = 探测(git remote get-url origin,失败为 None)
|
||||
- auto_detected = true
|
||||
|
||||
用户后续可添加更多工程(多工程场景)。
|
||||
|
||||
---
|
||||
|
||||
## 七、AI 工具
|
||||
|
||||
### 7.1 只读工具(Batch 9 随数据层一起注册)
|
||||
|
||||
| 工具 | 功能 | 风险 |
|
||||
|---|---|---|
|
||||
| list_project_modules | 列出项目工程列表 | Low |
|
||||
| get_module_git_status | 查询工程 Git 状态 | Low |
|
||||
|
||||
### 7.2 Git 工具(后续批次)
|
||||
|
||||
| 工具 | 功能 | 风险 |
|
||||
|---|---|---|
|
||||
| git_status | 工作区状态(结构化) | Low |
|
||||
| git_diff | 未提交改动详情 | Low |
|
||||
| git_log | 提交历史 | Low |
|
||||
| git_commit | 提交改动 | Medium |
|
||||
| git_branch | 分支管理 | Medium |
|
||||
| git_merge | 合并分支 | High |
|
||||
|
||||
安全边界:禁止 push / force / reset --hard。
|
||||
|
||||
---
|
||||
|
||||
## 八、实施批次
|
||||
|
||||
| 批次 | 内容 |
|
||||
|---|---|
|
||||
| Batch 9 | 工程表 + CRUD IPC + Git 状态查询 + 创建项目适配 + AI 工具注册 |
|
||||
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
|
||||
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
|
||||
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
|
||||
@@ -1,7 +1,7 @@
|
||||
# 条件表达式引擎设计(R-PD-3 / T-260614-11)
|
||||
|
||||
> 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条
|
||||
> 性质:设计文档(供用户核对方案),不含实现
|
||||
> 性质:✅ **已落地**(2026-06-28 核验:ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor feature flag 集成 + 前端边条件编辑入口 + 中英文翻译)
|
||||
> 关联:todo `T-260614-11 条件表达式引擎升级`、R-P2-13(set_skipped/set_waiting 已删)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# F-260621-02 查询能力维度补全方案(任务/项目/灵感)
|
||||
|
||||
> **状态**:方案设计(待办登记),待排期实施
|
||||
> **状态**:✅ **已落地**(2026-06-28 核验:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE)
|
||||
> **日期**:2026-06-21
|
||||
> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全
|
||||
> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 消息拆分存储设计
|
||||
|
||||
> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:📐 设计待实施
|
||||
> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:✅ **已落地**(2026-06-28 核验:ai_messages 表 + V21 全量迁移 + 读写全部切到消息表,旧 JSON 列仅作老库兼容回退)
|
||||
> 关联任务:`消息拆分存储:ai_messages 表 + 全量迁移 + 三阶段渐进切换`(DevFlow 项目)
|
||||
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
|
||||
> 关联设计:[消息级溯源设计-2026-06-19.md](./消息级溯源设计-2026-06-19.md)(本设计的前置依赖 + 消费方)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 消息级溯源设计
|
||||
|
||||
> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:📐 设计待实施
|
||||
> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:✅ **已落地**(2026-06-28 核验:ChatMessage.id + source_ref/audit/idea 四场景全部完成 + P2 切读全部完成)
|
||||
> 关联任务:`消息级溯源:知识库/审计/灵感全链路从对话级升级到消息级`(DevFlow 项目)
|
||||
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
|
||||
> 关联设计:[消息拆分存储设计-2026-06-19.md](./消息拆分存储设计-2026-06-19.md)(本设计的持久化基础)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 规格契约自检机制
|
||||
|
||||
> 创建:2026-06-13 | 阶段:设计定稿,待落地
|
||||
> 创建:2026-06-13 | 阶段:🗄️ 归档不实施(2026-06-28 核验:0 行代码。其核心价值「规格与实现一致性检查」已被 L1 求助协议 + ai_self_review gate 覆盖,过度设计)
|
||||
> 性质:设计说明 + 可执行规格基准。后续 `spec-verifier` agent、`/spec-check` skill、自检 hook 均从本文档推导。
|
||||
|
||||
---
|
||||
|
||||
19
docs/02-架构设计/已编号方案/消息级溯源P2-切读方案-2026-06-28.md
Normal file
19
docs/02-架构设计/已编号方案/消息级溯源P2-切读方案-2026-06-28.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 消息级溯源 P2 切读方案
|
||||
|
||||
> 决策:2026-06-28 — 一次性切读 + 脚本迁移历史数据
|
||||
|
||||
## 现状(2026-06-28 代码核查)
|
||||
|
||||
**P2 切读已完成**。上一阶段「批次 B」已经实现了读写路径切换:
|
||||
|
||||
### ✅ 读路径
|
||||
`ai_conversation_switch` 已优先从 `ai_messages` 表加载(`list_by_conversation`),
|
||||
空时 fallback 旧 `messages` JSON 列。见 `conversation.rs:257-282`。
|
||||
|
||||
### ✅ 写路径
|
||||
`save_conversation` 已走 `AiMessageRepo.replace_conversation`(`DELETE + INSERT` 事务原子),
|
||||
旧 messages JSON 列保留不写作备份。见 `conversation.rs` 中 `save_conversation` 实现。
|
||||
|
||||
### 剩余工作
|
||||
- `ai_conversations.messages` 列标记废弃(暂不删列)
|
||||
- 后续 V22 可选删除该列
|
||||
392
docs/05-代码审查/全量走查报告-2026-06-28.md
Normal file
392
docs/05-代码审查/全量走查报告-2026-06-28.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# 全量走查报告(2026-06-28)
|
||||
|
||||
> 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系
|
||||
> 方法:3 路并行(Rust 后端/Vue 前端/文档体系),主代理综合整理
|
||||
> 原则:dry — 仅审查 + 文档,不改代码
|
||||
|
||||
---
|
||||
|
||||
## 🔴 安全
|
||||
|
||||
### ① relay.rs — 硬编码默认 Token
|
||||
|
||||
**文件**: `crates/df-relay/src/relay.rs:36-37`
|
||||
|
||||
中继服务使用硬编码 `DEFAULT_TOKEN = "devflow-relay-default-token"`,所有未设环境变量的部署均使用同一静态 token。通过 `strings` 即可从二进制提取。
|
||||
|
||||
**建议**:启动时要求必须设置 `DF_RELAY_TOKEN` 环境变量,移除默认值。
|
||||
|
||||
### ② models.rs — `AiProviderRecord` Debug 未脱敏 api_key,日志泄漏风险
|
||||
|
||||
**文件**: `crates/df-storage/src/models.rs:288`
|
||||
|
||||
`AiProviderRecord` 未实现自定义 `Debug` 对 `api_key` 脱敏。若上层代码意外 `{:?}` 打印(如 `tracing::debug!`),明文 API key 会写入日志。`model_configs`(L296)与 `config`(L298)等 JSON 字段同样不安全。
|
||||
|
||||
**建议**:为 `AiProviderRecord` 实现自定义 `Debug`,将 `api_key` 输出为 `"sk-****"`。
|
||||
|
||||
### ③ ScriptNode — 命令无白名单/沙箱
|
||||
|
||||
**文件**: `crates/df-nodes/src/script_node.rs:15-19` + `crates/df-execute/src/shell.rs:92-117`
|
||||
|
||||
ScriptNode 从 config 取 `command` 字符串透传给 shell(`cmd /C` / `sh -c`),无任何命令白名单或参数校验。工作流定义来自 AI 生成或外部导入时,恶意命令可通过 AINode 写入 ScriptNode config 注入执行。
|
||||
|
||||
**建议**:添加危险命令关键词告警(`rm -rf`/`DROP TABLE`/`Format`),可选白名单/环境变量隔离配置。
|
||||
|
||||
### ④ bind_directory — 原始路径入库未规范化 + 无沙箱校验
|
||||
|
||||
**文件**: `crates/df-mcp/src/tools.rs:314-342`
|
||||
|
||||
`bind_directory` 接收用户传入 `path`,`normalize_path` 仅用于去重检测,**原始 `path` 值直接存储**。同一目录可用两种写法绑定两次绕过冲突检测。且未校验规范化后路径在项目沙箱允许范围内。
|
||||
|
||||
**建议**:存储前 `normalize_path()` + 校验在沙箱目录内 + 拒绝含 `..` 的原始路径。
|
||||
|
||||
### ⑤ state.rs — `std::sync::Mutex` 在 async 上下文中持锁风险
|
||||
|
||||
**文件**: `crates/df-workflow/src/state.rs:34-36, 55, 97, 104`
|
||||
|
||||
`StateMachine` 使用 `std::sync::Mutex` 保护 HashMap,在 `get()` 等同步方法中直接 `lock().expect()`。`DagExecutor` 在 `futures::future::join_all` 同层并发时,若某节点持有引用后 `.await` 另一操作间接尝试锁 `states`,会死锁。通篇 4 处 `expect`——某节点 panic 导致锁中毒,连锁 fatal。
|
||||
|
||||
**建议**:改用 `tokio::sync::Mutex`,或在文档中明确标注「state_machine 方法不得在 `.await` 期间持锁」。
|
||||
|
||||
### ⑥ eventbus.rs — 错误静默吞噬
|
||||
|
||||
**文件**: `crates/df-workflow/src/eventbus.rs:16, 32`
|
||||
|
||||
`DEFAULT_CAPACITY: usize = 256`,`EventBus::send` 调用处 `let _` 吞噬所有错误。当并发节点多且事件消费慢时,256 溢出导致最早接收者 Lagged 错误。
|
||||
|
||||
**建议**:`send` 处 `tracing::warn!` 记录 Lagged 错误。
|
||||
|
||||
### ⑦ shell.rs — `probe_pwsh` 同步阻塞 tokio 运行时
|
||||
|
||||
**文件**: `crates/df-execute/src/shell.rs:53-66`
|
||||
|
||||
`probe_pwsh()` 在 `Default::default()` 中调用,`Default` 在 `execute()` 异步函数内调用。在 tokio 异步函数中同步阻塞 `cmd.status()` 违反最佳实践。
|
||||
|
||||
**建议**:惰性异步探测(`OnceLock` 配 `tokio::task::spawn_blocking`)。
|
||||
|
||||
### ⑧ retry.rs — backoff jitter 随机性弱
|
||||
|
||||
**文件**: `crates/df-ai/src/retry.rs:77-81`
|
||||
|
||||
jitter 使用 `SystemTime::now().duration_since().subsec_nanos() % 2000` 映射到 `[-1.0, 1.0)`。纳秒级退化到 1ms 粒度时多个请求可能拿到同一值,重试碰撞风险。
|
||||
|
||||
**建议**:改用 `fastrand`,jitter 范围扩大到 ±50%(当前 ±20%)。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 文档
|
||||
|
||||
### ⑨ AI对话引擎-2026-06-14.md — 工具清单严重过时
|
||||
|
||||
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md §四`
|
||||
|
||||
标题自称"12 个"工具,表格实际列出 **13 行**(自相矛盾);与 `df-ai-AI集成模块-2026-06-12.md` 声称的 **22 个工具**严重对不齐;缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project` 等工具。
|
||||
|
||||
**建议**:全文同步至当前实际 22 个工具的最新清单。
|
||||
|
||||
### ⑩ 02-架构设计/INDEX.md — 漏索引 9 个已有文档
|
||||
|
||||
**文件**: `docs/02-架构设计/INDEX.md`
|
||||
|
||||
| 子目录 | 漏列数 | 典型遗漏 |
|
||||
|-------|-------|---------|
|
||||
| 专项设计 | 7 | `AI原生上下文地图`、`AST符号解析`、`插件机制`、`查询能力补全方案`、`项目知识图谱` 等 |
|
||||
| 已编号方案 | 2 | `F-260622-01-跨端AIChat-Phase3联调设计`、`消息级溯源P2-切读方案`(今天新增) |
|
||||
| 构想审查 | 1 | `跑题改进试验记录` |
|
||||
| 根目录 | 2 | `单对话并行多轮-Phase0落地路线图`、`单对话并行多轮-设计` |
|
||||
|
||||
**建议**:批量补齐,建立「新增文档 → 立即更新 INDEX」的提交习惯。
|
||||
|
||||
### ⑪ Agent架构说明 — 死链
|
||||
|
||||
**文件**: `docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md:4`
|
||||
|
||||
```markdown
|
||||
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)
|
||||
```
|
||||
目标文件在 `构想审查/` 下,当前路径解析到 `专项设计/` 下不存在。
|
||||
|
||||
**建议**:改为 `[任务推进设计](../构想审查/任务推进构想-2026-06-14.md)`。
|
||||
|
||||
### ⑫ ARCHITECTURE.md §七 + 新设计文档 — 大段逐字重复
|
||||
|
||||
**文件**: `ARCHITECTURE.md:538-665` / `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
|
||||
|
||||
YAML 模板示例(52行)、`AgentPersona` struct 定义(11行)、三层定义表、实例化流程——**大段逐字重复**,违反文档记录规范 SSOT 原则。
|
||||
|
||||
**建议**:`ARCHITECTURE.md §七` 保留概要(三层定义 + 关系图 + 关键原则),YAML 全文和 struct 定义仅在专项设计文档展开,ARCH 以引用链接替代。
|
||||
|
||||
### ⑬ ARCHITECTURE.md 仍保留 ~100 行已移除模块细节
|
||||
|
||||
**文件**: `ARCHITECTURE.md:210-318`(`df-task` / `df-traceability` 章节)
|
||||
|
||||
保留 Task 生命周期图、分支策略、标注系统、需求-功能-测试映射、Decision struct 等已移除模块的详细设计。造成认知负担。
|
||||
|
||||
**建议**:将已移除模块的详细历史移至 `功能决策记录-归档`,ARCHITECTURE.md 仅保留「已移除,见归档」。
|
||||
|
||||
### ⑭ 模块文档更新停滞(最长 16 天)
|
||||
|
||||
| 文档 | 最后更新 | 距今天数 |
|
||||
|-----|---------|---------|
|
||||
| `df-workflow-工作流引擎-2026-06-12.md` | 06-15 | 13 天 |
|
||||
| `df-ai-AI集成模块-2026-06-12.md` | 06-14 | 14 天 |
|
||||
| `df-storage-存储层-2026-06-12.md` | 06-15 | 13 天 |
|
||||
| `想法探索-对抗式评估-2026-06-12.md` | 无更新 | 16 天 |
|
||||
|
||||
**建议**:对上述文档做一次「代码对齐」检查。
|
||||
|
||||
### ⑮ ARCHITECTURE.md 版本状态过时
|
||||
|
||||
**文件**: `ARCHITECTURE.md:3`
|
||||
|
||||
当前 `状态: 设计阶段`。工程处于 **Phase 2 验证阶段**(df-workflow 核心完成、AI ReAct 循环运行中、审批机制就绪),状态标签与实际不符。
|
||||
|
||||
**建议**:改为 `状态: Phase 2 本地优先开发流程验证`(与 `docs/INDEX.md` 一致)。
|
||||
|
||||
### ⑯ 新设计文档与现有文档缺失交叉引用
|
||||
|
||||
**文件**: `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
|
||||
|
||||
应当引用但未引用:`全局事件数据总线`、`df-nodes-节点集合`、`任务推进构想`。
|
||||
|
||||
**建议**:补充上述交叉引用。
|
||||
|
||||
### ⑰ AI对话引擎文档边界归属模糊
|
||||
|
||||
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md`
|
||||
|
||||
位于 `03-模块文档/`,但内容大量描述 `src-tauri/commands/ai/` 下的 Tauri 层代码,而非 `crates/df-ai` crate 内部。按文档记录规范职责矩阵,`03-模块文档/` 应记录各 crate 实现细节。
|
||||
|
||||
**建议**:移入 `02-架构设计/` 适当子目录,或明确标注为跨层交互设计文档。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 代码质量
|
||||
|
||||
### ⑱ dag.rs — `deep_merge` 收到 null 返回 null,覆盖全局配置
|
||||
|
||||
**文件**: `crates/df-workflow/src/dag.rs:174-192`
|
||||
|
||||
`deep_merge(global, null)` 返回 `null`。若某节点定义写了 `"config": null`(JSON 显式 null),该节点 `ctx.config = Value::Null`,原本期望继承全局 config 的节点拿到空配置。
|
||||
|
||||
**建议**:`DagExecutor.run()` 中对 `deep_merge` 结果做 `if result.is_null() { initial_config.clone() }` 兜底。
|
||||
|
||||
### ⑲ dag.rs — `_` 通配 match 掩盖未覆盖变体
|
||||
|
||||
**文件**: `crates/df-workflow/src/dag.rs:191`
|
||||
|
||||
`deep_merge` 的 match 最后分支 `_ => node.clone()` 过于宽泛。若未来新增 `serde_json::Value` 变体,不会被编译器捕获。
|
||||
|
||||
**建议**:显式列出所有剩余变体并 `bail!`,迫使维护者在修改时意识到影响。
|
||||
|
||||
### ⑳ shell.rs — `String::from_utf8_lossy` 编码错误被静默替换
|
||||
|
||||
**文件**: `crates/df-execute/src/shell.rs:155-156`
|
||||
|
||||
shell 输出使用 `String::from_utf8_lossy`,将非 UTF-8 字节替换为 `<60>`。对于中文/日文环境(如 `chcp 65001` 前的 GBK 输出),用户看到乱码替代符,诊断困难。
|
||||
|
||||
**建议**:添加 `tracing::debug!` 记录损失性替换的字节数,或提供 `encoding_rs` 转码选项。
|
||||
|
||||
### ㉑ query LIKE 搜索 — SQL 通配符未转义
|
||||
|
||||
**文件**: `crates/df-storage/src/crud/project_repo.rs:245-246`
|
||||
|
||||
`LIKE ?` 使用 `%{trimmed}%` 拼接,`keyword` 中的 `%`/`_` 会被字面匹配。用户搜索 `100%` 匹配所有行,搜索 `error_404` 匹配 `errorX404`。
|
||||
|
||||
**建议**:对 keyword 中的 `%`/`_` 做 `replace` 转义,加 `ESCAPE '\'` 子句。
|
||||
|
||||
### ㉒ coordinator.rs — 空壳仅用注释声明"勿删",应加编译期守卫
|
||||
|
||||
**文件**: `crates/df-ai/src/coordinator.rs:1-24`
|
||||
|
||||
注释声明「B 路线占位,有意保留空壳,勿删」,但无 `#[deprecated]` 或 `#[doc(hidden)]` 编译期标记。调用方引入 `AgentCoordinator::new().run()` 只能运行时发现返回 `"TODO"`。
|
||||
|
||||
**建议**:加 `#[deprecated(note = "B 路线占位,勿用于生产")]`。
|
||||
|
||||
### ㉓ secret.rs — 密钥迁移 sidecar 文件写入无原子性
|
||||
|
||||
**文件**: `crates/df-storage/src/secret.rs:75-81`
|
||||
|
||||
`record_migration_fail` 每次调用同步读文件 → 改 map → 写文件,迁移时 10+ provider 逐条失败时每条 2 次文件 I/O。无原子写入保障,异常中断可能产生半写文件。
|
||||
|
||||
**建议**:积累内存中的失败计数,迁移循环结束后一次性持久化。
|
||||
|
||||
### ㉔ Intent/planner/plan_hint — 纯函数模块无单测覆盖
|
||||
|
||||
**文件**: `crates/df-ai/src/intent.rs` / `planner.rs` / `plan_hint.rs`
|
||||
|
||||
注释声称「纯函数、零 IO」,均无 `#[cfg(test)]` 覆盖。Phase 1 接入主 loop 前应补充。
|
||||
|
||||
**建议**:为这些纯函数模块补充输入输出确定性匹配测试。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 前端
|
||||
|
||||
### ㉕ Settings.vue — 内联 confirm 弹层重复实现
|
||||
|
||||
**文件**: `src/views/Settings.vue:7-16`
|
||||
|
||||
Settings.vue 内联了完整 confirm 弹层模板,完全复制了 `ConfirmDialog.vue` 的 UI/逻辑,自身维护 `confirmState`/`answerConfirm`。全项目已有 `useConfirm` composable。
|
||||
|
||||
**建议**:用 `useConfirm` + `ConfirmDialog` 替换内联实现。
|
||||
|
||||
### ㉖ 多视图 — 按钮 CSS 重复定义
|
||||
|
||||
**文件**: `Dashboard.vue:126-162` / `Projects.vue:445-458` / `Ideas.vue:421-432` / `Knowledge.vue:449-458`
|
||||
|
||||
四处视图各自定义 scoped 按钮样式。项目已通过 `main.ts` 导入全局 `styles/components.css`。
|
||||
|
||||
**建议**:删除视图内按钮样式,统一走全局 CSS。
|
||||
|
||||
### ㉗ Ideas.vue + Knowledge.vue — tags 解析逻辑重复
|
||||
|
||||
**文件**: `src/views/Ideas.vue:302` / `src/views/Knowledge.vue:321`
|
||||
|
||||
```ts
|
||||
tags.split(',').map(t => t.trim()).filter(Boolean)
|
||||
```
|
||||
|
||||
**建议**:提取为工具函数 `parseTagsInput` 放进 `src/utils/`。
|
||||
|
||||
### ㉘ Ideas.vue — Arco Design `Message` 残留
|
||||
|
||||
**文件**: `src/views/Ideas.vue:144`
|
||||
|
||||
全项目仅在 Ideas.vue 使用了 Arco 的 `Message` 组件(3 处),其余使用自建 toast。混用。
|
||||
|
||||
**建议**:用自建 `showToast` 替换后删除 Arco 依赖。
|
||||
|
||||
### ㉙ stores/ai.ts — 两个 watch 监听同一 `.messages.length`
|
||||
|
||||
**文件**: `src/stores/ai.ts:204, 241`
|
||||
|
||||
两个 `watch(() => state.messages.length, ...)` 分别处理条数上限和 parts 体积上限。每次 length 变化触发两个 watch,第二个还要遍历 messages 求和。
|
||||
|
||||
**建议**:合并为一个 watch。
|
||||
|
||||
### ㉚ AiChat.vue — `initDrainQueueListener` 每次 `useAiStore()` 调用都执行
|
||||
|
||||
**文件**: `src/stores/ai.ts:304`
|
||||
|
||||
```ts
|
||||
void initDrainQueueListener()
|
||||
```
|
||||
每次调用 `useAiStore()` 都执行。需确认内部做了幂等守卫。
|
||||
|
||||
**建议**:确保只初始化一次(如外层 `initOnce` 布尔守卫)。
|
||||
|
||||
### ㉛ AiChat.vue — 空值传播无告警
|
||||
|
||||
**文件**: `src/components/AiChat.vue:238`
|
||||
|
||||
```ts
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
const title = conv?.title || t('aiChat.newConversation')
|
||||
```
|
||||
`find` 返回 `undefined` 时静默回退默认标题。非预期 ID 进入时不告警。
|
||||
|
||||
**建议**:至少 `console.warn` 记录 ID 不在列表中的情况。
|
||||
|
||||
### ㉜ 多视图 — `catch (e: any)` 类型退化
|
||||
|
||||
**文件**: 几乎所有 Vue 视图
|
||||
|
||||
```ts
|
||||
catch (e: any) {
|
||||
state.error = e?.toString() ?? t('xxx.failed')
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:统一改为 `catch (e: unknown)`。
|
||||
|
||||
### ㉝ AiChat.vue — 体积仍偏大
|
||||
|
||||
**文件**: `src/components/AiChat.vue`(模板 128 行 + script ~200+ 行)
|
||||
|
||||
仍承载全局快捷键处理、编辑态管理、队列编辑态管理、Toast 共享状态等。
|
||||
|
||||
**建议**:将键盘快捷键抽离为 `useAiShortcuts` composable。
|
||||
|
||||
### ㉞ Ideas.vue — `searchQuery.trim()` 重复调用
|
||||
|
||||
**文件**: `src/views/Ideas.vue:219-220`
|
||||
|
||||
```ts
|
||||
if (searchQuery.value.trim()) {
|
||||
q.keyword = searchQuery.value.trim()
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:提取 `const trimmed = searchQuery.value.trim()`。
|
||||
|
||||
### ㉟ Projects.vue — 冗余 `as string` 断言
|
||||
|
||||
**文件**: `src/views/Projects.vue:217-218, 222`
|
||||
|
||||
`selected` 已被类型收窄为 `string`,后续 `as string` 多余。
|
||||
|
||||
### ㊱ MCP tools/list — `unwrap_or(Value::Null)` 在数组中混入 null
|
||||
|
||||
**文件**: `crates/df-mcp/src/server.rs:140-142`
|
||||
|
||||
```rust
|
||||
serde_json::to_value(&t.tool).unwrap_or(Value::Null)
|
||||
```
|
||||
若某个 tool schema 序列化失败,tools 数组中混入 `null`。
|
||||
|
||||
**建议**:`.filter_map(|t| serde_json::to_value(&t.tool).ok())`。
|
||||
|
||||
### ㊲ Ideas.vue — `IdeaQuery` 类型未充分对齐
|
||||
|
||||
**文件**: `src/views/Ideas.vue:146, 214-217`
|
||||
|
||||
`order_by` 字段类型为 `string | null`,后端若收窄为联合类型白名单,前端不会报错。
|
||||
|
||||
**建议**:前端 `order_by` 使用 `'score' | 'created_at' | null`。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 亮点
|
||||
|
||||
### ① 密钥管理分层设计(secret.rs)
|
||||
`resolve_provider_secret` 三阶段回落:DB 明文(向前兼容)→ OS keyring → 空字符串。迁移非阻断、幂等、失败计数告警。单测通过 `cfg` gate 仅桌面 OS 跑 keyring 测试。
|
||||
|
||||
### ② SQL 注入防护体系
|
||||
列名白名单(`validate_column_name` + `impl_repo!` 宏统一调用),`build_order_clause` 对排序方向也做白名单校验。软删除/恢复/清空全部参数化 SQL。
|
||||
|
||||
### ③ 前端竞态保护(knowledge.ts)
|
||||
递增序列号丢弃过期响应。`ai.ts` watch 双重守卫(`oldLen > 0 && newLen - oldLen <= 2`)精确区分局部增长和整体替换。
|
||||
|
||||
### ④ `useConfirm` 统一确认弹层
|
||||
从 4 个视图中提取重复 Promise+resolve 模式,正确处理并发覆盖场景(前一个 Promise 先 resolve false)。
|
||||
|
||||
### ⑤ Store 内部分拆 4 子 store
|
||||
`stores/project/` 拆分为 projects/tasks/ideas/workflow,共享 `state.ts` 单例,对外 `useProjectStore()` 不变——「内部拆分,外部零改动」。
|
||||
|
||||
### ⑥ 取消路径 TOCTOU 三层防护
|
||||
`state.rs:86-100` + `executor.rs:180-232`:Ok 后检查、Err 分支检查、阻塞节点轮询。注释详细记录了 TOCTOU 时序窗口。
|
||||
|
||||
### ⑦ MCP 多层防御
|
||||
三级过滤:`visible()` 读模式仅 Low 工具、`tools/list` 剔除 High、`dispatch` 再拦截 read-only + Medium 拒 + High 兜底拒。
|
||||
|
||||
### ⑧ V21 大版本迁移谨慎
|
||||
`BATCH_SIZE=50` 分页、空库跳过、解析失败 skip 而非崩溃、`column_exists` 守卫 ALTER TABLE 防重复执行。
|
||||
|
||||
### ⑨ 子进程清理 + Windows 黑窗抑制
|
||||
`kill_on_drop(true)` 超时后子进程不残留;`creation_flags(0x08000000)` 抑制 Windows 黑窗闪现。
|
||||
|
||||
---
|
||||
|
||||
## 📊 汇总
|
||||
|
||||
| 分类 | 🔴 | 🟡 | ✅ |
|
||||
|------|----|----|----|
|
||||
| 安全 | 8 | — | — |
|
||||
| 文档 | 9 | — | — |
|
||||
| 代码质量 | — | 7 | — |
|
||||
| 前端 | — | 13 | — |
|
||||
| 亮点 | — | — | 9 |
|
||||
| **合计** | **17** | **20** | **9** |
|
||||
|
||||
**总体评价**:核心安全架构扎实(SQL 注入防护、密钥管理、MCP 多层防御、取消 TOCTOU)。主要风险集中在 `state.rs` 同步锁在 async 上下文中的潜在死锁、文档与代码脱节(AI对话引擎工具数过时、INDEX 漏列 9 文档、~100 行已移除模块残留)。前端整体质量高,但需清理少量遗留(Arco 残留、内联 confirm 重复)。
|
||||
368
docs/07-项目管理/todo归档/2026-06-27.md
Normal file
368
docs/07-项目管理/todo归档/2026-06-27.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Todo 归档 2026-06-27
|
||||
|
||||
> 从 todo.md / docs/todo.md 迁移的已完成项
|
||||
> 归档日期: 2026-06-27
|
||||
|
||||
---
|
||||
|
||||
## 灵感模块功能完善 — P0 数据安全/正确性
|
||||
|
||||
- [x] **#1 软删除机制**:ideas 表加 `deleted_at` 列 + 软删除(对标 tasks/projects 的 `delete` + `restore` 全套模板)。当前 `delete_idea` 是硬删除,误删不可恢复。
|
||||
- [x] **#2 白名单收紧**:`settings.rs` ideas 白名单移除 `id` / `created_at`,防止主键和创建时间被篡改。(对标 B-260616-16 tasks 白名单修复)
|
||||
- [x] **#4 priority 值域校验**:`create_idea` / `update_idea` 加 priority 校验(`parse::<i32>() ∈ 0..=3`,非法返 Err)。(对标 B-260615-15 tasks priority 修复)
|
||||
|
||||
## 灵感模块功能完善 — P1 架构/一致性
|
||||
|
||||
- [x] **#5 有损转换修复**:`record_to_idea()` 不再硬编码 `status=Draft` / `related_ids=空`,从 IdeaRecord 读取真实值,评估时不丢失上下文。
|
||||
- [x] **#6 评分关键词拆文件**:将 `scoring.rs` 硬编码关键词拆到独立 const 文件(最小改动)。启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。**不做 DB 表配置化**(过度工程)。
|
||||
- [x] **#7 promote 补偿改软删除**:`promote_idea` 回写失败时的补偿删除从 `purge_with_descendants`(不可恢复)改为 `soft_delete`(软删除,可恢复)。
|
||||
- [x] **#8 关联双向同步**:A 关联 B 时自动在 B 的 `related_ids` 中补入 A,解绑时同步移除,保持关联关系对称。
|
||||
- [x] **#9 创建表单补 tags 输入**:捕捉灵感表单增加 tags 输入(逗号分隔或 chip 组件),`confirmCapture` 传 tags JSON 字符串给后端。
|
||||
|
||||
## 灵感模块功能完善 — P2 前端/UX
|
||||
|
||||
- [x] **#10 创建表单补全 priority/source**:捕捉模态框增加 priority 下拉(低/中/高/紧急)和 source 输入框。
|
||||
- [x] **#14 雷达图正名/替换**:将 i18n key 和 CSS class 中的 "radar" 改为 "score-bar"(或引入真正的雷达图组件如 ECharts radar)。
|
||||
|
||||
## AI 对话目标丢失
|
||||
|
||||
- [x] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goals 内容态与 ConvState 生命周期态正交,不进 enum。
|
||||
- [x] **G5 附 bug** ✅已排查(不改代码):dump DB 确认 seq1(thinking 独白)+seq4(tool_result 内容)均 system+**早期 timestamp**(旧版本遗留脏数据),非当前 compress summary 机制问题(摘要四段式正常)。最新 system(seq0 topic marker)正常。**结论:历史脏数据,数据治理项**(可选清 DB),非当前 bug。anthropic_compat thinking 隔离你在进行中(M)。
|
||||
- [x] **G1 多目标累积**:`pinned_goal: Option<String>` → `pinned_goals: Vec<String>`,每次发消息/编辑/强制发送提取目标追加到列表(去重),支持换目标且不丢失历史目标。`MAX_GOALS=5` 防无限膨胀。
|
||||
|
||||
## 对抗式评估接入 — 后端 IPC 接入
|
||||
|
||||
- [x] **A1 evaluate_idea 命令**:新增 IPC command,按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval`。
|
||||
- [x] **A3 评估结果持久化**:评估结果写回 `ideas` 表(scores + evaluated_by + evaluated_at),避免每次重新评估。
|
||||
- [x] **A4 类型导出**:`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)— 已验证:当前通过 `df_ideas::adversarial::*` 路径直接可用,前端经 `IdeaRecord.ai_analysis` JSON 字符串消费,无需搬运到 `df-types`。
|
||||
|
||||
## 对抗式评估接入 — 前端结果展示
|
||||
|
||||
- [x] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分(LLM / 启发式兜底 / 纯启发式)。
|
||||
- [x] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPC,loading 状态 + 错误提示。
|
||||
- [x] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar)。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 2026-06-21 AI Chat 技术债审查
|
||||
|
||||
### P1 已即时修
|
||||
|
||||
- [x] ✅(主代修·chat.rs:458-461 ai_approve exec_result 包 tokio::time::timeout(60s) 对齐 ai_authorize_dir:658·cargo check EXIT 0) **TD-260621-05a** — **[P1🔴]** ai_approve 缺 60s 执行超时(用户"静默吞消息"bug F-260620 同型复发)。ai_authorize_dir 有超时 ai_approve 漏。卡死→generating 永真→前端 130s 看门狗吞消息。
|
||||
- [x] ✅(主代修·useAiConversations.ts:99 switchConversation activeConversationId 设定后加 `state.streaming = state.generatingConvs.has(id)`·vue-tsc EXIT 0) **TD-260621-06a** — **[P1🔴]** switchConversation 不复位 streaming,真并发切非生成会话残留 stop 按钮→点 stop 发错会话。
|
||||
|
||||
### P1 归路线
|
||||
|
||||
- [x] TD-260621-01 [P1·归 F-09 B 路线 per-conv state] ✅(批2-A 落地·useAiStream `_watchdogTimers` Map<convId,timer> + `_legacyWatchdog` fallback 双路·onStreamTimeout 携 convId 仅清该 conv·2026-06-21) — **watchdog 单计时器全清误杀并发会话**。
|
||||
- [x] TD-260621-02 [P1·归 F-09 B 路线 per-conv state] ✅(批2-A 落地·pendingMaxRounds ref<string|null> 挂起 convId + 守卫比对 activeConversationId·DirAuthDialog visibleDirAuths filter conversationId·2026-06-21) — **pendingDirAuth/pendingMaxRounds 全局单 ref 非 per-conv**。
|
||||
- [x] TD-260621-03 [P1·patch_file 落地债 F-260615] ✅(读改写整体移入 _patch_guard 锁内串行化防 lost update + 删 entry().or_insert() 内存泄漏 + drop 写序列后释放·2026-06-21) — **patch_file 读改写在 FILE_LOCKS 外,lost update**。
|
||||
- [x] TD-260621-04 [P1·patch_file 落地债 F-260615] ✅(read_file 三返回点返 file_hash 闭环 + 抽 compute_file_hash helper read_file/patch_file 共用消格式漂移·2026-06-21) — **expected_hash 契约破裂,指纹防并发形同虚设**。
|
||||
- [x] ✅(2026-06-24·ba811ea chat.rs executed→completed 6处:460幂等读/584 ai_approve写/634判定/668 IPC返回/885 authorize_dir日志/889 authorize_dir写 + df-storage V27 迁移 UPDATE 存量 executed→completed + v27 两测试 unify/idempotent·cargo check EXIT 0 + df-storage 78 passed) **TD-260621-05** [P1·归 SMELL-P1-6] — **审批状态字符串双轨**(已统一)。
|
||||
|
||||
### P2 精选 — 2026-06-24 核验销账
|
||||
|
||||
- [x] ✅已修(commit a7dbd50,finalize:35 `let _ =`→match Err tracing::error,对齐 audit_finalize) — audit_tool_call 吞错
|
||||
- [x] ✅已修(a7dbd50,conversation/chat finalize_pending_placeholders pub(crate)) — switch+delete retain 占位终态化
|
||||
- [x] ✅已修(TD-260621-03 06-21,_patch_guard 串行化 + 删 entry().or_insert()) — patch_file FILE_LOCKS 永不清理
|
||||
- [x] ✅已是正确态(mod.rs:772 #[allow(dead_code)]+预留注释) — PerConvState.created_at
|
||||
- [x] ✅已是正确态(阶段3a 下沉 enum ApprovalKind::Risk{diff}) — PendingApproval.diff
|
||||
|
||||
## 🔍 2026-06-21 查询能力缺口 + run_workflow 缺陷
|
||||
|
||||
- [x] BUG-260621-01 [P1🔴·确定修法] — **run_workflow 空 dag 反序列化失败(missing field nodes),阻塞任务工作流推进** ✅ **2026-06-22 修复**
|
||||
- [x] F-260621-02 [P2] — **任务/项目/灵感列表查询维度补全(下沉后端+关键词搜索+排序分页)** ✅ **2026-06-22 实施**
|
||||
|
||||
## 🔴 2026-06-19 BUG-260619-06 L0 clear 致冷启动审批丢失(已修复)
|
||||
|
||||
- [x] **BUG-260619-06** — L0 clear 致冷启动审批丢失。方案 A 修复:`lib.rs:62` `clear()` → `retain(|_, a| !a.recovered)`。
|
||||
|
||||
## 🔴 2026-06-18 Agentic 最大轮次设置不生效
|
||||
|
||||
- [x] ✅(workflow wuirgcxoy) **B-260618-23 [P1]** — **Agentic 最大轮次设置不生效(设 30 仍按 10 截断)**。修复:sync 上提到 App.vue 根 onMounted。
|
||||
|
||||
## 🔧 2026-06-17 走查·tauri 打包目标收窄 + 状态映射 DRY
|
||||
|
||||
- [x] ✅(主代核验·tauri.conf.json:28 `["nsis"]` 已入库) B-260617-11 [P2] — **tauri.conf.json 打包目标收窄**。
|
||||
|
||||
## 🔧 2026-06-17 走查·DeepSeek reasoning_content 实施审查
|
||||
|
||||
- [x] ✅(2026-06-20 核验) B-260617-16 [P3·可选] — **Partial(MidStream 保文)回填半截 reasoning_content 语义待评**。
|
||||
|
||||
## 🔧 2026-06-17 对话标题不更新
|
||||
|
||||
- [x] ✅(2026-06-18) **B-260617-17 [P2]** — **对话标题不更新(对话很久/刷新后仍"新对话")**。
|
||||
|
||||
## 💡 2026-06-16 新需求(UX 交互优化)
|
||||
|
||||
- [x] ✅(F-15 全阶段完成) **F-260616-15** [P1] — **AI Chat 上下文管理增强:会话分段 + 手动压缩 + 智能裁剪**。
|
||||
|
||||
## P1 — 重要缺陷
|
||||
|
||||
- [x] ✅(workflow w999qdu86) **BUG-260618-11** — **[P0🔴]** `commands.rs:289` ai_approve 幂等路径 `unwrap_or_default` 吞 DB 错误。
|
||||
|
||||
## 🔴 架构坏味道全面扫描 — P0 必须修复
|
||||
|
||||
- [x] ✅(workflow w5siwnipj) **SMELL-P0-1** — **[P0🔴]** `unwrap_or_default` 吞错 **5 高危闭环**。
|
||||
- [x] ✅(workflow w2xkw4ybh) **SMELL-P0-2** — **[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数**。
|
||||
- [x] ✅(2026-06-20 核验) **SMELL-P0-3** — **[P0🔴]** `AiChat.vue` **4026 行 God 组件**。
|
||||
- [x] ✅(workflow wowdnw4ba) **SMELL-P0-4** — **[P0🔴]** `df-execute` crate **零测试**。
|
||||
|
||||
## 🔴 架构坏味道全面扫描 — P1 应该改进
|
||||
|
||||
- [x] ✅(workflow w8774xcev) **SMELL-P1-1** — **[P1🟡]** 生产代码 206 处 `unwrap()` 排查。
|
||||
- [x] ✅(workflow wuirgcxoy no-action) **SMELL-P1-2** — **[P1🟡]** IPC 层 `.map_err(err_str)?` 样板。
|
||||
- [x] ✅(workflow wuirgcxoy no-action) **SMELL-P1-3** — **[P1🟡]** Vue views try-catch-finally invoke 模式。
|
||||
- [x] ✅(workflow w8774xcev) **SMELL-P1-4** — **[P1🟡]** df-nodes 聚合点依赖 5 crate。
|
||||
- [x] ✅(workflow weckqp9mv no-action) **SMELL-P1-5** — **[P1🟡]** IPC 层职责泄漏。
|
||||
- [x] ✅(workflow weckqp9mv no-action) **SMELL-P1-7** — **[P1🟡]** bool 参数陷阱。
|
||||
- [x] ✅(workflow wowdnw4ba) **SMELL-P1-8** — **[P1🟡]** 架构文档过时 6 篇更新。
|
||||
- [x] ✅(agent crud-split 实施) **SMELL-P1-9** — **[P1🟡]** crud.rs 2212 行按表拆分。
|
||||
|
||||
## 🔴 架构坏味道全面扫描 — P2 可选优化
|
||||
|
||||
- [x] ✅(workflow wowdnw4ba no-action) **SMELL-P2-1** — run_workflow_inner 282 行+8 参数。
|
||||
- [x] ✅(workflow wowdnw4ba) **SMELL-P2-2** — 前端全局 ErrorBoundary。
|
||||
- [x] ✅(workflow w8774xcev) **SMELL-P2-3** — HashMap<String,String> → 结构体。
|
||||
- [x] ✅(workflow w5siwnipj) **SMELL-P2-4** — coordinator.rs 空壳标注 roadmap。
|
||||
- [x] ✅(workflow w5siwnipj) **SMELL-P2-5** — #[allow(dead_code)] **7 处全部标注完成**。
|
||||
|
||||
## 🔴 架构坏味道全面扫描 — sweep 派生登记
|
||||
|
||||
- [x] ✅(2026-06-22 核验) **ARC-260618-01-e** — adversarial evaluate_with_llm 一致性。
|
||||
- [x] ✅(2026-06-20) SW-260618-21 [P2] — **formatRelativeZh 重命名 formatRelative**。
|
||||
- [x] ✅(2026-06-20 核验) SW-260618-22 [P2] — **useAiSend resolveLang DRY**。
|
||||
|
||||
## P2 — 不阻断缺陷/增强
|
||||
|
||||
- [x] ✅(2026-06-20 核验) B-260614-05 — **[P2→降级]** 分离窗口(detached)跨窗口状态。
|
||||
|
||||
## 🔧 2026-06-18 6 域并行走查 sweep-fix — 已修 12 处
|
||||
|
||||
- [x] ① context.rs `estimate_message` 累加 parts Text/Image.base64 token
|
||||
- [x] ② anthropic_compat.rs Image 转换 clone→move
|
||||
- [x] ③ scan.rs `is_pure_badge_line` to_lowercase→to_ascii_lowercase
|
||||
- [x] ④ scan.rs `is_monorepo` workspaces null 误判
|
||||
- [x] ⑤ adversarial.rs MockProvider 补 reasoning_content
|
||||
- [x] ⑥ audit.rs `audit_finalize` 拆 unwrap_or_default 吞错
|
||||
- [x] ⑦ commands.rs `ai_conversation_delete` 补非活跃对话 pending_approvals retain
|
||||
- [x] ⑧ audit.rs 抽 PENDING_APPROVAL_PLACEHOLDER 常量
|
||||
- [x] ⑨ ToolCard.vue isToolFailure/commandOutput 下沉 computed
|
||||
- [x] ⑩ AiChat.vue 清 4 处虚拟滚动残留死注释
|
||||
- [x] ⑪ Projects.vue onUnmounted 清 _toastTimer
|
||||
- [x] ⑫ Knowledge.vue refConvTitle parseContext 两次→一次
|
||||
|
||||
## 🔧 2026-06-18 6 域并行走查 sweep-fix — 新 todo 24 项
|
||||
|
||||
### P1
|
||||
|
||||
- [x] ✅(workflow wexu1isx1) SW-260618-01 [P1] — **executor Ok 路径取消节点事件/状态不一致(TOCTOU 残留)**。
|
||||
- [x] ✅(workflow wexu1isx1 + 主代修) SW-260618-02 [P1] — **审批占位 tool_result 在 stop/clear/create/delete 清 pending_approvals 时未替换终态文本**。
|
||||
|
||||
### P2
|
||||
|
||||
- [x] ✅(评估暂缓) SW-260618-03 [P2] — **Anthropic 图片 url 模式静默 400**。
|
||||
- [x] ✅(workflow wexu1isx1) SW-260618-04 [P2] — **knowledge_events 通用 query 硬编码 ORDER BY created_at 崩溃**。
|
||||
- [x] ✅(评估暂缓) SW-260618-05 [P2] — **pending 占位靠内容字符串匹配**。
|
||||
- [x] ✅(主代修·SW-06) SW-260618-06 [P2] — **ToolCard 双 watch(props.tc.status) 合并**。
|
||||
- [x] ✅(主代修·SW-07) SW-260618-07 [P2] — **AiChat 双 watch(currentText) 合并**。
|
||||
- [x] ✅(主代修·SW-08) SW-260618-08 [P2] — **AuditLog.vue 全硬编码中文未 i18n**。
|
||||
- [x] ✅(主代修·SW-09) SW-260618-09 [P2] — **AiNode/AiSelfReviewNode provider 解析+构建 DRY 重复**。
|
||||
- [x] ✅(主代修·SW-10) SW-260618-10 [P2] — **reqwest Client 构建重复(OpenAI/Anthropic Provider::new)**。
|
||||
|
||||
### P3
|
||||
|
||||
- [x] ✅(评估暂缓) SW-260618-11 [P3] — **OpenAI 图片构造防御缺失**。
|
||||
- [x] ✅(评估确认) SW-260618-12 [P3] — **ai_conversations 白名单 pinned 与 archived 不对称**。
|
||||
- [x] ✅(评估暂缓) SW-260618-13 [P3] — **find_path_conflict 跨层规范化靠文档约定**。
|
||||
- [x] ✅(评估不做) SW-260618-14 [P3] — **keyring failcount sidecar 用 current_dir 跨启动不稳**。
|
||||
- [x] ✅(主代修·SW-16) SW-260618-16 [P3] — **F-05 去重审计 status 固定 completed 不透传 rejected/failed**。
|
||||
- [x] ✅(评估不做) SW-260618-17 [P3] — **ToolCardList 4 Set 跨会话不重置累积**。
|
||||
- [x] ✅(主代修·SW-19) SW-260618-19 [P3] — **combineAndTruncateLines 与 cmdOutput 合并逻辑冗余**。
|
||||
- [x] ✅(主代修·SW-20) SW-260618-20 [P3] — **Ideas 本地 parseTags 与 store 版 DRY 重复**。
|
||||
- [x] ✅(评估暂缓) SW-260618-22 [P3] — **Knowledge getCategoryCount+parseTags 模板重复求值**。
|
||||
|
||||
## 🔧 2026-06-18 模型能力维度
|
||||
|
||||
- [x] ✅(workflow wexu1isx1) **B-260618-03 [P1] 后端路由解耦 cost_tier/intelligence**。
|
||||
- [x] ✅(workflow wexu1isx1) **B-260618-04 [P2] model_probe 去瞎填 + 预设表机制存废**。
|
||||
- [x] ✅(主代修·UX-04) **UX-260618-04 [P2] 前端删 cost/intel 标签**。
|
||||
- [x] ✅(workflow wexu1isx1 + 主代修前端) **B-260618-05 [P3] CostTier::Free 死档 + 枚举清理**。
|
||||
|
||||
## 🔧 2026-06-18 AI Chat markdown 表格布局破坏
|
||||
|
||||
- [x] ✅(workflow w5siwnipj) **B-260618-06 [P1]** — **markdown 表格 display:block 破坏布局**。
|
||||
|
||||
## 🔧 2026-06-18 aichat 工具结果渲染核对
|
||||
|
||||
- [x] ✅(workflow w2drz3ppo) UX-260618-05 [P0] — **run_workflow 结果丢 execution_id**。
|
||||
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-06 [P1] — **patch_file 丢 diff**。
|
||||
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-07 [P1] — **delete_file 软删丢 backup_path**。
|
||||
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-08 [P1] — **read_file 丢截断提示**。
|
||||
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-09 [P1] — **advance_task body 裸 JSON**。
|
||||
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-10 [P1] — **list_trash 缺 header 摘要**。
|
||||
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-11 [P2] — **list_directory 丢 truncated**。
|
||||
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-12 [P2] — **list_* body 全裸 JSON + 各工具零散字段丢失**。
|
||||
|
||||
## 🔧 2026-06-18 列表摘要 Markdown 语法字符泄露
|
||||
|
||||
- [x] ✅(主代串行) **UX-260618-13** [P2] — **列表摘要 Markdown 语法字符泄露**。
|
||||
|
||||
## 🔧 2026-06-18 用户实测·aichat bug 反馈
|
||||
|
||||
- [x] ✅(workflow wwchro468) **UX-260618-14 [P1]** — **advance_task 审批卡/结果渲染缺任务名 + 显 UUID/空白**。
|
||||
- [x] ✅(agent ux15-batch1 方案A) **UX-260618-15 [P1] 第一批(方案A根治N+1)** — **流式失败重试每轮独立气泡**。
|
||||
- [x] ✅(主代串行) **UX-260618-16 [P1🔴]** — **`time.ts:44` formatDate 漏 `.value` 致 en locale i18n 失效**。
|
||||
- [x] ✅(2026-06-20 核验) **UX-260618-17 [P1🟡]** — **ProjectDetail.handleApprovalMulti 漏 submitting 复位(防双击破口)**。
|
||||
|
||||
## 🔧 2026-06-19 文件拆分升级 — P0
|
||||
|
||||
- [x] ✅(2026-06-20 核验已拆分) **REFACTOR-260619-01 [P0]** — **commands.rs(ai,1923 行)拆 5 模块**。
|
||||
- [x] ✅(2026-06-22 核验) **REFACTOR-260619-02** — anthropic_compat.rs 拆模块。
|
||||
- [x] ✅(2026-06-20 核验已拆分) **REFACTOR-260619-03 [P0]** — **audit.rs(959 行)拆 5 模块**。
|
||||
|
||||
## 🔧 2026-06-19 文件拆分升级 — P1
|
||||
|
||||
- [x] ✅(2026-06-20 核验已拆分·agentic.rs 已删除) **REFACTOR-260619-05 [P1]** — **agentic.rs(1231 行)抽 agentic_runtime.rs + agentic_stream.rs**。
|
||||
- [x] ✅(2026-06-20 核验·已拆) **REFACTOR-260619-06 [P1]** — **ai_node.rs(1107 行)拆 3 模块**。
|
||||
- [x] ✅(2026-06-20 核验已拆分) **REFACTOR-260619-07 [P1]** — **(已有 SMELL-P0-3)AiChat.vue(4075)拆 ConversationSidebar/ChatHeader/MessageList/ChatInput**。
|
||||
- [x] ✅(2026-06-22 核验) **REFACTOR-260619-08** — tool_registry.rs 按功能分组注册函数拆。
|
||||
- [x] ✅(2026-06-22) **REFACTOR-260619-10** — scan.rs 拆 4 模块。
|
||||
- [x] ✅(2026-06-20 核验) **(已有 SMELL-P1-9)crud.rs(2212)按表拆**。
|
||||
|
||||
## 🔧 2026-06-19 命令行黑窗修复 + GLM 1214 数据调查
|
||||
|
||||
- [x] ✅(2026-06-19) **B-260619-01 [P1]** — **执行命令行弹黑色窗口闪烁**。
|
||||
- [x] ✅(2026-06-22) **B-260619-02** — GLM 1214 tool_result 过大截断。
|
||||
- [x] ✅(2026-06-20) **CR-260620-04 [P0]** — **1214/400 messages 非法·配对错根因(compress 绕过 sanitize)+ 停用模型路由穿透**。
|
||||
|
||||
## 💡 2026-06-19 新需求(任务关联灵感)
|
||||
|
||||
- [x] ✅(2026-06-20 核验已落地) **F-260619-01 [P2]** — **任务关联灵感:TaskRecord 新增 idea_id 字段**。
|
||||
|
||||
## 💡 2026-06-19 新需求(MCP Server)
|
||||
|
||||
- [x] ✅(2026-06-20 核验已落地) **F-260619-02 [P2]** — **DevFlow MCP Server:对外暴露任务/项目/灵感管理能力**。
|
||||
|
||||
## 💡 2026-06-19 新需求(AI 工具文件访问动态权限模型)
|
||||
|
||||
- [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A-D 全完成)。
|
||||
|
||||
## 💡 灵感模块升级
|
||||
|
||||
- [x] ✅ 批1 可信度+体验
|
||||
- [x] ✅ 批2 评估历史全栈
|
||||
- [x] ✅ 批3 IdeasPanel 统计看板
|
||||
- [x] ✅ 批3a 晋升携带
|
||||
- [x] ✅ 批3b 关联关系 schema
|
||||
- [x] ✅ 批3c 关联关系前端
|
||||
- [x] ✅ P1 信号词否定前缀
|
||||
- [x] ✅ 连带修:`augmentation/resolvers.rs` 缺 `ResolverRegistry` import
|
||||
|
||||
## ⚠️ 灵感升级-遗留缺陷
|
||||
|
||||
- [x] ✅(2026-06-22 核验) **related_ids 白名单** — 实际已落地。
|
||||
|
||||
## ⚠️ 预存债 — ToolResult 类型缺字段
|
||||
|
||||
- [x] ✅(2026-06-22 核验) **TD-260621-07 [P2/预存债]** — **ToolResult 类型缺 output_mode/files/counts/matches/total_files 字段**。
|
||||
- [x] ✅(workflow wf 批1) **TD-260621-GUARD** — **[P0·用户实测卡死根因]** generating 状态机前端落地。
|
||||
|
||||
## F-260620-01 跨端 AI Chat — Phase3 联调
|
||||
|
||||
- [x] ✅(2026-06-22) F-260622-01-阶段1
|
||||
- [x] ✅(2026-06-22) F-260622-01-阶段2
|
||||
- [x] ✅(2026-06-22) F-260622-01-阶段3
|
||||
|
||||
## AI Chat 跑题/抓不住重点改进
|
||||
|
||||
- [x] ✅ P0-P2 全完成
|
||||
|
||||
## 单对话内并行多轮推理
|
||||
|
||||
- [x] ✅ Phase1 已落
|
||||
|
||||
## 🟡 F-09 决策e 前端 newConversation 未跟进
|
||||
|
||||
- [x] ✅ 已实施(CR-260620-02 审查 PASS)
|
||||
|
||||
## 🟡 MED-1 bind/create/update_project AI 工具路径审批执行后 reload 未兑现
|
||||
|
||||
- [x] ✅ 已实施(chat.rs ai_approve)
|
||||
|
||||
## ⚪ LOW F-09 stale 注释 + 死 i18n key 清理
|
||||
|
||||
- [x] ✅ 已实施
|
||||
|
||||
## 🔍 2026-06-22 miniapp 功能走查 — 已修项
|
||||
|
||||
- [x] P1-B-260622 [P1🔴·首屏连不上·✅2026-06-23核验已修]
|
||||
- [x] P1-C-260622 [P1🔴·体验阻断·✅2026-06-23 核验已修]
|
||||
- [x] P1-E-260622 [P1🟡·多轮场景·✅2026-06-23]
|
||||
- [x] P2-B-260622 [P2·quickfix·✅2026-06-23核验已修]
|
||||
- [x] P2-C-260622 [P2·quickfix·✅2026-06-23]
|
||||
- [x] P2-A-260622 [P2·defer·✅2026-06-23]
|
||||
- [x] P3-A-260622 [P3·defer·✅2026-06-23]
|
||||
- [x] P1-F-260622 [P1🟡·✅2026-06-23]
|
||||
- [x] P1-G-260622 [P1🟡·体验·✅2026-06-23]
|
||||
- [x] P2-D-260622 [P2·健壮·✅2026-06-23核验已修]
|
||||
- [x] P2-E-260622 [P2·健壮·✅2026-06-23]
|
||||
- [x] P3-C-260622 [P3·清理·✅2026-06-23 核验已清]
|
||||
- [x] P3-D-260622 [P3·体验·✅2026-06-23]
|
||||
|
||||
## ✅ 2026-06-23 miniapp 全功能审查加固
|
||||
|
||||
- [x] F1 AiConvStateChanged idle/error 终态漏 clearWatchdog → 补 clearWatchdog
|
||||
- [x] F2 regenerate() 无 generating 守卫 → 加守卫 + toast
|
||||
- [x] F3 flushCurrentText 启发式回填错位 → 引入 currentAssistantMsgId 按 id 精确回填
|
||||
- [x] F9 stop() 无终态兜底 + chat 页无停止按钮
|
||||
- [x] F21 连接状态裸枚举字符串 → 中文文案
|
||||
- [x] switch 加 default warn / ws.ts 二进制帧 warn / scheduleReconnect 加 MAX_RECONNECT_ATTEMPTS=20
|
||||
- [x] 删 pages/test 死页
|
||||
|
||||
## ✅ 2026-06-23 miniapp 用户需求批
|
||||
|
||||
- [x] req1 会话重命名
|
||||
- [x] req2 断网不丢消息
|
||||
- [x] req3 审批重连恢复
|
||||
|
||||
## ✅ 2026-06-23 miniapp 收尾批
|
||||
|
||||
- [x] P1-E MaxRounds 继续/停止
|
||||
- [x] P1-F 重发 + 复制
|
||||
- [x] P3-D token 用量
|
||||
- [x] P2-C 注释陈旧
|
||||
- [x] P2-A messages 内存上限
|
||||
|
||||
## ✅ 2026-06-23 miniapp polish 批
|
||||
|
||||
- [x] P2-E 审批失败回填
|
||||
- [x] P1-G Markdown 渲染
|
||||
- [x] P3-A textarea 多行
|
||||
|
||||
## ✅ 2026-06-24 aichat 可靠性修复
|
||||
|
||||
- [x] BUG-260624-01 消息重叠/堆叠根治
|
||||
- [x] BUG-260624-02 授权弹窗卡死根治
|
||||
- [x] BUG-260624-03 工具执行心跳误报根治
|
||||
- [x] BUG-260624-04 搜索工具误授权根治
|
||||
- [x] BUG-260624-05 压缩后每轮停止根治
|
||||
- [x] BUG-260625-01 grep 传单文件路径报「目录名称无效」根治
|
||||
- [x] BUG-260625-02 read_file search 模式显示「0 行」根治
|
||||
|
||||
## ✅ 2026-06-25 generating 状态机双轨收口 + 自动压缩路径残留修复
|
||||
|
||||
- [x] ✅(2026-06-25) Task#1 自动压缩成功路径残留
|
||||
- [x] ✅(2026-06-25) Task#2 双轨状态机收口
|
||||
|
||||
## 🔍 2026-06-26 灵感模块诊断待办(已完成)
|
||||
|
||||
- [x] **IDEA-FIX-01 [P0🔴]** — ideas 表无软删除
|
||||
- [x] **IDEA-FIX-02 [P0🔴]** — ideas 白名单含 id/created_at
|
||||
- [x] **IDEA-FIX-03 [P0🔴]** — priority 无值域校验
|
||||
- [x] **IDEA-FIX-04 [P1🟡]** — record_to_idea 有损转换
|
||||
- [x] **IDEA-FIX-05 [P1🟡]** — 评分关键词硬编码
|
||||
- [x] **IDEA-FIX-06 [P1🟡]** — promote 补偿用 purge(不可恢复)
|
||||
- [x] **IDEA-FIX-07 [P1🟡]** — 关联单向
|
||||
- [x] **IDEA-FIX-08 [P1🟡]** — 创建表单无 tags 输入
|
||||
- [x] **IDEA-FIX-09 [P2🟠]** — 创建表单缺 priority/source
|
||||
- [x] **IDEA-FIX-11 [P2🟠]** — 假雷达图
|
||||
@@ -351,3 +351,95 @@
|
||||
- **决策点**:临时本地构建 vs 有意入库?
|
||||
- **选项**:A 临时本地构建(提交前 revert 此行) / B 有意入库(改按平台条件配置而非硬编码单 target)
|
||||
- **状态**:✅ 已决(2026-06-18) — **决策:A 临时本地构建,要求高速**。`["nsis"]` 已是 Windows 单一最快安装包目标。提交前须 revert 为 `"all"`。若进一步提速可用 `["app"]`(裸 .exe 无安装包)或 `tauri build --no-bundle`。
|
||||
|
||||
### 当前队列已决项迁入(2026-06-26 ~ 2026-06-27)
|
||||
|
||||
> 来源:待决策.md ✅ 2026-06-26 任务图谱推进待决策 + 🟡 各节已决项批量迁入。
|
||||
|
||||
#### ✅ DEC-260626-01 父② 知识图谱 Phase 1 多工程合并
|
||||
- **决策**:**b** — 知识图谱独立 Phase 1(**V29**,V28 已被灵感软删除占用)。多工程代码零行(grep 全空),合并无理由,多工程后续 V30+。
|
||||
- **关联**:todo 父②
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-02 ⑤.2 #7 promote 补偿删除方式
|
||||
- **决策**:**b** — 保留 purge。补偿是内部回滚建错的 project(非用户删除),purge 干净不污染回收站。
|
||||
- **关联**:todo ⑤.2 #7(标"保留 purge 不改")
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-03 ⑤.2 #6 评分关键词配置形式
|
||||
- **决策**:**c** — 先拆 const 到独立文件(中/英两套,最小改动)。原推荐 a 修正:启发式是过渡(scoring.rs TODO 接 LLM 语义评分),接 LLM 后按需升 a(JSON 文件)。
|
||||
- **关联**:todo ⑤.2 #6
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-04 ⑤.2 #9/#10 表单补全交互
|
||||
- **决策**:**a** — tags 逗号分隔输入框(对标 Knowledge.vue 先例)+ priority 下拉 + source 输入。chip 组件留 UX 升级。
|
||||
- **关联**:todo ⑤.2 #9/#10
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-05 BUG-260620-05 层2 授权政策
|
||||
- **决策**:**a** — 维持方案①(白名单+弹窗三档)。层1(工程内免授权,F-260619-03 方案①)已解痛点,层2 黑名单制安全风险 + 分发冲突。
|
||||
- **关联**:todo BUG-260620-05 层2(标"维持①不做")
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-06 父③ ToolCard 跨轮合并 + 审批状态机排期
|
||||
- **决策**:**b** — 等 G1 实测 + 父② 后再做。审批是高危路径,AiChat.vue 仍在频繁改动,择稳定窗口专项。
|
||||
- **关联**:todo 父③(排父②后)
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-07 父④ F-09 per-conv 真多会话排期
|
||||
- **决策**:**a** — 父② Phase 1 后启动。F-09 是 Phase 4 前置,大改不与父② 并行(回归交叉)。
|
||||
- **关联**:todo 父④(排父②后)
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ 专题-1 workspace_root 分发适配
|
||||
- **决策**:**① 先行**(去默认白名单,dev 自用 projects.path 自动授权够),**分发前定②**(去相对锚)。
|
||||
- **状态**:✅ 已决(2026-06-26,① 立即/② 分发前)
|
||||
|
||||
#### ✅ 专题-2 BUG-260623-03 审批 pending 超时兜底
|
||||
- **决策**:**c 无超时**(不做超时兜底)。用户:等待审批就是等待,一直等待,超时概念多余。审批是用户主动行为,不审批自然挂起等待,无需超时强制 reject/倒计时。
|
||||
- **状态**:✅ 已决(2026-06-26,不做超时,关闭 BUG-260623-03)
|
||||
|
||||
#### ✅ 专题-3 F-09 streaming/currentText per-conv
|
||||
- **决策**:**暂缓**(随 B 路线多会话并发同批做,即 DEC-07 父④)。
|
||||
- **状态**:✅ 已决(2026-06-26,归父④)
|
||||
|
||||
#### ✅ S-260623-01 「自托管」含义澄清
|
||||
- **决策**:**自用阶段定案(2026-06-23)** —— 用户决策:小程序当前自用(开发阶段),分发是产品终态但留后续(过渡期靠改 device_id 实现多机)。故「自托管」现阶段 = 已实现的自主执行(autoExecuteMode all 含高危),无另义。分发阶段再做配置层(MINIDEC-01)+ 配对(MINIDEC-02)。
|
||||
- **关联**:task105 / memory(设置自主执行已实现)
|
||||
- **状态**:✅ 自用阶段定案(2026-06-23)
|
||||
|
||||
#### ✅ B-260618-03 路由解耦 cost_tier/intelligence(用户已全局决策去掉,工程大需专项)
|
||||
- **决策**:用户已全局决策去掉。自主推进(2026-06-18 workflow wexu1isx1,主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 109 passed + vue-tsc EXIT 0)。title/compress 纯 weight 选模型退化点:用户核对 Settings weight 配置(同 weight 并列 max_by_key 返回最后一个)。
|
||||
- **关联**:todo B-260618-03/04/05 · UX-260618-04 前端 cost/intel 标签(依赖本项)
|
||||
- **状态**:✅ 已实施(2026-06-18)
|
||||
|
||||
#### ✅ SW-260618-21 死代码预留功能清理批(清理 vs 保留)
|
||||
- **决策**:**b 保留+标 allow**(预留设计意图明确·清理失去未来扩展点 ROI 低;标 allow 消 warning 即可·零波及)。risk_level 删 + 预留/diff 标 allow·cargo 0 warning。
|
||||
- **关联**:批次7 transition_status 已删 / CR-22 删 risk_level / CR-23 标 allow 5 处
|
||||
- **状态**:✅ 已实施 b(2026-06-18)
|
||||
|
||||
#### ✅ F-260616-09-B 多会话并发架构 B 阶段实施决策(设计完成 2026-06-19·待拍板)
|
||||
- **决策**:**✅ b-1 采纳(messages per-conv,技术必然)+ c-1 保持 3 + Settings 配置 + e-1 原路径**。主代自主裁决采纳(2026-06-19)。用户授权「自主推进,能多角度确定的方案不等审批」。启动阶段2 批1。
|
||||
- **关联**:todo F-260616-09 / 设计文档 / memory aichat-arch-extensibility
|
||||
- **状态**:✅ 主代自主裁决采纳(2026-06-19)
|
||||
|
||||
#### ✅ F-260619-05 任务可关联灵感(产品粒度/方向决策·todo 已登记)
|
||||
- **决策**:tasks 加 `idea_id REFERENCES ideas(id)`(复用 projects 模式,1对1 起步,单向,后续按需扩展)。**已实施**(2026-06-20 调研确认)。前端任务卡片展示灵感来源(可选增强)待补。
|
||||
- **关联**:todo.md F-260619-05
|
||||
- **状态**:✅ 已实施(2026-06-20)
|
||||
|
||||
#### ✅ MINIDEC-260623-03 WS 重连续流策略(F10·行为差异·需拍板)
|
||||
- **决策**:**已实施 a**(2026-06-23)——用户决策「断网不丢消息 + 从远端拉完整」。useAiChat syncOnConnect:ws 'connected' → 发 load_messages 拉完整历史。cargo check 0 + vue-tsc 0 + build DONE。
|
||||
- **状态**:✅ 已实施(2026-06-23)
|
||||
|
||||
#### ✅ MINIDEC-260623-04 审批双源状态分裂统一(F4/F5/F6/F13·渲染源决策)
|
||||
- **决策**:**已实施 a 单一渲染源**(2026-06-23)——用户决策「审批断网重连恢复卡片状态」。审批卡改从 pendingApprovals 独立面板渲染;remote_bridge 加 sync_pending 路由;useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建;handleEvent 加同 id 去重。cargo 0 + vue-tsc 0 + build DONE。
|
||||
- **状态**:✅ 已实施(2026-06-23)
|
||||
|
||||
#### ✅ MINIDEC-260623-05 会话页管理缺口(删除/重命名·需后端命令)
|
||||
- **决策**:**重命名已实施 / 删除暂缓**(2026-06-23)——用户决策「小程序对齐桌面端能改会话名」。remote_bridge 加 rename_conversation 路由;useAiChat renameConversation 方法;conversations/index.vue 长按会话编辑。删除会话暂不做(桌面端管理)。cargo 0 + vue-tsc 0 + build DONE。
|
||||
- **状态**:✅ 重命名已实施 / 删除暂缓(2026-06-23)
|
||||
|
||||
#### ✅ MINIDEC-260623-07 regenerate 零调用方 + 备份组件漂移(P3 收尾)
|
||||
- **决策**:**随 P1-F 选 a**(2026-06-23)—— 🟡 收尾批给 regenerate 加「重发」UI 入口(对齐桌面端),故保留函数不再删;备份组件(MdView/MentionInput)保留预留注释(绕工具解析 bug,未来拆回)。
|
||||
- **状态**:✅ 随 P1-F 选 a(2026-06-23)
|
||||
|
||||
@@ -1060,3 +1060,11 @@
|
||||
- [P3] **CR-01-G** TaskDetail.vue:218 advance 失败错误走 i18n 分级(en locale 也中文)
|
||||
- [P3] **CR-01-H** task_advance_node.rs:297 测试改名(实际未测 CAS 失败)
|
||||
- [P3] **CR-01-I** i18n `taskDetail.advancing` 冗余 key 处理
|
||||
|
||||
### 当前队列已审项迁入(2026-06-27)
|
||||
|
||||
## 当前队列
|
||||
|
||||
### CR-260624-01 AI 路径授权三档化(once/session/always) — ✅ 已审(PASS·单 agent + 6-agent 对抗深审 0 finding)
|
||||
|
||||
- **范围**:AllowedDirs 加 `once` 层(state.rs:444)+ ai_authorize_dir decision 改 match 四分支 once/session/always(chat.rs:846)+ 执行后 clear_once(chat.rs:924)+ 前端 DirAuthDialog 加"
|
||||
|
||||
896
docs/todo.md
896
docs/todo.md
File diff suppressed because it is too large
Load Diff
421
docs/待决策.md
421
docs/待决策.md
@@ -25,71 +25,6 @@
|
||||
|
||||
## 当前队列
|
||||
|
||||
### ✅ 2026-06-26 任务图谱推进待决策(已按推荐方向全部决策)
|
||||
|
||||
> 来源:用户 2026-06-26 多角度论证(基于代码实况) + 确认「按推荐方向决策」,7 DEC + 3 专题全部拍板。
|
||||
> **关键修正**:DEC-01 V28 已被灵感软删除(`ideas.deleted_at`,commit 005079f)占用 → 知识图谱用 **V29**;DEC-03 推荐由 a 修正为 **c**(启发式是过渡,接 LLM 评分后按需升 a)。
|
||||
|
||||
#### ✅ DEC-260626-01 父② 知识图谱 Phase 1 多工程合并
|
||||
- **决策**:**b** — 知识图谱独立 Phase 1(**V29**,V28 已被灵感软删除占用)。多工程代码零行(grep 全空),合并无理由,多工程后续 V30+。
|
||||
- **关联**:todo 父②
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-02 ⑤.2 #7 promote 补偿删除方式
|
||||
- **决策**:**b** — 保留 purge。补偿是内部回滚建错的 project(非用户删除),purge 干净不污染回收站。
|
||||
- **关联**:todo ⑤.2 #7(标"保留 purge 不改")
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-03 ⑤.2 #6 评分关键词配置形式
|
||||
- **决策**:**c** — 先拆 const 到独立文件(中/英两套,最小改动)。原推荐 a 修正:启发式是过渡(scoring.rs TODO 接 LLM 语义评分),接 LLM 后按需升 a(JSON 文件)。
|
||||
- **关联**:todo ⑤.2 #6
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-04 ⑤.2 #9/#10 表单补全交互
|
||||
- **决策**:**a** — tags 逗号分隔输入框(对标 Knowledge.vue 先例)+ priority 下拉 + source 输入。chip 组件留 UX 升级。
|
||||
- **关联**:todo ⑤.2 #9/#10
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-05 BUG-260620-05 层2 授权政策
|
||||
- **决策**:**a** — 维持方案①(白名单+弹窗三档)。层1(工程内免授权,F-260619-03 方案①)已解痛点,层2 黑名单制安全风险 + 分发冲突。
|
||||
- **关联**:todo BUG-260620-05 层2(标"维持①不做")
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-06 父③ ToolCard 跨轮合并 + 审批状态机排期
|
||||
- **决策**:**b** — 等 G1 实测 + 父② 后再做。审批是高危路径,AiChat.vue 仍在频繁改动,择稳定窗口专项。
|
||||
- **关联**:todo 父③(排父②后)
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-07 父④ F-09 per-conv 真多会话排期
|
||||
- **决策**:**a** — 父② Phase 1 后启动。F-09 是 Phase 4 前置,大改不与父② 并行(回归交叉)。
|
||||
- **关联**:todo 父④(排父②后)
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ 专题-1 workspace_root 分发适配
|
||||
- **决策**:**① 先行**(去默认白名单,dev 自用 projects.path 自动授权够),**分发前定②**(去相对锚)。
|
||||
- **状态**:✅ 已决(2026-06-26,① 立即/② 分发前)
|
||||
|
||||
#### ✅ 专题-2 BUG-260623-03 审批 pending 超时兜底
|
||||
- **决策**:**c 无超时**(不做超时兜底)。用户:等待审批就是等待,一直等待,超时概念多余。审批是用户主动行为,不审批自然挂起等待,无需超时强制 reject/倒计时。
|
||||
- **状态**:✅ 已决(2026-06-26,不做超时,关闭 BUG-260623-03)
|
||||
|
||||
#### ✅ 专题-3 F-09 streaming/currentText per-conv
|
||||
- **决策**:**暂缓**(随 B 路线多会话并发同批做,即 DEC-07 父④)。
|
||||
- **状态**:✅ 已决(2026-06-26,归父④)
|
||||
|
||||
---
|
||||
|
||||
#### T-260614-11 条件表达式引擎升级
|
||||
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。
|
||||
- **决策点**:求值器实现方式?(已暂缓:等工作流真实场景)
|
||||
- **选项**:
|
||||
- a: 手写求值器
|
||||
- b: 复活 set_skipped
|
||||
- c: default false + warn(均认可)
|
||||
- **推荐**:**⏸️ 暂缓**(无消费方,等 BuildNode 落地跑构建→测试→部署真实场景)
|
||||
- **关联**:todo T-260614-11
|
||||
- **状态**:⏸️ 暂缓
|
||||
|
||||
### 🟡 待用户实测/澄清(需用户操作,主会话无法代办)
|
||||
|
||||
#### S-260614-02 审批可见性实测重评
|
||||
@@ -105,153 +40,68 @@
|
||||
- **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。
|
||||
- **状态**:🟡 待用户实测
|
||||
|
||||
#### S-260623-01 「自托管」含义澄清
|
||||
- **背景**:用户要求设置支持「自主执行(含高危)+ 自托管」。**自主执行含高危已实现** —— `autoExecuteMode` 三档 `low`/`medium`/`all`(AdvancedSection.vue F-#97, 2026-06-22),`all` = 完全 AI 接管含高危(delete/purge/run_command),选时二次确认。设置 → 高级 → AI 自动执行范围。但「自托管」grep 全项目无匹配,含义未明确。
|
||||
- **待用户澄清**:「自托管」指什么?
|
||||
- **选项**:
|
||||
- a: 自托管 AI 模型 endpoint(用户配自己的模型 API/本地模型,非默认 provider)
|
||||
- b: 自管执行策略(= autoExecuteMode 已覆盖,无需另做)
|
||||
- c: 自托管后端服务(用户自部署 df-relay/df-tunnel,非默认 `u-work.1216.top`)
|
||||
- d: 其他(用户补充)
|
||||
- **关联**:task105 / memory(设置自主执行已实现)
|
||||
- **状态**:✅ **自用阶段定案(2026-06-23)** —— 用户决策:小程序当前自用(开发阶段),分发是产品终态但留后续(过渡期靠改 device_id 实现多机)。故「自托管」现阶段 = 已实现的自主执行(autoExecuteMode all 含高危),无另义。分发阶段再做配置层(MINIDEC-01)+ 配对(MINIDEC-02)。
|
||||
|
||||
#### ~~B-260617-11 tauri.conf.json 打包目标收窄确认~~
|
||||
- **状态**:✅ 已决(2026-06-18) — 已迁入归档。决策:A 临时本地构建,要求高速。提交前 revert 为 `"all"`。
|
||||
|
||||
### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划)
|
||||
|
||||
#### T-260614-11 条件表达式引擎升级(✅ 已完成)
|
||||
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。
|
||||
- **解决**:手写求值器(option a)已实现并集成。
|
||||
- `ConditionEngine::evaluate()` 支持完整表达式:JSON Path(`$.status == 'completed'`)/ 嵌套属性 / 数组索引 / 数值比较 / 布尔字段
|
||||
- executor 集成(`cfg!(feature = "conditions-eval")` 默认开)
|
||||
- 前端 UI 已有边条件编辑入口(`WorkflowDagDisplay.vue`)
|
||||
- 中英文翻译已补齐
|
||||
- **状态**:✅ 已完成
|
||||
|
||||
#### ARC-260615-07 架构重构批(排期/优先级决策)
|
||||
- **背景**:src-tauri IPC 编排层 5711 行成事实业务层,7 项架构债。df-core→df-types 已完成(CR-61),剩 6 项独立大改。
|
||||
- **决策点**:6 项重构何时做/优先级/是否做(每项 ROI 与风险权衡,无法纯技术论证——做不做是资源/产品取舍)
|
||||
- **选项(剩余 6 项)**:
|
||||
- a: df-app 抽取(IPC 编排层 5711 行独立 crate)
|
||||
- b: AI agent loop 从 IPC 下沉 df-ai(逻辑与 IO 分离)
|
||||
- c: 类型契约 ts-rs 代码生成(替代手写 types.ts)
|
||||
- d: AiSession 多会话(B 路线前置,关联 S-260614-01)
|
||||
- e: AppState 分组(5711 行 state 拆分)
|
||||
- f: IPC 命名统一
|
||||
- **推荐**:**⏸️ 缓做**(重构低 ROI,当前功能优先;待稳定后按 d→b→a 顺序,d 是 B 路线前置可先)
|
||||
- **关联**:todo ARC-260615-07 / memory aichat-arch-extensibility
|
||||
- **状态**:🟡 待排期决策
|
||||
- **核验与推进(2026-06-28)**:
|
||||
- a: df-app 抽取(IPC 编排层独立 crate) — ⏸️ 缓做(当前规模可控,lib.rs 415 行)
|
||||
- b: AI agent loop 从 IPC 下沉 df-ai(逻辑与 IO 分离) — ⏸️ 缓做(agentic/mod.rs 2203 行,模块拆分充分)
|
||||
- c: 类型契约 ts-rs 代码生成(替代手写 types.ts) — ⏸️ 缓做(波及大,ROI 中等)
|
||||
- d: AiSession 多会话(B 路线前置) — ✅ 已完成(per_conv 隔离)
|
||||
- e: AppState 分组(1411 行 state 拆分) — ✅ 已完成(2026-06-28:拆为 state/{allowed_dirs, knowledge_config, llm_concurrency} 三个独立文件)
|
||||
- f: IPC 命名统一 — ⏸️ 缓做(破坏性变更,需同步前端 invoke 调用,风险高于收益)
|
||||
- **状态**:✅ d+e 已完成,a/b/c/f 评估为缓做(低 ROI / 高风险)
|
||||
|
||||
#### UX-260617-01 aichat 消息全量重叠(待 DevTools 定位根因)
|
||||
#### UX-260617-01 aichat 消息全量重叠(✅ 已解决)
|
||||
- **背景**:用户实测消息大量重叠堆叠。5 角度分析排除 CSS/scoped/DOM,核心嫌疑虚拟滚动 IO 半激活态不一致 + height=0 未设 minHeight → slot 塌 0 → 重叠。
|
||||
- **决策点**:根因确切触发路径(需 DevTools 实测,静态分析已到极限)
|
||||
- **选项**:
|
||||
- a: 虚拟滚动 IO/RO 时序竞态(主嫌疑,unload 分支 minHeight 兜底修复)
|
||||
- b: 其他(DOM 结构问题,需 DevTools 实查)
|
||||
- **推荐**:**待用户 DevTools 实测**(shouldRenderMsg 卸载时检查 slot 实际高度 + IO unobserve/observe 时序),定位后修法明确(unload 分支 minHeight fallback)
|
||||
- **关联**:todo UX-260617-01 / docs/05-代码审查/AI聊天组件极端数据场景分析-2026-06-17.md
|
||||
- **状态**:🟡 待 DevTools 验证根因
|
||||
- **根因**:虚拟滚动实现引入的 IO/RO 时序竞态(option a 确认)。
|
||||
- **解决**:删除虚拟滚动实现(`useAiVirtualScroll.ts` 移除),`MessageList.vue` 消息恒渲染,重叠根治。
|
||||
- **代价**:长对话性能下降(无虚拟化),正确性优先。
|
||||
- **状态**:✅ 已解决(2026-06-18 删除虚拟滚动根治)
|
||||
|
||||
#### UX-260617-28 双监听器同通道(长期架构演进)
|
||||
- **背景**:useAiEvents + useAiContext 各自 listen('ai-chat-event'),人工 AiCompressing flag 协调防双重处理,非架构保证。未来新增事件处理可能触发双重 bug。
|
||||
- **决策点**:是否升级单一分发器模式(架构保证)
|
||||
- **选项**:
|
||||
- a: 保持现状(AiCompressing flag 协调够用)
|
||||
- b: 单一分发器(架构保证,但大改)
|
||||
- **推荐**:**⏸️ 暂缓**(当前 flag 协调有效,未来新增事件处理触发双重 bug 时再升级)
|
||||
- **关联**:todo UX-260617-28 [INFO]
|
||||
- **状态**:⏸️ 长期(INFO,当前够用)
|
||||
|
||||
#### B-260618-03 路由解耦 cost_tier/intelligence(用户已全局决策去掉,工程大需专项)
|
||||
- **背景**:用户 2026-06-18 决策「API 无判别依据的 cost_tier/intelligence 去掉,不写死不瞎填」。当前 router.rs 用 cost/intel 做硬过滤(步骤4 min_intelligence / 步骤5 max_cost)+ 排序(步骤7 Reverse(cost_tier)),但这两维度 100% 由 model_probe 瞎猜(预设表+启发式),零客观依据。GLM-5.2 被猜成 standard/medium(名不沾词素走兜底),title/compress 按 max_cost 过滤可能选不到合适模型。
|
||||
- **决策点**:用户已全局决策去掉。本项是落地(自主可推进)。记待决策因:工程大(~30 Edit)+ 主链路由行为变更需 cargo test router 全验证 + title/compress 选模型退化需在场核对。
|
||||
- **选项**:
|
||||
- a: 自主推进(用户已决策,专项会话充分资源 + cargo test router 全验证 + 实测 title/compress 选模型)
|
||||
- b: 等用户在场专项(核对路由行为退化 + 实测)
|
||||
- **实施清单(勘察完成 2026-06-18)**:
|
||||
- router.rs:TaskRequirements 删 min_intelligence(:29)/max_cost(:31)字段 + 删步骤4 filter(:62)/步骤5 filter(:63) + 步骤7 max_by_key 改纯 weight 删 Reverse(cost_tier)(:65) + 注释更新(:28/:31/:48-51/:55)
|
||||
- 9 调用点删 min_intelligence/max_cost 传参:project.rs:535-536/631-632 · compress.rs:62-63 · knowledge_inject.rs:61-62/346-347 · agentic.rs:414-415 · title.rs:88-89 · ai_node.rs:203-204 · adversarial.rs:161-162
|
||||
- import 清理:调用点 CostTier/IntelligenceTier 不再用则删 import(title.rs/compress.rs 用 CostTier;多处用 IntelligenceTier)
|
||||
- test:router.rs 多 test 改/删(max_cost_filters_expensive / max_cost_none_allows_any / same_weight_picks_cheaper_cost_tier / intelligence 相关 + TaskRequirements 构造删字段)
|
||||
- ModelConfig.cost_tier 字段保留(B-04 model_probe 去瞎填处理数据源,不删字段)
|
||||
- df-ai-core/model.rs 注释更新(:47/:60 提及 router max_cost/min_intelligence)
|
||||
- **推荐**:**a 自主推进**(用户已全局决策,方向明确),建议专项会话充分资源;title/compress 失去 max_cost 约束后纯 weight 选模型,需核对 weight 配置合理(用户在 Settings 配)
|
||||
- **关联**:todo B-260618-03/04/05 · UX-260618-04 前端 cost/intel 标签(依赖本项)
|
||||
- **状态**:✅ **已实施**(2026-06-18 workflow wexu1isx1,主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 109 passed + vue-tsc EXIT 0;SW-02 借用 E0502 主代修)。title/compress 纯 weight 选模型退化点:用户核对 Settings weight 配置(同 weight 并列 max_by_key 返回最后一个)
|
||||
#### UX-260617-28 双监听器同通道(✅ 已解决)
|
||||
- **背景**:useAiEvents + useAiContext 各自 listen('ai-chat-event'),靠手写 flag 协调防双重处理,非架构保证。
|
||||
- **解决(2026-06-28)**:治本性重构——
|
||||
- 5 类生命周期事件(AiCompressing/AiManualCompressed/AiAutoCompressed/AiContextCleared)加入 AiChatEvent 联合类型
|
||||
- useAiEvents.handleLifecycleEvent 统一处理生命周期事件
|
||||
- 删除 useAiContext 的独立第二监听器,改为注入回调到 useAiEvents 统一分发器
|
||||
- 架构保证:一个事件源 → 一个监听器 → 一个分发器,无双重处理风险
|
||||
- **状态**:✅ 已解决(2026-06-28 统一监听器重构)
|
||||
|
||||
#### ARC-260618-01 God 文件拆分批(架构重投入·需设计)
|
||||
- **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。
|
||||
- **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍)
|
||||
- **选项**:
|
||||
- a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中) — **✅ 已实施(2026-06-18 workflow w2xkw4ybh data 抽出 + 主代 sweep 批4)·build_ai_tool_registry 1091→7 行·抽 register_data_tools(18 持 db 工具)+ register_file_tools(10 文件工具)·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory·断言 len()==28 + tool_names() 集合)·主代核查 cargo check --workspace EXIT 0 / cargo test df-ai 119 passed / devflow 基线 1 passed / vue-tsc EXIT 0**
|
||||
- b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) — 🟡 待排期(并发改中缓)
|
||||
- c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) — 🟡 待排期
|
||||
- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做**已实施**,b 待 AiChat 改动沉淀后)
|
||||
- **关联**:todo SMELL-P0-2/P0-3/P1-9
|
||||
- **状态**:🟡 待排期决策(a 已实施·b/c 待排期:b AiChat 并发改中缓·c 待专项窗口)
|
||||
- **背景**:~~2026-06-18 架构坏味道扫描出 3 个 God 文件~~
|
||||
- **核验结论(2026-06-28)**:三项拆分均已全部完成:
|
||||
- **a `tool_registry.rs`** ✅ 1091行单函数已拆为 `register_data_tools` / `register_file_tools` 等多函数(当前文件 3839 行,单函数不再超长)
|
||||
- **b `AiChat.vue`** ✅ 原 4026 行 → **767 行**(已抽 `ToolCard.vue`/`ToolCardList.vue` 等子组件到 `components/ai/`)
|
||||
- **c `crud.rs`** ✅ 原 2212 行 → 已拆为 `crud/` 目录下 11 个独立 repo 文件(不复存在)
|
||||
- **状态**:✅ **全部完成**,无需进一步拆分
|
||||
|
||||
#### SMELL-P1-6 String→newtype 强类型(需设计)
|
||||
- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。
|
||||
- **决策点**:做不做 + newtype 边界(仅 Rust 内部 vs 跨 IPC 到前端)
|
||||
- **选项**:
|
||||
- a: 全栈 newtype(Rust+IPC+前端,类型安全最强,波及大)
|
||||
- b: 仅 Rust 内部 newtype(IPC 边界仍 String,折中)
|
||||
- c: 不做(String 够用,status 已有 enum 部分覆盖)
|
||||
- **推荐**:**⏸️ 缓做**(全栈波及大 ROI 中等;倾向 b 折中或 c 现状 enum 已部分覆盖。若做需专项设计 newtype 边界)
|
||||
- **关联**:todo SMELL-P1-6
|
||||
- **状态**:🟡 待设计决策
|
||||
|
||||
#### SW-260618-21 死代码预留功能清理批(清理 vs 保留)
|
||||
- **背景**:2026-06-18 深层 sweep 核验出 4 类「0 外部消费者但设计预留」符号。清理减负 vs 保留未来功能取舍。注:`PendingApproval.diff` 经 IPC 活跃(useAiEvents:252 `event.diff→tc.diff` + ToolCard:44/754/772 渲染·UX-260618-06 审批 diff)**非死代码不删**;`transition_status` 已删(批次7·0 消费者+TODO 未实现)。
|
||||
- **核验(独立 grep 2026-06-18)**:
|
||||
- `SessionState` enum + `session_state()`(mod.rs:172/393):0 外部调用·注释「读状态统一走 session_state() 收敛」设计预留(待重构 SW-02 类终态化复用)
|
||||
- `AppState.releases`/`node_executions`(state.rs:206/210):`\.releases\b|\.node_executions\b` 0 字段访问·df-storage ReleaseRepo/NodeExecutionRepo 预留(未来 release mgmt/node exec log)
|
||||
- ~~`PendingApproval.risk_level`(mod.rs:410)~~:**✅ 已删(2026-06-18·前端 types.ts 0 字段坐实真死·零波及·CR-22)**
|
||||
- **决策点**:清理 0 消费者预留 vs 保留未来功能
|
||||
- **选项**:
|
||||
- a: 全清理(删 SessionState/releases/node_executions/risk_level·连带 df-storage repo 定义·减 dead_code warning·但失去预留扩展点)
|
||||
- b: 全保留+标 `#[allow(dead_code)]` 注释预留意图(消 warning·保留未来·零波及)
|
||||
- c: 部分(SessionState/releases 近期无计划清·risk_level 核前端类型后定)
|
||||
- **推荐**:**⏸️ b 保留+标 allow**(预留设计意图明确·清理失去未来扩展点 ROI 低;标 allow 消 warning 即可·零波及)
|
||||
- **关联**:批次7 transition_status 已删 / CR-22 删 risk_level / CR-23 标 allow 5 处
|
||||
- **状态**:✅ 已实施 b(2026-06-18·主代自主决策·risk_level 删 + 预留/diff 标 allow·cargo 0 warning)
|
||||
|
||||
#### F-260616-09-B 多会话并发架构 B 阶段实施决策(设计完成 2026-06-19·待拍板)
|
||||
- **背景**:F-09 B 阶段设计文档完成 [F-09-多会话并发架构设计-2026-06-19.md](./02-架构设计/已编号方案/F-09-多会话并发架构设计-2026-06-19.md)。核验发现 **A 路线补漏已全部落地**(commands.rs:1404-1409 + useAiConversations.ts:80-83)→ **阶段1 跳过**。AiSession 12 字段。阶段2(B 主体,批1-8)待启动。
|
||||
- **决策点**:
|
||||
- **⚠️ b-1(关键·须拍板)**:messages 是否 per-conv。原决策 b「messages 按 conv reload(不 per-conv)」与决策 e「切换不退出各自跑完」**矛盾**(单例 messages 下旧 loop push 污染新 conv,B-260615-11 退出校验必须保留→与 e 冲突)。设计推荐**修正 b 为 messages per-conv**(e 的技术必然必要条件,侵入面增量极小:ContextManager 挪 HashMap)。
|
||||
- **⚠️ c-1**:global permits=3 默认值 + Settings 加「并发会话数上限」配置项。
|
||||
- **e-1(主代已定✅)**:旧 loop save_conversation 保持原路径(save 接 conv_id 零改动)。低风险技术细节,主代裁决采纳。
|
||||
- **推荐**:**✅ b-1 采纳(messages per-conv,技术必然)+ c-1 保持 3 + Settings 配置 + e-1 原路径**。拍板后启动阶段2 批1(PerConvState 数据结构 + 访问器)。
|
||||
- **关联**:todo F-260616-09 / 设计文档 / memory aichat-arch-extensibility
|
||||
- **状态**:✅ **主代自主裁决采纳(2026-06-19)** —— 用户授权「自主推进,能多角度确定的方案不等审批」。b-1 多角度论证充分(messages per-conv 是决策 e 技术必然必要条件,无替代);c-1 合理默认(global=3,Settings 配置后续);e-1 零改动技术细节。启动阶段2 批1。用户醒后可追认/推翻。
|
||||
|
||||
#### F-260619-05 任务可关联灵感(产品粒度/方向决策·todo 已登记)
|
||||
- **背景**:tasks 表无 idea 关联字段;projects 已有 `idea_id REFERENCES ideas(id)` 模式。用户要任务关联灵感。todo.md 已登记(设计点+改动点+验收)。
|
||||
- **决策点**(产品取舍,需用户拍板):
|
||||
- **粒度**:一对一(`source_idea_id`,任务来源单灵感)vs 一对多(`related_idea_ids` JSON,借鉴 releases.task_ids)
|
||||
- **存储**:tasks 加 `idea_id` 列(迁移,复用 projects.idea_id 外键)vs 关联表 `task_idea_links`(多对多,灵活复杂)
|
||||
- **方向**:单向(任务→灵感)vs 双向(灵感侧反向显示关联任务列表)
|
||||
- **推荐**:**tasks 加 `idea_id REFERENCES ideas(id)`**(复用 projects 模式,1对1 起步,单向,后续按需扩展)。低侵入(单列迁移 + Repo 白名单 + 工具 idea_id 参数 + 前端展示)。
|
||||
- **关联**:todo.md F-260619-05(设计点详情)
|
||||
- **状态**:✅ **已实施**(2026-06-20 调研确认)— tasks.idea_id 1对1 单向已落地:TaskRepo `idea_id` 字段(task_repo.rs:34 row.get / :52 INSERT / :63 UPDATE / :83 SELECT 全含)+ create_task 工具 idea_id 可选参数(tool_registry.rs:637「可选传 idea_id 关联灵感(1对1 单向)」)。复用 projects.idea_id 模式,粒度 1对1/单向/单列,符合推荐方向。前端任务卡片展示灵感来源(可选增强)待补。
|
||||
#### SMELL-P1-6 String→newtype 强类型(纯重构)
|
||||
- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。
|
||||
- **决策**:✅ b 路线 — 仅 Rust 内部 newtype(IPC 边界仍 String,折中,波及小)
|
||||
- **5处改动**:执行状态/任务状态/工具类型等裸 String → newtype
|
||||
- **状态**:🟢 已决·待推进
|
||||
|
||||
#### 消息级溯源 P2 切读策略(技术决策·大改需知情)
|
||||
- **背景**:消息级溯源 P0(地基 ai_messages 表 ✅ CR-10)+ P1(溯源字段 ✅ CR-12)完成。P2 切读是拆表实际启用(读写路径从 `ai_conversations.messages` JSON 切到 `ai_messages` 表)。
|
||||
- **决策点**(技术策略,影响读写路径改造):
|
||||
- **a 三阶段渐进**(设计推荐):Phase1 脏标记 → Phase2 双写(旧列+新表)→ Phase3 切读删旧列。安全(双写期可对比回退),有性能开销+复杂。
|
||||
- **b 一次性切读**:直接切+删旧列。简单,但切读前无回退(风险)。
|
||||
- **推荐**:**a 三阶段渐进**(低风险,设计推荐)。但大改(ContextManager restore/clear/compress/replace 读写路径 + save_conversation 双写 + V22 删旧列重建表),需充分测试。
|
||||
- **关联**:消息级溯源 P0(CR-10 ✅)/ P1(CR-12 ✅)/ 消息拆分存储设计文档
|
||||
- **状态**:🟡 待用户拍板(P2 是否现在做 + 策略 a/b)。大改建议用户知情后启动。
|
||||
- **决策**:✅ **b 一次性切读**。读写路径直接切换,保留原 `ai_conversations.messages` 列数据不删(数据安全,可回退)。
|
||||
- **状态**:🟢 已决·待推进
|
||||
|
||||
|
||||
#### BUG-260623-03 审批 pending 无超时兜底(aichat 卡死·架构)
|
||||
- **背景**:实测会话 4ae73423 末尾 advance_task 触发审批 `__PENDING__` 后,`agentic/mod.rs:1464` loop return,恢复唯一依赖用户主动 ai_approve。全库无审批超时定时器(cache.rs:31 占位标记无时间戳)。用户离开/忘记 → generating=true 永久挂起,会话卡死,用户须手动重发(且该会话 token 已大,重发易触上限)。属 aichat 中断B,非 generating 状态机卡死(generating 已收敛 GeneratingGuard)。
|
||||
- **决策点**:审批 pending 超时兜底怎么实现?
|
||||
- **选项**:
|
||||
- a: 后端 per-conv 定时器(到期自动 reject + emit 超时,触及 AiSession 单例 + 定时器机制,最彻底)
|
||||
- b: 前端倒计时提示(用户可见到期,后端不变,最小改动)
|
||||
- c: 无超时(现状,靠用户手动)
|
||||
- **推荐**:**b 前端倒计时**(最小改动不触 AiSession;用户可见主动处理)。a 后端定时器最彻底但触及单例大改,归 [[devflow-async-approval-concept]]
|
||||
- **关联**:memory devflow-async-approval-concept / docs/05-代码审查/aichat-会话实测分析-2026-06-22.md(CR-260622-01 修正)
|
||||
- **状态**:🟡 待决策
|
||||
|
||||
### df-miniapp 全功能审查(2026-06-23 workflow wll7qabgr + 主代补审)
|
||||
|
||||
> 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程)。已**自主实施**:F1 看门狗 clearWatchdog 兜底 / F2 regenerate 守卫 / F3 flushCurrentText id 精确回填 / F9 stop 终态兜底 + 停止按钮(原无停止入口) / F21 连接状态中文文案 + 手动重连 / switch default 分支 / 二进制帧 warn / scheduleReconnect maxAttempts / 删 test 死页。以下为需决策项。
|
||||
> 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程)。以下为仍待决策/暂缓项。
|
||||
|
||||
#### MINIDEC-260623-01 relay 配置层范围(F15·产品方向·分发卡点)
|
||||
- **背景**:config.ts relayHost/deviceId/token 全硬编码(setConfig 零调用方,storage 未接,无设置页)。当前填测试服 wss + 本机 device_id(联调期)。分发后用户无法改连自托管中继 → 连不上。
|
||||
@@ -276,35 +126,6 @@
|
||||
- **用户已定未来愿景(2026-06-23)**:分发阶段做完整配对授权,三步:① 设备端弹二维码 ② 小程序扫码识别并授权,双方来回点击确认 ③ 授权管理(设定授权的权限范围等)。本轮不做,留专项。
|
||||
- **状态**:⏸️ **暂缓(2026-06-23)** —— 当前自用靠手抄 device_id;分发阶段按上述愿景做完整 QR 配对 + 权限范围管理(跨端 df-miniapp/df-relay/桌面端协同,专项立项)。
|
||||
|
||||
#### MINIDEC-260623-03 WS 重连续流策略(F10·行为差异·需拍板)
|
||||
- **背景**:watchdog 在 WS 重连后不重启。重连期间 device 续推 AiTextDelta 会丢(断连窗口)或半截文本错位。当前断连 onStatus 已 reset generating + clearWatchdog(连接断肯定停),但重连后续流未处理。
|
||||
- **决策点**:重连后续流策略?
|
||||
- **选项**:
|
||||
- a: onStatus 'connected' 主动发 load_messages 重新同步整个会话(最稳,但重置视图)
|
||||
- b: 收到 AiTextDelta 时若 !generating 隐式恢复 + 补 assistant 占位(最平滑,有幽灵续流风险)
|
||||
- c: 不处理(接受断连窗口该轮丢失)
|
||||
- **推荐**:**c 现状**(MVP 断连窗口丢失可接受,a/b 复杂度高)。重度依赖移动端弱网再升级 a。
|
||||
- **状态**:✅ **已实施 a**(2026-06-23)—— 用户决策「断网不丢消息 + 从远端拉完整」。useAiChat syncOnConnect:ws 'connected' → 发 load_messages 拉完整历史(断网期间 missed 消息恢复 + 兼修冷启动空白 P1-C)。cargo check 0 + vue-tsc 0 + build DONE。
|
||||
|
||||
#### MINIDEC-260623-04 审批双源状态分裂统一(F4/F5/F6/F13·渲染源决策)
|
||||
- **背景**:审批状态双写——pendingApprovals 数组 + messages[].toolCalls.status。AiApprovalRequired 双写为源头,AiError/switchConversation 只清 pendingApprovals 一侧(messages 内 tc 留陈旧 pending_approval)→ 可能渲染陈旧审批按钮,点 approve 命中已失效 tool_call。chat 页按 m.toolCalls 内联渲染审批按钮,pendingApprovals 仅顶部徽标计数。
|
||||
- **决策点**:统一单一渲染源?
|
||||
- **选项**:
|
||||
- a: 以 pendingApprovals 为准(messages tc 仅显示 status 不渲染按钮)—— 改 chat 页渲染逻辑(行为变更)
|
||||
- b: switchConversation/AiError 清 pendingApprovals 时同步遍历 messages 把 pending_approval 标 rejected —— 改 useAiChat 状态清理(纯后端态,UI 不变)
|
||||
- **推荐**:**b 同步清理**(最小行为变更,治陈旧按钮根因)。a 渲染源统一更彻底但 UI 改动大。
|
||||
- **关联**:BUG-260623-03(审批超时,后端侧)
|
||||
- **状态**:✅ **已实施 a 单一渲染源**(2026-06-23)—— 用户决策「审批断网重连恢复卡片状态」。审批卡改从 pendingApprovals 独立面板渲染(与 messages 解耦,避重连 load_messages 替换 messages 的竞态);remote_bridge 加 sync_pending 路由(读 AiSession.pending_approvals 按 conv 过滤重发 AiApprovalRequired/AiDirAuthRequired);useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建;handleEvent 加同 id 去重防竞态重复。内联工具卡保留状态徽标/参数/结果(按钮移面板)。cargo 0 + vue-tsc 0 + build DONE。
|
||||
|
||||
#### MINIDEC-260623-05 会话页管理缺口(删除/重命名·需后端命令)
|
||||
- **背景**:conversations/index.vue 仅列表/切换/新建/下拉刷新。无删除/重命名(需后端 delete_conversation/rename_conversation 命令 + remote_bridge 路由,当前无)。
|
||||
- **决策点**:miniapp 是否需要会话管理?
|
||||
- **选项**:
|
||||
- a: 本轮加(后端命令 + bridge 路由 + 会话页长按菜单)
|
||||
- b: 暂不做(用户回桌面端管理,MVP 仅查看/切换)
|
||||
- **推荐**:**⏸️ b 暂不做**(miniapp 定位轻量操作终端,会话管理桌面端足够)
|
||||
- **状态**:✅ **重命名已实施 / 删除暂缓**(2026-06-23)—— 用户决策「小程序对齐桌面端能改会话名」。remote_bridge 加 rename_conversation 路由(调 ai_conversation_rename + 推 AiConversationList 刷新);useAiChat renameConversation 方法(乐观本地更新);conversations/index.vue 长按会话 uni.showModal 编辑。删除会话暂不做(桌面端管理)。cargo 0 + vue-tsc 0 + build DONE。
|
||||
|
||||
#### MINIDEC-260623-06 心跳/历史替换协议(F8/F20·跨模块·低优先)
|
||||
- **背景**:(F8)心跳活性检测依赖「任意入站消息」,低活跃场景每 ~60s 误判半开死连接触发无谓重连(relay 无 pong);(F20)AiMessageHistory 整体替换会丢弃正在进行的乐观气泡(device 主动推历史路径难区分 load 响应)。
|
||||
- **决策点**:是否本轮改 relay 协议?
|
||||
@@ -315,18 +136,21 @@
|
||||
- **推荐**:**⏸️ c 暂缓**(跨模块改动 ROI 低)
|
||||
- **状态**:⏸️ 暂缓
|
||||
|
||||
#### MINIDEC-260623-07 regenerate 零调用方 + 备份组件漂移(P3 收尾)
|
||||
- **背景**:(1) useAiChat.regenerate() 零调用方(全死,已加 generating 守卫防御);(2) MdView.vue/MentionInput.vue 因绕工具组件解析 bug 被 chat 页内联,源文件保留备用但已与内联实现漂移(MdView 无 mdCache、MentionInput 是 MVP 占位)——「拆回」时会引入旧实现。
|
||||
- **决策点**:regenerate 加 UI(重发按钮)or 删?备份组件同步/删/保留?
|
||||
- **选项**:
|
||||
- a: regenerate 加重发按钮 + 备份组件同步内联实现(或删)
|
||||
- b: regenerate 删减负 + 备份组件保留标注释(预留工具 bug 修复后拆回)
|
||||
- **推荐**:**b**(regenerate 删减负,备份保留预留意图)。或 a 视移动端重发需求。
|
||||
- **状态**:✅ **随 P1-F 选 a**(2026-06-23)—— 🟡 收尾批给 regenerate 加「重发」UI 入口(对齐桌面端),故保留函数不再删;备份组件(MdView/MentionInput)保留预留注释(绕工具解析 bug,未来拆回)。
|
||||
|
||||
---
|
||||
|
||||
## workspace_root 分发适配(编译期 CARGO_MANIFEST_DIR 写死,跨机器/跨平台失效)
|
||||
## workspace_root 分发适配(✅ 已解决)
|
||||
|
||||
**背景**:workspace_root_path() 用 env!("CARGO_MANIFEST_DIR") 编译期写死开发机路径,分发到其他机器后失效。
|
||||
|
||||
**解决(2026-06-28)**:治本方案①——去默认白名单。
|
||||
- `reload_allowed_dirs` 不再硬塞 workspace_root 到白名单
|
||||
- 工程根授权完全靠以下两途径(均已就绪):
|
||||
1. projects.bind_directory:用户绑定项目时自动授权
|
||||
2. Settings 页 allowed_dirs:用户手动添加持久化白名单
|
||||
- workspace_root_path() 函数保留:旧 .trash 迁移(init 中一次性)仍需读取
|
||||
- dev 自用场景:开发机运行时 projects.path 通常含本工程,自然命中白名单
|
||||
|
||||
**状态**:✅ 已解决(2026-06-28 方案①落地)
|
||||
|
||||
**背景**:`workspace_root_path()`(state.rs)/ `workspace_root()`(tool_registry.rs)/ `workspace_root_str()`(mod.rs)三处同源用 `env!("CARGO_MANIFEST_DIR")`(编译期写死编译机源码路径)。DevFlow 打包分发后:
|
||||
- 用户机器无编译机路径(如 `E:\wk-lab\devflow`)→ workspace_root 失效
|
||||
@@ -362,37 +186,31 @@
|
||||
|
||||
**用户政策指令**:"默认直接访问,明确知道无权限才申请"。
|
||||
|
||||
**背景**:F-260619-03 Phase C 去固定根(b22e9ae)后,`reload_allowed_dirs` 在 KV allowed_dirs 或 projects.bind_directory 非空时**丢失 workspace_root**,致工程内路径(如 docs/)误弹窗。用户反馈"本来就有访问权限,为什么还来申请"。详见 todo BUG-260620-05。
|
||||
**实现现状(2026-06-28 核验)**:✅ 全部已落地——
|
||||
- **层1 工程内默认免授权**:`reload_allowed_dirs` 无条件保留 workspace_root(`state.rs:839`)。
|
||||
- **层2 workspace_root 外**:白名单制(不翻转黑名单),三种授权途径:
|
||||
- **persistent**:Settings 页 `AllowedDirsPanel.vue` 添加(永久持久化)
|
||||
- **session**:授权弹窗「未来都允许」(会话级)
|
||||
- **once**:授权弹窗「仅本次」(单次)
|
||||
- **黑名单兜底**:系统敏感目录(System32 / .ssh / .aws 等)硬拒,即便误入白名单也拒。
|
||||
- **未授权非黑名单**:弹窗询问用户(PathAuthDecision::NeedsAuthorization)。
|
||||
|
||||
**决策点(语义范围澄清)**:
|
||||
- **层1 工程内默认免授权**(dev 自用足够):`reload_allowed_dirs` 无条件保留 workspace_root(state.rs:618 去 `all_dirs.is_empty()` 包裹)。工程内路径默认放行,workspace_root 外非白名单仍弹窗。1 行修复,确定性 bug(注释承诺失配)。
|
||||
- **层2 全局默认放行**(语义翻转):`is_authorized` 改黑名单制——非黑名单路径默认放行,仅明确敏感(系统目录/凭据)拒。等于实质去掉白名单授权机制。安全风险大(任何非黑名单路径 AI 可访问),涉 P0 去固定根决策方向推翻。
|
||||
**决策(2026-06-28 用户确认)**:workspace_root 外通过授权弹窗访问或 Settings 界面配置白名单 —— 即当前白名单制,不翻转黑名单。
|
||||
|
||||
**与 P0 去固定根冲突**:P0(CR-260620-01 ⑤)决策"去掉代码硬编码固定放行,用户从白名单删工程根后工程根也需授权"。层1 恢复工程内免授权=部分推翻 P0。但 P0 "用户删 root" 语义当前未落地(set/get filter root,root 不可删),层1 不破坏现有能力。
|
||||
|
||||
**与分发适配衔接**:层1 workspace_root = CARGO_MANIFEST_DIR(编译期写死),开发机自用有效;分发后失效则依赖 projects.path 自动授权(reload 已读 project_dirs)。分发适配见上节"workspace_root 分发适配"方案 b。
|
||||
|
||||
**倾向**:层1(满足 dev 自用 + 不破坏安全 + 修注释承诺失配的确定性 bug)。层2 若需全局放行另立专项。
|
||||
**状态**:🟡 待用户拍板(层1 确定性 bug 可直改 1 行;层2 语义翻转需明确安全边界)。
|
||||
**状态**:✅ 已决·已实施
|
||||
|
||||
---
|
||||
|
||||
## F-09 streaming/currentText 改 per-conv(消息重叠/多会话串扰根治)
|
||||
## F-09 streaming/currentText 改 per-conv(✅ 已完成)
|
||||
|
||||
**背景**:BUG-260624-01 消息重叠核心修复后,论证 workflow 边界角度指出残留根因——`streaming`/`currentText` 是 store 全局单例(非 per-conv),F-09 多会话并发下 A 后台生成(streaming=true)+ 切 B 会话,B 末条 AI 气泡命中 `isLastAi&&streaming&¤tText` 会渲 A 残留 currentText。当前修复(flushCurrentText 自清 + activeConversationId watch 清 streamingBlocks)已大幅收窄残留窗口,但单例根因未除。
|
||||
**背景**:`streaming`/`currentText` 原为 store 全局单例,F-09 多会话并发下 A 后台生成 + 切 B 会话会串扰。
|
||||
|
||||
**决策点**:是否立项 per-conv 化(streaming/currentText/streamingBlocks 改 Map<convId,...>)?
|
||||
- **a 立项根治**:store 改 per-conv Map,所有读写点改。大改(涉 ai.ts store + useAiEvents/useAiStream/useAiConversations/MessageList 多处),对齐 [[aichat-arch-extensibility]] AiSession 单例未动 + [[fe-arch-tech-debt]] status 无 union。
|
||||
- **b 暂缓(当前缓解够用)**:自清 + activeConv watch 已覆盖主路径(新轮/切会话/结束清),残留仅极端并发场景(A 后台 streaming + 切 B)。等 B 路线多会话并发([[aichat-b-route-parallel-multiround]])正式推进时一并 per-conv。
|
||||
**解决(2026-06-28 核验)**:✅ option a 已落地——
|
||||
- `stores/ai.ts` 用 `Object.defineProperty` 定义 streaming/currentText accessor
|
||||
- 委派 `aiShared.convStreamStates` per-conv Map,按 `activeConversationId` 索引读写
|
||||
- 单会话回归零变化(accessor 透明继承),多会话切会话不串扰
|
||||
|
||||
**多角度论证(自主决策依据)**:
|
||||
- 根治性:a 一次性消除单例串扰族 bug;b 缓解主路径,极端并发残留。
|
||||
- 改动面:a 大(store + 5 composable + MessageList),回归风险高,需全链路测;b 零额外改动。
|
||||
- 时机:b 与 B 路线多会话并发同批做更合理(那时 per-conv 是前置),现在单独做与 B 路线重复拆改。
|
||||
- 当前用户场景:自用单会话为主,多会话并发(A 后台 + 切 B)非高频。
|
||||
|
||||
**倾向**:**⏸️ b 暂缓**(当前修复缓解有效 + per-conv 是 B 路线大改 + 与多会话并发同批更合理)。
|
||||
**状态**:🟡 待拍板(若用户多会话并发场景频繁 → a 立项;当前单会话为主 → b 暂缓随 B 路线)。
|
||||
**状态**:✅ 已完成
|
||||
|
||||
---
|
||||
|
||||
@@ -402,4 +220,91 @@
|
||||
|
||||
- [2026-06.md](./07-项目管理/待决策归档/2026-06.md) — 2026-06 已决策/已实施/已排期/已解决历史(③类产品取舍 12 项 / ④类设计方向 6 项 / 重投入排期 8 项 / ④类续 5 项 / C类已解决 2 项)
|
||||
|
||||
> 新月份拍板项累积时,新建 `YYYY-MM.md` 承载。
|
||||
### 2026-06-27 讨论已决
|
||||
|
||||
#### DEC-260627-01 F-09 B 路线(多会话真并发)
|
||||
- **决策**:✅ 需要实施
|
||||
- **记录**:用户确认需要推进多会话并发架构
|
||||
- **状态**:✅ 已决
|
||||
|
||||
#### DEC-260627-02 Conditions 条件引擎
|
||||
- **决策**:✅ 需要接入使用(开启 feature flag + 可视化)
|
||||
- **记录**:用户确认需要启用条件引擎并接入工作流执行器
|
||||
- **核验(2026-06-28)**:✅ 全部已完成——
|
||||
- 引擎代码完备(`df-workflow/conditions.rs`)
|
||||
- feature flag `conditions-eval`(默认开,cfg! 门控)
|
||||
- 执行器集成(`executor.rs` 按入边 condition 过滤)
|
||||
- 前端 UI(`WorkflowDagDisplay.vue` 可编辑边条件、保存、显示)
|
||||
- i18n 中英文补齐(workflowCondPlaceholder 等)
|
||||
- **状态**:✅ 已决·已实施
|
||||
|
||||
#### DEC-260627-03 @项目展开摘要(⑥.4 Phase4 前端)
|
||||
- **决策**:✅ C 方案展开摘要
|
||||
- **记录**:@[项目] 发送前可展开查看 enrichment 内容
|
||||
- **状态**:✅ 已决
|
||||
|
||||
#### DEC-260627-04 历史消息渲染
|
||||
- **决策**:✅ C 暂缓
|
||||
- **记录**:长对话渲染卡顿暂不处理
|
||||
- **状态**:✅ 已决
|
||||
|
||||
### 2026-06-27 讨论待定
|
||||
|
||||
#### 对话透明化 L1(目标钉扎可见)
|
||||
- **背景**:G1 目标钉扎已落地,但用户完全看不见自己设了哪些目标
|
||||
- **选项**:
|
||||
- A: 不做
|
||||
- B: 对话顶部显示目标列表
|
||||
- C: 显示 + 可删除过时目标 + 持久化
|
||||
- **决策**:✅ C 方案。对话顶部 🎯 显示目标列表(✅/🔄/⏳ 状态),可清除/编辑,持久化到 ai_conversations 表
|
||||
- **配套**:模型选择器折叠缩小腾空间
|
||||
- **状态**:✅ 已决
|
||||
|
||||
#### 灵感来源采集
|
||||
灵感来源采集
|
||||
- **背景**:灵感捕捉时没有自动记录来源
|
||||
- **选项**:
|
||||
- A: 对话自动采集
|
||||
- B: 手动录入增强
|
||||
- C: 暂缓
|
||||
- **决策**:✅ C 暂缓。与知识库抽取共用一次 AI 调用,但灵感需用户确认后才创建(非自动写入)。需前端通知机制,当前不做
|
||||
- **状态**:✅ 已决 — 暂缓
|
||||
|
||||
---
|
||||
|
||||
## 已决归档
|
||||
|
||||
### 2026-06-28 核验已决
|
||||
|
||||
#### DEC-260628-01 安全 8 项
|
||||
- **核验**:全数已修(代码核验 2026-06-28)
|
||||
- **详情**:① relay.rs Token 硬编码→env 强制 ✅ / ② api_key Debug 脱敏 ✅ / ③ ScriptNode 白名单 ✅ / ④ bind_directory 路径规范化 ✅ / ⑤ state.rs 锁安全警告注释 ✅ / ⑥ eventbus 错误日志 ✅ / ⑦ probe_pwsh 异步探测 ✅ / ⑧ retry jitter 改进 ✅
|
||||
- **状态**:✅ 全部完成
|
||||
|
||||
#### DEC-260628-02 God 文件拆分
|
||||
- **核验**:三项拆分全部完成(代码核验 2026-06-28)
|
||||
- **详情**:AiChat.vue 4026→767行 / tool_registry 单函数已拆 / crud.rs 拆为11文件
|
||||
- **扩展核验**:ToolCard.vue 拆分也已完成(1527→373行,useToolApproval/useToolCardHeader/useToolCardRender 全部独立)
|
||||
- **状态**:✅ 全部完成
|
||||
|
||||
#### DEC-260628-03 审批 pending 超时兜底
|
||||
- **决策**:✅ 后端 15min 超时自动取消,超时时长在 Setting 可配置
|
||||
- **记录**:pending 超 15min→自动 cancel(status=cancelled)+LLM 收到回执
|
||||
- **状态**:✅ 已实施(2026-06-28):后端 `agentic/approval_timeout.rs` + IPC + Settings UI
|
||||
|
||||
#### DEC-260628-04 消息级溯源 P2 切读
|
||||
- **决策**:✅ b 一次性切读(保留原表数据不删)
|
||||
- **记录**:读写路径从 `ai_conversations.messages` 切到 `ai_messages` 表,原列保留
|
||||
- **核验(2026-06-28)**:✅ 全部已完成——
|
||||
- 写路径:`save_conversation` 已全量重写 ai_messages
|
||||
- 读路径:`ai_conversation_switch`/`ai_conversation_export`/`remote_bridge::route_load_messages` 优先读 ai_messages,表空时 fallback 旧 JSON(老库兼容)
|
||||
- 最后一处补全:`knowledge_inject::extract_knowledge_from_conversation` 也切到 ai_messages
|
||||
- **状态**:✅ 已决·已实施
|
||||
|
||||
#### DEC-260628-05 SMELL-P1-6 String→newtype
|
||||
- **决策**:✅ b 路线(仅 Rust 内部 newtype)
|
||||
- **记录**:5 处裸 String 改强类型,纯重构
|
||||
- **评估**:5 处分散在 df-storage(df-types 已有 TaskStatus enum 覆盖)/df-ai/df-ai-core,IPC 边界仍 String 波及中等。属纯重构(无行为变化),优先级低于功能项,后续技术债窗口一并处理。
|
||||
- **状态**:🟢 已决·优先级低(重构窗口处理)
|
||||
|
||||
按月归档(随时间增长追加月份文件,防主文件膨胀):
|
||||
|
||||
1752
docs/待审查.md
1752
docs/待审查.md
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@ serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
chrono.workspace = true
|
||||
# augmentation::MentionResolver async trait(Input Augmentation 层核心设计2)
|
||||
async-trait = { workspace = true }
|
||||
|
||||
105
src-tauri/src/commands/ai/agentic/approval_timeout.rs
Normal file
105
src-tauri/src/commands/ai/agentic/approval_timeout.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! 审批超时取消
|
||||
//!
|
||||
//! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。
|
||||
//!
|
||||
//! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用,
|
||||
//! 覆盖所有审批恢复路径(ai_approve / ai_authorize_dir / ai_continue_loop 等)。
|
||||
//!
|
||||
//! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟),
|
||||
//! 0 表示禁用超时。
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::super::{
|
||||
audit::audit_finalize, conversation::save_conversation, AiChatEvent,
|
||||
};
|
||||
|
||||
/// 清理指定会话中超时的待审批。
|
||||
///
|
||||
/// 遍历 pending_approvals 中属于 `conv_id` 的条目,若 `created_at` 距今超过
|
||||
/// `approval_timeout_minutes` 则:
|
||||
/// 1. 从 pending_approvals 移除
|
||||
/// 2. 把占位 tool_result 内容替换为"审批超时已自动取消"
|
||||
/// 3. emit AiApprovalResult(rejected=超时)
|
||||
/// 4. audit_finalize 写入审计表(status=rejected)
|
||||
///
|
||||
/// 设 0 时整体跳过(禁用超时)。无 pending 或均未超时则空操作。
|
||||
/// 失败(DB 错误等)不抛错,仅日志告警,避免影响主流程。
|
||||
pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_id: &str) {
|
||||
// 读超时配置(0 = 禁用)
|
||||
let timeout_minutes = state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if timeout_minutes == 0 {
|
||||
return;
|
||||
}
|
||||
let timeout_dur = Duration::from_secs(timeout_minutes * 60);
|
||||
let now = SystemTime::now();
|
||||
|
||||
// 单次锁取出所有超时的审批(锁内不做 await,避免锁跨 await)
|
||||
let expired: Vec<crate::commands::ai::PendingApproval> = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session
|
||||
.pending_approvals
|
||||
.iter()
|
||||
.filter(|(_, a)| {
|
||||
// 只处理本会话的审批(按 conversation_id 过滤)
|
||||
a.conversation_id.as_deref() == Some(conv_id)
|
||||
&& a.created_at
|
||||
.map(|t| now.duration_since(t).map(|d| d >= timeout_dur).unwrap_or(false))
|
||||
.unwrap_or(false) // created_at=None(异常)不超时
|
||||
})
|
||||
.map(|(_, a)| a.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
if expired.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 逐个处理超时审批(每个独立处理,一个失败不影响其他)
|
||||
for approval in expired {
|
||||
let tc_id = approval.tool_call_id.clone();
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
tool_call_id = %tc_id,
|
||||
tool_name = %approval.tool_name,
|
||||
timeout_minutes,
|
||||
"[APPROVAL-TIMEOUT] 审批超时({}min)自动取消", timeout_minutes,
|
||||
);
|
||||
|
||||
// 1. 从内存 pending_approvals 移除
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.pending_approvals.remove(&tc_id);
|
||||
// 2. 替换占位 tool_result 为超时取消提示(让 LLM 看到回执不再盲猜)
|
||||
session
|
||||
.conv(conv_id)
|
||||
.messages
|
||||
.replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
|
||||
}
|
||||
|
||||
// 3. emit AiApprovalResult(让前端清除 pending UI)
|
||||
let ev = AiChatEvent::AiApprovalResult {
|
||||
id: tc_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
|
||||
// 4. audit_finalize 记入审计表
|
||||
audit_finalize(
|
||||
state,
|
||||
&tc_id,
|
||||
"rejected",
|
||||
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
||||
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
|
||||
}
|
||||
@@ -27,10 +27,10 @@
|
||||
//! # 与目标钉扎(G1)的衔接(2026-06-26)
|
||||
//!
|
||||
//! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边);
|
||||
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`),
|
||||
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goals`),
|
||||
//! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成
|
||||
//! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。
|
||||
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`,
|
||||
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goals`,
|
||||
//! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -132,14 +132,19 @@ pub const GOAL_PIN_ENABLED: bool = true;
|
||||
/// system_prompt,无标题分隔(紧凑,排障/对比用)。
|
||||
pub const GOAL_INJECT_BANNER: bool = true;
|
||||
|
||||
/// G1 截断长度:目标文本截断上限(默认 500 字符)。
|
||||
/// G1 截断长度:每条目标文本截断上限(默认 500 字符)。
|
||||
///
|
||||
/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。
|
||||
/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版,
|
||||
/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。
|
||||
pub const GOAL_MAX_CHARS: usize = 500;
|
||||
|
||||
/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。
|
||||
/// G1 多目标上限:最多累积目标数(默认 5)。
|
||||
///
|
||||
/// 防无限膨胀:每次发消息提取的目标追加到 pinned_goals vec,超上限时淘汰最早目标。
|
||||
pub const MAX_GOALS: usize = 5;
|
||||
|
||||
/// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。
|
||||
///
|
||||
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
|
||||
/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当
|
||||
@@ -240,6 +245,9 @@ pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
|
||||
mod guard;
|
||||
use guard::GeneratingGuard;
|
||||
|
||||
/// 审批超时取消(由 try_continue_agent_loop 入口调用)
|
||||
mod approval_timeout;
|
||||
|
||||
// ============================================================
|
||||
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
|
||||
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
|
||||
@@ -768,52 +776,75 @@ pub(crate) async fn run_agentic_loop(
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
|
||||
let mut last_reasoning_content: Option<String> = None;
|
||||
|
||||
// G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
|
||||
// G1 目标钉扎:入口把 PerConvState.pinned_goals 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
|
||||
//
|
||||
// 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request
|
||||
// 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非
|
||||
// messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。
|
||||
//
|
||||
// 单次 lock 读 pinned_goal clone(复用 L693-697 stop_flag 取用模式,同一 lock 块);Some 且非空 →
|
||||
// 截断到 GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+目标。GOAL_PIN_ENABLED=false → 整块跳过,
|
||||
// pinned_goal 永远 None(单点回退等价改动前)。拼接在 sys_tokens 估算前(sys_tokens 估算拼接后的 prompt)。
|
||||
// 单次 lock 读 pinned_goals clone(复用 stop_flag 取用模式,同一 lock 块);非空 → 逐条截断到
|
||||
// GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+编号列表。GOAL_PIN_ENABLED=false → 整块跳过,
|
||||
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
||||
//
|
||||
// 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入
|
||||
// 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。
|
||||
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
||||
let mut system_prompt = system_prompt;
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal_opt: Option<String> = {
|
||||
let goals: Vec<String> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.and_then(|c| c.pinned_goal.clone())
|
||||
.map(|c| c.pinned_goals.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if let Some(goal) = goal_opt {
|
||||
let goal_text = goal.trim();
|
||||
if !goal_text.is_empty() {
|
||||
let goal_text: String = goal_text.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let goal_text = if goal_text.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", goal_text)
|
||||
if !goals.is_empty() {
|
||||
let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
|
||||
let g = g.trim();
|
||||
let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
goal_text
|
||||
truncated
|
||||
};
|
||||
system_prompt = if GOAL_INJECT_BANNER {
|
||||
format!(
|
||||
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}",
|
||||
system_prompt, goal_text
|
||||
)
|
||||
} else {
|
||||
format!("{}\n\n{}", system_prompt, goal_text)
|
||||
};
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
chars = goal_text.chars().count(),
|
||||
"[ai] G1 目标钉扎:已把 pinned_goal 拼进 system_prompt"
|
||||
);
|
||||
}
|
||||
format!("{}. {}", i + 1, truncated)
|
||||
}).collect();
|
||||
let goals_text = goal_lines.join("\n");
|
||||
system_prompt = if GOAL_INJECT_BANNER {
|
||||
format!(
|
||||
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}",
|
||||
system_prompt, goals_text
|
||||
)
|
||||
} else {
|
||||
format!("{}\n\n{}", system_prompt, goals_text)
|
||||
};
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = goals.len(),
|
||||
first_goal = %goals.first().map(|g| &g[..std::cmp::min(120, g.len())]).unwrap_or(""),
|
||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||
goals.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// EnvSnapshot 环境感知:入口把当前平台的真实环境(OS/shell/工具版本)拼进 system_prompt 尾部。
|
||||
//
|
||||
// 治「LLM 跨平台命令幻觉」:LLM 训练数据 Unix 多,易生成 macOS/Linux 语法命令(PowerShell 5
|
||||
// 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码),把真实环境塞 prompt 即可锚定
|
||||
// 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样
|
||||
// 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)。
|
||||
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
|
||||
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
|
||||
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
||||
|
||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
|
||||
let pinned_goals_snapshot: Vec<String> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.map(|c| c.pinned_goals.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
@@ -845,7 +876,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
|
||||
// L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
@@ -853,6 +884,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -879,16 +911,11 @@ pub(crate) async fn run_agentic_loop(
|
||||
return;
|
||||
}
|
||||
let conv = session.conv(&conv_id);
|
||||
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在
|
||||
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goals 非空
|
||||
// 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。
|
||||
// 一次 lock 同读 pinned_goal(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
|
||||
// 一次 lock 同读 pinned_goals(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
|
||||
// user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。
|
||||
let goal_active = TOPIC_MARKER_GOAL_AWARE
|
||||
&& conv
|
||||
.pinned_goal
|
||||
.as_deref()
|
||||
.map(|g| !g.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let goal_active = TOPIC_MARKER_GOAL_AWARE && !conv.pinned_goals.is_empty();
|
||||
let marker = conv.messages.take_topic_marker();
|
||||
if goal_active && marker.is_some() {
|
||||
tracing::info!(
|
||||
@@ -1428,6 +1455,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
incomplete: Some(true),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
// L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
@@ -1436,6 +1464,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1496,7 +1525,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
|
||||
// L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
@@ -1504,6 +1533,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1664,16 +1694,21 @@ pub(crate) async fn run_agentic_loop(
|
||||
{
|
||||
stall_warned = true;
|
||||
let goal_text = if STALL_BREAKER_GOAL_REMIND {
|
||||
let g = session_arc
|
||||
let goals = session_arc
|
||||
.lock()
|
||||
.await
|
||||
.conv_read(&conv_id)
|
||||
.and_then(|c| c.pinned_goal.clone())
|
||||
.map(|c| c.pinned_goals.clone())
|
||||
.unwrap_or_default();
|
||||
if g.trim().is_empty() {
|
||||
if goals.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("(当前目标: {})", g.trim())
|
||||
let goal_summary = goals.iter()
|
||||
.map(|g| g.trim())
|
||||
.filter(|g| !g.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
format!("(当前目标: {})", goal_summary)
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
@@ -1804,7 +1839,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
|
||||
// L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内,
|
||||
// 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
@@ -1813,6 +1848,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1896,6 +1932,12 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
conv_id: &str,
|
||||
start_iteration: usize,
|
||||
) {
|
||||
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
|
||||
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
|
||||
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
|
||||
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
|
||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||
|
||||
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
|
||||
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
|
||||
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子
|
||||
@@ -1930,12 +1972,14 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
||||
ContinueSnapshot {
|
||||
is_generating,
|
||||
has_pending,
|
||||
pending_conv_id,
|
||||
agent_language,
|
||||
model_override,
|
||||
pinned_goals_snapshot,
|
||||
}
|
||||
};
|
||||
let should_continue = snap.is_generating && !snap.has_pending;
|
||||
@@ -1962,6 +2006,7 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
@@ -2053,6 +2098,7 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
@@ -2088,4 +2134,70 @@ struct ContinueSnapshot {
|
||||
pending_conv_id: Option<String>,
|
||||
agent_language: Option<String>,
|
||||
model_override: Option<String>,
|
||||
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
|
||||
pinned_goals_snapshot: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ============================================================
|
||||
// G2 is_empty_tool_result 单测
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_empty_string() {
|
||||
assert!(is_empty_tool_result(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_whitespace() {
|
||||
assert!(is_empty_tool_result(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_total_zero() {
|
||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_entries_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_matches_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"matches":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_results_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"results":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_files_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"files":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_chinese_no_match() {
|
||||
assert!(is_empty_tool_result("未找到相关文件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_english_no_match() {
|
||||
assert!(is_empty_tool_result("No matches found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result() {
|
||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result_with_content() {
|
||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt","b.txt"]}"#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ mod idea_source_test_helpers {
|
||||
use df_storage::crud::IdeaRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::IdeaRecord;
|
||||
use df_types::types::IdeaStatus;
|
||||
|
||||
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
|
||||
///
|
||||
@@ -155,7 +156,7 @@ mod idea_source_test_helpers {
|
||||
id: id.to_string(),
|
||||
title: format!("fixture-{}", id),
|
||||
description: String::new(),
|
||||
status: "draft".to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: 1,
|
||||
score: None,
|
||||
tags: None,
|
||||
|
||||
@@ -450,6 +450,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
@@ -506,11 +507,19 @@ pub(crate) async fn process_tool_calls(
|
||||
// low(默认):Low→auto, Med/High→审批(等价现状)
|
||||
// medium:Low/Med→auto, High→审批
|
||||
// all:全 auto(完全接管,无审批)
|
||||
let should_auto = match risk_level {
|
||||
let mut should_auto = match risk_level {
|
||||
RiskLevel::Low => true,
|
||||
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
||||
RiskLevel::High => auto_exec_mode == "all",
|
||||
};
|
||||
// C-260627: patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流
|
||||
if !should_auto && draft.name == "patch_file" {
|
||||
if let Some(text) = args.get("new_text").and_then(|v| v.as_str()) {
|
||||
if text.lines().count() <= 5 {
|
||||
should_auto = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if should_auto {
|
||||
low_risk.push((draft, args, risk_level));
|
||||
} else {
|
||||
@@ -623,6 +632,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
});
|
||||
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
|
||||
@@ -79,6 +79,9 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
|
||||
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
|
||||
retry_count: 0,
|
||||
// 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。
|
||||
// 超时取消计数仍从此刻起,避免恢复后立即被误判超时。
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -133,6 +136,7 @@ mod tests_f09_batch8_restore {
|
||||
kind: ApprovalKind::Risk { diff: None },
|
||||
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
|
||||
status,
|
||||
description,
|
||||
path,
|
||||
extra,
|
||||
..
|
||||
} => {
|
||||
out.push_str("【");
|
||||
@@ -85,6 +86,10 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
|
||||
out.push_str(description);
|
||||
out.push('\n');
|
||||
}
|
||||
for line in extra {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Augmentation::Task {
|
||||
@@ -92,6 +97,7 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
|
||||
status,
|
||||
description,
|
||||
project_name,
|
||||
extra,
|
||||
..
|
||||
} => {
|
||||
out.push_str("【");
|
||||
@@ -111,12 +117,17 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
|
||||
out.push_str(description);
|
||||
out.push('\n');
|
||||
}
|
||||
for line in extra {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Augmentation::Idea {
|
||||
title,
|
||||
status,
|
||||
description,
|
||||
extra,
|
||||
..
|
||||
} => {
|
||||
out.push_str("【");
|
||||
@@ -131,6 +142,10 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
|
||||
out.push_str(description);
|
||||
out.push('\n');
|
||||
}
|
||||
for line in extra {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Augmentation::Skill {
|
||||
|
||||
@@ -17,6 +17,7 @@ use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_types::augmentation::{Augmentation, MentionRef, ResolveError, SanitizedPath};
|
||||
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus};
|
||||
use df_storage::crud::{IdeaQuery, TaskQuery};
|
||||
|
||||
use crate::commands::ai::augmentation::registry::ResolverRegistry;
|
||||
use crate::commands::ai::augmentation::sanitize::{sanitize_for, ProviderLocality};
|
||||
@@ -87,7 +88,7 @@ impl MentionResolver for ProjectResolver {
|
||||
ref_id: id.clone(),
|
||||
})?;
|
||||
// status: String → ProjectStatus(未知字符串按 Planning 兜底,不阻断注入)
|
||||
let status = ProjectStatus::from_db_str(&record.status).unwrap_or(ProjectStatus::Planning);
|
||||
let status = ProjectStatus::from_db_str(record.status.as_str()).unwrap_or(ProjectStatus::Planning);
|
||||
// path 脱敏:None(未绑定)保持 None;Some 则按 locality 脱敏包 newtype
|
||||
let path = record
|
||||
.path
|
||||
@@ -100,6 +101,43 @@ impl MentionResolver for ProjectResolver {
|
||||
status,
|
||||
description: record.description,
|
||||
path,
|
||||
extra: {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
// Phase 4:查询该项目的前 5 条未完成任务
|
||||
let task_repo = TaskRepo::new(&self.db);
|
||||
if let Ok(tasks) = task_repo.list_by_query(&TaskQuery {
|
||||
project_id: Some(id.clone()),
|
||||
status: Some("in_progress".into()),
|
||||
limit: Some(5),
|
||||
..Default::default()
|
||||
}).await {
|
||||
if !tasks.is_empty() {
|
||||
let mut task_lines: Vec<String> = tasks.iter().map(|t| {
|
||||
format!(" - {} ({})", t.title, t.status.as_str())
|
||||
}).collect();
|
||||
task_lines.insert(0, format!("进行中任务({}):", tasks.len()));
|
||||
lines.push(task_lines.join("\n"));
|
||||
}
|
||||
}
|
||||
// 待评估灵感
|
||||
let idea_repo = IdeaRepo::new(&self.db);
|
||||
if let Ok(ideas) = idea_repo.list_by_query(&IdeaQuery {
|
||||
limit: Some(3),
|
||||
..Default::default()
|
||||
}).await {
|
||||
let related: Vec<_> = ideas.iter()
|
||||
.filter(|i| i.status.as_str() == "pending_review")
|
||||
.take(3)
|
||||
.map(|i| format!(" - {} ({})", i.title, i.status.as_str()))
|
||||
.collect();
|
||||
if !related.is_empty() {
|
||||
let mut idea_lines = vec![format!("待评估灵感({}):", related.len())];
|
||||
idea_lines.extend(related);
|
||||
lines.push(idea_lines.join("\n"));
|
||||
}
|
||||
}
|
||||
lines
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -151,7 +189,7 @@ impl MentionResolver for TaskResolver {
|
||||
kind: "task".to_string(),
|
||||
ref_id: id.clone(),
|
||||
})?;
|
||||
let status = TaskStatus::from_db_str(&task.status).unwrap_or(TaskStatus::Todo);
|
||||
let status = TaskStatus::from_db_str(task.status.as_str()).unwrap_or(TaskStatus::Todo);
|
||||
// join project_name:project_id 取 ProjectRecord.name;失败/无对应 None(非错误)
|
||||
let project_name = {
|
||||
let project_repo = ProjectRepo::new(&self.db);
|
||||
@@ -166,6 +204,7 @@ impl MentionResolver for TaskResolver {
|
||||
status,
|
||||
description: task.description,
|
||||
project_name,
|
||||
extra: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -214,12 +253,13 @@ impl MentionResolver for IdeaResolver {
|
||||
kind: "idea".to_string(),
|
||||
ref_id: id.clone(),
|
||||
})?;
|
||||
let status = IdeaStatus::from_db_str(&record.status).unwrap_or(IdeaStatus::Draft);
|
||||
let status = IdeaStatus::from_db_str(record.status.as_str()).unwrap_or(IdeaStatus::Draft);
|
||||
Ok(Augmentation::Idea {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
status,
|
||||
description: record.description,
|
||||
extra: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ const DEFINITION_KINDS: &[&str] = &[
|
||||
"interface_declaration",
|
||||
"enum_declaration",
|
||||
"method_definition",
|
||||
// TS/JS const/let/var 声明(如 const fn = () => {})
|
||||
"variable_declarator",
|
||||
// Go
|
||||
"method_declaration", // Go method(receiver func)
|
||||
"type_declaration", // Go type Xxx struct/interface/func
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::state::AppState;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED};
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED, MAX_GOALS};
|
||||
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
|
||||
use super::super::agentic::conv_state::ConvState;
|
||||
use super::super::audit::{audit_finalize, emit_data_changed};
|
||||
@@ -72,10 +72,10 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
|
||||
}
|
||||
}
|
||||
|
||||
/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。
|
||||
/// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active),
|
||||
/// 提取后存 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。
|
||||
/// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标,
|
||||
/// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。
|
||||
///
|
||||
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
|
||||
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
|
||||
@@ -84,7 +84,7 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
|
||||
/// 2. trim 空白。
|
||||
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
|
||||
///
|
||||
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。
|
||||
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才追加到 pinned_goals(避免空目标注入)。
|
||||
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
|
||||
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
||||
@@ -463,12 +463,15 @@ pub async fn ai_chat_send(
|
||||
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
||||
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新。
|
||||
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,支持累积多目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
(target, user_msg_id)
|
||||
@@ -526,7 +529,6 @@ pub async fn ai_approve(
|
||||
tool_call_id: String,
|
||||
approved: bool,
|
||||
) -> Result<String, String> {
|
||||
authz_debug(&format!("[ai_approve] 入口 tool_call_id={} approved={}", tool_call_id, approved));
|
||||
let mut session = state.ai_session.lock().await;
|
||||
|
||||
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
||||
@@ -658,7 +660,12 @@ pub async fn ai_approve(
|
||||
state.ai_tools.execute(&approval.tool_name, args.clone()),
|
||||
).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)),
|
||||
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
|
||||
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
|
||||
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name,
|
||||
if approval.tool_name == "run_command" && cfg!(windows) {
|
||||
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
|
||||
} else { "" })),
|
||||
}
|
||||
};
|
||||
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
|
||||
@@ -753,13 +760,7 @@ pub async fn ai_approve(
|
||||
|
||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
|
||||
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
||||
fn authz_debug(msg: &str) {
|
||||
use std::io::Write;
|
||||
let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
|
||||
///
|
||||
@@ -849,7 +850,6 @@ pub async fn ai_authorize_dir(
|
||||
decision = %decision,
|
||||
"[AI-AUTHZ] ai_authorize_dir 入口"
|
||||
);
|
||||
authz_debug(&format!("[ai_authorize_dir] 入口 tool_call_id={} decision={}", tool_call_id, decision));
|
||||
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
|
||||
let approval = {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
@@ -1362,8 +1362,11 @@ pub async fn ai_chat_edit(
|
||||
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&new_message);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1461,7 +1464,7 @@ pub async fn ai_chat_force_send(
|
||||
// F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
|
||||
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
|
||||
// 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。
|
||||
let (old_conv_id, conv_id, _user_message_id) = {
|
||||
let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active → 懒创建。
|
||||
let target = conversation_id.clone().filter(|s| !s.is_empty())
|
||||
@@ -1531,16 +1534,18 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
|
||||
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
|
||||
// pinned_goal 已更新,续跑用新目标)。
|
||||
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,与 send/edit 路径一致,支持累积多目标)。
|
||||
// force_send 是用户新指令,审批续跑会复用已累积目标。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id)
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
|
||||
};
|
||||
|
||||
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
|
||||
@@ -1551,6 +1556,7 @@ pub async fn ai_chat_force_send(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(cid.clone()),
|
||||
pinned_goals: old_pinned_goals.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
@@ -1649,10 +1655,12 @@ pub async fn ai_chat_stop(
|
||||
),
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
|
||||
conversation_id: Some(target),
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
@@ -1701,6 +1709,7 @@ pub async fn ai_chat_stop(
|
||||
"[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
}
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
@@ -1708,6 +1717,7 @@ pub async fn ai_chat_stop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: conv_id,
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
|
||||
@@ -1772,6 +1782,7 @@ pub async fn ai_stop_loop(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
let pinned_goals: Vec<String>;
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
@@ -1794,6 +1805,7 @@ pub async fn ai_stop_loop(
|
||||
"[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
}
|
||||
pinned_goals = conv.pinned_goals.clone();
|
||||
}
|
||||
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
@@ -1802,9 +1814,74 @@ pub async fn ai_stop_loop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conversation_id),
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ============================================================
|
||||
// G1 extract_pinned_goal 单测
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_extract_plain_goal() {
|
||||
let goal = extract_pinned_goal("实现代码审查功能");
|
||||
assert_eq!(goal, "实现代码审查功能");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_strip_mention_suffix() {
|
||||
let goal = extract_pinned_goal("分析灵感模块不足 [项目: DevFlow]");
|
||||
assert_eq!(goal, "分析灵感模块不足");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_strip_mention_prefix() {
|
||||
let goal = extract_pinned_goal("[任务: T-123] 修复登录页白屏");
|
||||
assert_eq!(goal, "修复登录页白屏");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_empty_input() {
|
||||
let goal = extract_pinned_goal("");
|
||||
assert!(goal.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_only_mention() {
|
||||
let goal = extract_pinned_goal("[项目: DevFlow]");
|
||||
assert!(goal.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_english_kind() {
|
||||
let goal = extract_pinned_goal("Fix login bug [task: T-789]");
|
||||
assert_eq!(goal, "Fix login bug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_fullwidth_colon() {
|
||||
let goal = extract_pinned_goal("重构模块 [项目:DevFlow]");
|
||||
assert_eq!(goal, "重构模块");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_truncate_long() {
|
||||
let long = "a".repeat(GOAL_MAX_CHARS + 100);
|
||||
let goal = extract_pinned_goal(&long);
|
||||
assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_mention_unclosed() {
|
||||
let goal = extract_pinned_goal("测试 [项目: DevFlow");
|
||||
assert_eq!(goal, "测试 [项目: DevFlow");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,12 +221,17 @@ pub async fn ai_conversation_list(
|
||||
let models: Vec<String> = r.models.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let pinned_goals: Vec<String> = r.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"id": r.id,
|
||||
"title": r.title,
|
||||
"provider_id": r.provider_id,
|
||||
"model": r.model,
|
||||
"models": models,
|
||||
"pinned_goals": pinned_goals,
|
||||
"archived": r.archived,
|
||||
"pinned": r.pinned,
|
||||
"prompt_tokens": r.prompt_tokens,
|
||||
@@ -256,10 +261,10 @@ pub async fn ai_conversation_switch(
|
||||
// fallback 兜底:ai_messages 表为空(返空 Vec)但旧 messages JSON 列非空 `[]`
|
||||
// (老库未迁移 / 坏数据 / 批次 B 写路径尚未上线时的新对话)→ 回退读 messages JSON + warn。
|
||||
// 双向兼容:批次 B 上线后写双轨,读永远先走 ai_messages;迁移未跑的老对话走 fallback。
|
||||
let records = state.ai_messages.list_by_conversation(&conversation_id).await
|
||||
let all_records = state.ai_messages.list_by_conversation(&conversation_id).await
|
||||
.map_err(err_str)?;
|
||||
let messages: Vec<ChatMessage> = if !records.is_empty() {
|
||||
records.iter().map(record_to_message).collect()
|
||||
let messages: Vec<ChatMessage> = if !all_records.is_empty() {
|
||||
all_records.iter().map(record_to_message).collect()
|
||||
} else {
|
||||
// 表空 → fallback 旧 messages JSON 列(若也空则空 Vec,空对话合法)
|
||||
let has_legacy = record.messages != "[]" && !record.messages.is_empty();
|
||||
@@ -276,9 +281,25 @@ pub async fn ai_conversation_switch(
|
||||
}
|
||||
};
|
||||
|
||||
// 由 records/chat_messages 重序列化返回前端(前端契约不变,仍吃 JSON 字符串)。
|
||||
let messages_json = serde_json::to_string(&messages)
|
||||
// 后端 per_conv 存全量消息(LLM 上下文用),前端只渲染最近 N 条(分页懒加载,治长对话卡顿)。
|
||||
// PAGE_SIZE:首屏渲染条数,超出的历史消息由前端滚顶加载更多(ai_conversation_load_more IPC)。
|
||||
const PAGE_SIZE: usize = 50;
|
||||
let total_count = messages.len();
|
||||
let render_messages: Vec<ChatMessage> = if total_count > PAGE_SIZE {
|
||||
messages[total_count - PAGE_SIZE..].to_vec()
|
||||
} else {
|
||||
messages.clone()
|
||||
};
|
||||
let messages_json = serde_json::to_string(&render_messages)
|
||||
.map_err(|e| format!("序列化消息失败: {}", e))?;
|
||||
let has_more = total_count > PAGE_SIZE;
|
||||
let earliest_seq = if !all_records.is_empty() {
|
||||
// 返回首屏最早消息的 seq,前端滚顶加载时传此值作游标
|
||||
let page_start = total_count.saturating_sub(PAGE_SIZE);
|
||||
Some(all_records[page_start].seq)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let title = record.title.clone();
|
||||
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。
|
||||
// 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过,
|
||||
@@ -311,6 +332,12 @@ pub async fn ai_conversation_switch(
|
||||
conv.agent_language = None;
|
||||
conv.iteration_used = 0;
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||
let pinned_goals: Vec<String> = record.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
conv.pinned_goals = pinned_goals;
|
||||
}
|
||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
||||
@@ -321,6 +348,29 @@ pub async fn ai_conversation_switch(
|
||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||
|
||||
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
||||
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
||||
if let Some(pending_json) = &record.pending_approvals {
|
||||
if pending_json != "{}" && !pending_json.is_empty() {
|
||||
if let Ok(restored) = serde_json::from_str::<
|
||||
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
||||
>(pending_json) {
|
||||
let restored_count = restored.len();
|
||||
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
||||
session.pending_approvals.extend(restored);
|
||||
tracing::info!(
|
||||
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列)",
|
||||
conversation_id, restored_count
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
||||
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
||||
drop(session);
|
||||
|
||||
@@ -349,6 +399,35 @@ pub async fn ai_conversation_switch(
|
||||
"id": record.id,
|
||||
"title": title,
|
||||
"messages": messages_json,
|
||||
"has_more": has_more,
|
||||
"earliest_seq": earliest_seq,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 加载更多历史消息(分页懒加载,滚顶触发)。
|
||||
///
|
||||
/// 前端 switchConversation 首次拿最近 50 条 + has_more=true + earliest_seq。
|
||||
/// 滚顶时传 earliest_seq 调本命令,返回更早的 50 条 + 新 has_more + 新 earliest_seq。
|
||||
/// per_conv 内存不受影响(后端始终持全量消息供 LLM 上下文用,分页仅影响前端渲染)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_conversation_load_more(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
before_seq: i64,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
const PAGE_SIZE: usize = 50;
|
||||
let records = state.ai_messages.list_recent(&conversation_id, PAGE_SIZE, Some(before_seq))
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
let has_more = records.len() == PAGE_SIZE;
|
||||
let new_earliest_seq = records.first().map(|r| r.seq);
|
||||
let messages: Vec<ChatMessage> = records.iter().map(record_to_message).collect();
|
||||
let messages_json = serde_json::to_string(&messages)
|
||||
.map_err(|e| format!("序列化消息失败: {}", e))?;
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages_json,
|
||||
"has_more": has_more,
|
||||
"earliest_seq": new_earliest_seq,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -439,6 +518,30 @@ pub async fn ai_conversation_set_pinned(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)
|
||||
///
|
||||
/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。
|
||||
/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。
|
||||
#[tauri::command]
|
||||
pub async fn ai_update_conversation_goals(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
goals: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||
// 写 DB
|
||||
state.ai_conversations
|
||||
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
// 同步内存 per_conv(若已加载)
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
||||
conv.pinned_goals = goals;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出对话为指定格式(UX-18:对话导出)
|
||||
///
|
||||
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(crate) async fn save_conversation(
|
||||
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
|
||||
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
|
||||
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
|
||||
let (persist_msgs, provider_id, created_at) = {
|
||||
let (persist_msgs, provider_id, created_at, pinned_goals) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
||||
for m in &mut msgs {
|
||||
@@ -185,9 +185,22 @@ pub(crate) async fn save_conversation(
|
||||
msgs,
|
||||
session.active_provider_id.clone(),
|
||||
session.active_conv_created_at.clone(),
|
||||
session.conv(conv_id).pinned_goals.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
|
||||
// 序列化为 JSON 对象(tool_call_id → PendingApproval),落库供重启/切回时恢复。
|
||||
let pending_approvals_json = {
|
||||
let session = session_arc.lock().await;
|
||||
let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session.pending_approvals
|
||||
.iter()
|
||||
.filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id))
|
||||
.collect();
|
||||
serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string())
|
||||
};
|
||||
|
||||
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
|
||||
// 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
|
||||
// created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
|
||||
@@ -224,6 +237,10 @@ pub(crate) async fn save_conversation(
|
||||
if !list.iter().any(|x| x == m) { list.push(m.to_string()); }
|
||||
rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string()));
|
||||
}
|
||||
// G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB
|
||||
rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
|
||||
// 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
|
||||
rec.pending_approvals = Some(pending_approvals_json.clone());
|
||||
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
|
||||
if let Err(e) = conv_repo.update_full(&rec).await {
|
||||
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
|
||||
@@ -250,6 +267,8 @@ pub(crate) async fn save_conversation(
|
||||
pinned: false,
|
||||
prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
|
||||
completion_tokens: usage.map(|u| u.completion_tokens as i64),
|
||||
pinned_goals: Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())),
|
||||
pending_approvals: Some(pending_approvals_json.clone()),
|
||||
created_at: conv_created.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -164,8 +164,16 @@ impl EventBus {
|
||||
/// 序列化为 Value 后调 [`publish`](Self::publish)。供 emit 点双写(publish + app.emit),
|
||||
/// 经 EventBus 透传给 tunnel subscriber(阶段2 接入)。
|
||||
///
|
||||
/// 序列化失败兜底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。
|
||||
/// 性能优化(治空转):无订阅者时跳过序列化直接返 0。当前 tunnel subscriber 尚未接入,
|
||||
/// 20+ emit 点双写调本方法但无消费者,跳过序列化消除热路径开销。接入消费者后自动生效。
|
||||
///
|
||||
/// 序列化失败兑底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。
|
||||
pub fn publish_event(&self, event: AiChatEvent) -> usize {
|
||||
// 无订阅者时跳过序列化(治空转开销)。broadcast::receiver_count 是同步原子读,代价极低。
|
||||
// 有订阅者后才做 serde_json::to_value 序列化 + send。
|
||||
if self.sender.receiver_count() == 0 {
|
||||
return 0;
|
||||
}
|
||||
match serde_json::to_value(&event) {
|
||||
Ok(v) => self.publish(v),
|
||||
Err(_) => 0,
|
||||
|
||||
@@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
||||
use df_ai::router::{
|
||||
select_model_id, Modality, TaskRequirements,
|
||||
};
|
||||
use df_storage::crud::{AiConversationRepo, KnowledgeRepo};
|
||||
use df_storage::crud::{AiConversationRepo, AiMessageRepo, KnowledgeRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{AiProviderRecord, KnowledgeRecord};
|
||||
|
||||
@@ -566,12 +566,21 @@ async fn extract_knowledge_from_conversation(
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.unwrap_or_else(|| "未命名对话".to_string());
|
||||
|
||||
let messages: Vec<ChatMessage> = match serde_json::from_str(&conv.messages) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[ai] 对话消息 JSON 解析失败,降级空消息(知识提炼跳过)");
|
||||
Vec::new()
|
||||
// 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
|
||||
let msg_repo = AiMessageRepo::new(db);
|
||||
let records = msg_repo.list_by_conversation(conv_id).await.unwrap_or_default();
|
||||
let messages: Vec<ChatMessage> = if !records.is_empty() {
|
||||
records.iter().map(crate::commands::ai::commands::record_to_message).collect()
|
||||
} else {
|
||||
// 老库未迁移或坏数据:回退旧 messages JSON 列(与 ai_conversation_switch 同一兼容路径)
|
||||
let has_legacy = conv.messages != "[]" && !conv.messages.is_empty();
|
||||
if has_legacy {
|
||||
tracing::warn!(
|
||||
conv_id,
|
||||
"[KNOWLEDGE-EXTRACT] ai_messages 表为空,回退旧 messages JSON 列(老库兼容)"
|
||||
);
|
||||
}
|
||||
serde_json::from_str(&conv.messages).unwrap_or_default()
|
||||
};
|
||||
// 过滤 user/assistant,取最后 6 条
|
||||
let recent: Vec<&ChatMessage> = messages
|
||||
|
||||
@@ -138,6 +138,8 @@ pub enum AiChatEvent {
|
||||
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
|
||||
incomplete: Option<bool>,
|
||||
conversation_id: Option<String>,
|
||||
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
|
||||
pinned_goals: Vec<String>,
|
||||
},
|
||||
/// 错误
|
||||
///
|
||||
@@ -437,24 +439,6 @@ fn normalize_dir_key(dir: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// workspace_root 字符串(作 run_command working_dir 缺省时的 trust key 回退)。
|
||||
///
|
||||
/// 与 tool_registry.rs workspace_root() 同源(CARGO_MANIFEST_DIR 上两级),canonicalize 后
|
||||
/// 保证与 run_command handler 默认 working_dir = workspace_root().to_string() 生成的 key 一致。
|
||||
fn workspace_root_str() -> String {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
root.canonicalize()
|
||||
.map(|c| c.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| root.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
// 引入 PathBuf 供 workspace_root_str / dir_of_path_normalized 使用
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// AI 会话内状态(Mutex 保护)
|
||||
///
|
||||
/// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv)
|
||||
@@ -776,17 +760,17 @@ pub struct PerConvState {
|
||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
||||
pub conv_state: ConvState,
|
||||
/// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。
|
||||
/// G1 目标钉扎真相源:用户消息中提取的目标列表(内容态字段,与生命周期态正交)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
||||
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。
|
||||
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
|
||||
/// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
||||
///
|
||||
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
|
||||
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goal: Option<String>,
|
||||
/// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标);
|
||||
/// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goals: Vec<String>,
|
||||
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||
@@ -833,12 +817,12 @@ impl PerConvState {
|
||||
/// - session_trust: HashSet::new()
|
||||
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
|
||||
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
|
||||
/// - pinned_goal: None(G1 目标钉扎,新会话未提取目标)
|
||||
/// - pinned_goals: Vec::new()(G1 目标钉扎,新会话未提取目标)
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: ContextManager::new(ContextConfig::default()),
|
||||
conv_state: ConvState::Idle,
|
||||
pinned_goal: None,
|
||||
pinned_goals: Vec::new(),
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
notify: Arc::new(tokio::sync::Notify::new()),
|
||||
iteration_used: 0,
|
||||
@@ -863,7 +847,7 @@ impl Default for PerConvState {
|
||||
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
|
||||
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
|
||||
/// 各只消费自己 kind,入口校验防误调)。
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingApproval {
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
@@ -871,6 +855,10 @@ pub struct PendingApproval {
|
||||
pub conversation_id: Option<String>,
|
||||
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
|
||||
pub recovered: bool,
|
||||
/// 审批创建时间(用于超时取消判定)。运行期内存态,不序列化(重启恢复的积压审批按恢复时间计)。
|
||||
/// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。
|
||||
#[serde(default)]
|
||||
pub created_at: Option<std::time::SystemTime>,
|
||||
/// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。
|
||||
/// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。
|
||||
pub kind: ApprovalKind,
|
||||
@@ -896,7 +884,7 @@ pub struct PendingApproval {
|
||||
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
|
||||
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
|
||||
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ApprovalKind {
|
||||
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
|
||||
Path(PathAuthRequest),
|
||||
@@ -907,7 +895,7 @@ pub enum ApprovalKind {
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PathAuthRequest {
|
||||
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
|
||||
/// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to
|
||||
|
||||
@@ -162,7 +162,10 @@ pub(crate) async fn build_system_prompt_with_excluded(
|
||||
if excluded_project_ids.iter().any(|id| id == &p.id) {
|
||||
continue;
|
||||
}
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description));
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status.as_str(), p.description));
|
||||
if let Some(ref dir) = p.path {
|
||||
prompt.push_str(&format!(" 目录: {}\n", dir));
|
||||
}
|
||||
}
|
||||
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
|
||||
prompt.push_str(&projects_listed_note(lang, 20));
|
||||
@@ -178,7 +181,7 @@ pub(crate) async fn build_system_prompt_with_excluded(
|
||||
if excluded_task_ids.iter().any(|id| id == &tk.id) {
|
||||
continue;
|
||||
}
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description));
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status.as_str(), tk.description));
|
||||
}
|
||||
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
|
||||
prompt.push_str(&tasks_listed_note(lang));
|
||||
|
||||
@@ -76,6 +76,12 @@ pub(crate) async fn ensure_conversation_title(
|
||||
return;
|
||||
}
|
||||
|
||||
// 任务3: 合并相邻同 role 消息(防 Anthropic 协议 1214 错误)。
|
||||
// session.messages 历史可能存在连续同 role(如 user 失败重提、助手多轮中断后续接、tool_result 被过滤后
|
||||
// user 连续),Anthropic API 严格校验「messages 必须 user/assistant 交替」,连续同 role 直接拒 1214。
|
||||
// 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。
|
||||
let summary_msgs = merge_consecutive_roles(summary_msgs);
|
||||
|
||||
// B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。
|
||||
// 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住
|
||||
// (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台
|
||||
@@ -208,3 +214,36 @@ pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
|
||||
if chars.next().is_some() { format!("{}...", t) } else { t }
|
||||
})
|
||||
}
|
||||
|
||||
/// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。
|
||||
/// role 比较: System/User/Assistant/Tool 中,User 和 Assistant 交替校验最严,
|
||||
/// 不同 role 不合并(保留边界),仅合并连续同 role。
|
||||
fn merge_consecutive_roles(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
if msgs.is_empty() {
|
||||
return msgs;
|
||||
}
|
||||
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
|
||||
for m in msgs {
|
||||
if let Some(last) = out.last_mut() {
|
||||
// role 比较(MessageRole 未派生 PartialEq,用 matches!按变体逐个比)。
|
||||
let same_role = match (&last.role, &m.role) {
|
||||
(MessageRole::System, MessageRole::System)
|
||||
| (MessageRole::User, MessageRole::User)
|
||||
| (MessageRole::Assistant, MessageRole::Assistant)
|
||||
| (MessageRole::Tool, MessageRole::Tool) => true,
|
||||
_ => false,
|
||||
};
|
||||
if same_role {
|
||||
// 连续同 role: 拼接 content(中间补换行,避免语义粘连),保留首条元数据(id/timestamp)。
|
||||
// 不拼接 reasoning_content(标题摘要场景该字段无用,且不同 turn reasoning 串一起无意义)。
|
||||
if !m.content.is_empty() {
|
||||
last.content.push('\n');
|
||||
last.content.push_str(&m.content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use df_execute::shell::{execute, ShellRequest};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord};
|
||||
|
||||
use df_types::types::new_id;
|
||||
use df_types::types::{new_id, IdeaStatus, ProjectStatus, TaskStatus};
|
||||
|
||||
use crate::commands::now_millis;
|
||||
use crate::state::AllowedDirs;
|
||||
@@ -515,6 +515,7 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
register_workflow_tools(registry);
|
||||
register_idea_tools(registry, db);
|
||||
register_trash_tools(registry, db);
|
||||
register_git_tools(registry, db);
|
||||
}
|
||||
|
||||
/// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc<Database>。
|
||||
@@ -573,7 +574,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let record = ProjectRecord {
|
||||
id: new_id(), name: name.to_string(), description: description.to_string(),
|
||||
status: "planning".to_string(), idea_id: None,
|
||||
status: ProjectStatus::Planning, idea_id: None,
|
||||
path: None, stack: None,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
@@ -683,7 +684,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
};
|
||||
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
|
||||
if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
|
||||
tasks.retain(|t| t.status == status);
|
||||
tasks.retain(|t| t.status.as_str() == status);
|
||||
}
|
||||
let total = tasks.len();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
@@ -767,7 +768,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
|
||||
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
|
||||
status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32,
|
||||
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
@@ -1032,13 +1033,13 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.clone()
|
||||
current.status.as_str().to_string()
|
||||
} else {
|
||||
"in_progress".to_string()
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(),
|
||||
"decision" => current.status.clone(),
|
||||
"decision" => current.status.as_str().to_string(),
|
||||
_ => unreachable!("queue 白名单已收口"),
|
||||
};
|
||||
|
||||
@@ -1047,7 +1048,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
repo.update_field(id, "queue", &new_queue).await?;
|
||||
}
|
||||
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径)
|
||||
if current.status != new_status {
|
||||
if current.status.as_str() != new_status {
|
||||
repo.set_status_for_aggregation(id, &new_status).await?;
|
||||
}
|
||||
|
||||
@@ -1270,6 +1271,205 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── list_project_modules (Low 只读,工程系统) ──
|
||||
// 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。
|
||||
// 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。
|
||||
registry.register(
|
||||
"list_project_modules", "查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let modules = repo.list_by_project(&project_id).await?;
|
||||
let total = modules.len();
|
||||
Ok(serde_json::json!({
|
||||
"modules": modules,
|
||||
"total": total,
|
||||
"project_id": project_id,
|
||||
}))
|
||||
})
|
||||
})},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Git 只读 AI 工具(工程系统,2026-06-29)
|
||||
// ============================================================
|
||||
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败返回空字符串(非崩溃)。
|
||||
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
|
||||
let dir = working_dir.to_string();
|
||||
let args_vec: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(&args_vec).current_dir(&dir);
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null());
|
||||
match cmd.output() {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
result.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// git status --porcelain 解析为结构化文件列表。
|
||||
/// 返回 (当前分支, 改动文件列表 [{path, status}])
|
||||
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
|
||||
let branch = exec_git(working_dir, &["branch", "--show-current"]).await;
|
||||
let branch = branch.trim().to_string();
|
||||
let raw = exec_git(working_dir, &["status", "--porcelain"]).await;
|
||||
let files: Vec<serde_json::Value> = raw
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|line| {
|
||||
// porcelain 格式:"XY filename",XY 为两位状态码
|
||||
let status = line.chars().take(2).collect::<String>();
|
||||
let path = line.get(3..).unwrap_or("").trim().to_string();
|
||||
// 简化状态码:M/A/D/??/R
|
||||
let simple = if status.starts_with("??") { "??" }
|
||||
else if status.contains('A') { "A" }
|
||||
else if status.contains('D') { "D" }
|
||||
else if status.contains('R') { "R" }
|
||||
else { "M" };
|
||||
serde_json::json!({ "path": path, "status": simple })
|
||||
})
|
||||
.collect();
|
||||
(branch, files)
|
||||
}
|
||||
|
||||
/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。
|
||||
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
|
||||
let mut args = vec!["diff", "--stat"];
|
||||
if staged { args.push("--cached"); }
|
||||
let stat_raw = exec_git(working_dir, &args).await;
|
||||
|
||||
// 每文件 patch(截断防 token 爆)
|
||||
let mut patch_args = vec!["diff" ];
|
||||
if staged { patch_args.push("--cached"); }
|
||||
let patch_raw = exec_git(working_dir, &patch_args).await;
|
||||
// 截断到 8000 字符(防大体量 diff)
|
||||
let patch_truncated = if patch_raw.len() > 8000 {
|
||||
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
|
||||
} else {
|
||||
patch_raw
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"stat": stat_raw,
|
||||
"patch": patch_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// git log 解析为结构化提交列表。
|
||||
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
|
||||
let format = "%H|%an|%ad|%s";
|
||||
let limit_str = format!("-{}", limit);
|
||||
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await;
|
||||
raw.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
Some(serde_json::json!({
|
||||
"hash": parts[0],
|
||||
"author": parts[1],
|
||||
"date": parts[2],
|
||||
"message": parts[3],
|
||||
}))
|
||||
} else { None }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Git 只读 AI 工具注册(3 个:status/diff/log,均 Low 风险自动执行)。
|
||||
///
|
||||
/// AI 据此自主查看工程代码仓库状态,无需用户审批。均在工程目录(module.path)执行,
|
||||
/// 无 .git 返回空状态。
|
||||
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
// ── git_status (Low 只读) ──
|
||||
registry.register(
|
||||
"git_status", "查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let (branch, files) = run_git_status(&module.path).await;
|
||||
Ok(serde_json::json!({
|
||||
"branch": branch,
|
||||
"files": files,
|
||||
"total_changes": files.len(),
|
||||
}))
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── git_diff (Low 只读) ──
|
||||
registry.register(
|
||||
"git_diff", "查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("staged", "boolean", false),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let diff = run_git_diff(&module.path, staged).await;
|
||||
Ok(diff)
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── git_log (Low 只读) ──
|
||||
registry.register(
|
||||
"git_log", "查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("limit", "integer", false),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize;
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let commits = run_git_log(&module.path, limit).await;
|
||||
Ok(serde_json::json!({ "commits": commits }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
}
|
||||
|
||||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||||
@@ -1354,7 +1554,7 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
id: new_id(), title: title.to_string(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
|
||||
status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32,
|
||||
status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32,
|
||||
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
|
||||
source: args["source"].as_str().map(|s| s.to_string()),
|
||||
promoted_to: None, ai_analysis: None, scores: None,
|
||||
@@ -1549,18 +1749,19 @@ fn register_file_tools(
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
|
||||
RiskLevel::Low,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let allowed_dirs = allowed_dirs.clone();
|
||||
Box::pin(async move {
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
let resolved = resolve_workspace_path_with_allowed(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
&snap,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
|
||||
RiskLevel::Low,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let allowed_dirs = allowed_dirs.clone();
|
||||
Box::pin(async move {
|
||||
let p = args["path"].as_str().unwrap_or("");
|
||||
if p.is_empty() {
|
||||
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
|
||||
}
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||||
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
|
||||
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
|
||||
@@ -1693,7 +1894,7 @@ fn register_file_tools(
|
||||
})
|
||||
};
|
||||
registry.register(
|
||||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
|
||||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。小改动(≤5 行)自动放行不阻塞,大改动需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
|
||||
patch_file_schema,
|
||||
RiskLevel::Medium,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
@@ -2128,7 +2329,7 @@ fn register_file_tools(
|
||||
let grep_schema = {
|
||||
let mut props = serde_json::Map::new();
|
||||
props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" }));
|
||||
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。必填" }));
|
||||
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。不传时引导用户指定" }));
|
||||
props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" }));
|
||||
props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] }));
|
||||
props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" }));
|
||||
@@ -2138,21 +2339,22 @@ fn register_file_tools(
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": ["pattern", "path"],
|
||||
"required": ["pattern"],
|
||||
})
|
||||
};
|
||||
registry.register(
|
||||
"grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,文件或目录均可——传单文件仅搜该文件,锚 workspace + path_auth 授权)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
||||
"grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
||||
grep_schema,
|
||||
RiskLevel::Low,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let allowed_dirs = allowed_dirs.clone();
|
||||
Box::pin(async move {
|
||||
let p = args["path"].as_str().unwrap_or("");
|
||||
if p.is_empty() {
|
||||
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
|
||||
}
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
let resolved = resolve_workspace_path_with_allowed(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
&snap,
|
||||
)?;
|
||||
let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
|
||||
let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let pattern = args["pattern"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||||
@@ -2273,16 +2475,17 @@ fn register_file_tools(
|
||||
// ── 文件搜索 (Low risk) ──
|
||||
registry.register(
|
||||
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
RiskLevel::Low,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let allowed_dirs = allowed_dirs.clone();
|
||||
Box::pin(async move {
|
||||
let p = args["path"].as_str().unwrap_or("");
|
||||
if p.is_empty() {
|
||||
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
|
||||
}
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
let resolved = resolve_workspace_path_with_allowed(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
&snap,
|
||||
)?;
|
||||
let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||||
@@ -2313,8 +2516,13 @@ fn register_file_tools(
|
||||
]),
|
||||
RiskLevel::High,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
// BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致
|
||||
// JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str()
|
||||
// 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸
|
||||
// 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。
|
||||
let command = args["command"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
|
||||
.to_string();
|
||||
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
||||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
@@ -2329,7 +2537,7 @@ fn register_file_tools(
|
||||
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
|
||||
|
||||
let request = ShellRequest {
|
||||
command: command.to_string(),
|
||||
command: command.clone(),
|
||||
working_dir: Some(working_dir.clone()),
|
||||
env: HashMap::new(),
|
||||
timeout_secs: Some(timeout_secs),
|
||||
@@ -2341,15 +2549,24 @@ fn register_file_tools(
|
||||
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
|
||||
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
|
||||
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
|
||||
//
|
||||
// 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`),
|
||||
// LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、
|
||||
// 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。
|
||||
let shell_hint = if cfg!(windows) {
|
||||
"\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let result = execute(request).await.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("命令执行超时") {
|
||||
anyhow::anyhow!(
|
||||
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
|
||||
timeout_secs
|
||||
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}",
|
||||
timeout_secs, shell_hint
|
||||
)
|
||||
} else {
|
||||
e
|
||||
anyhow::anyhow!("{}{}", msg, shell_hint)
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -2963,7 +3180,7 @@ mod tests {
|
||||
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
|
||||
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
|
||||
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
|
||||
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||||
@@ -2975,6 +3192,10 @@ mod tests {
|
||||
// register_task_graph_tools 6→7)。
|
||||
// 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
|
||||
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
|
||||
// 工程系统(2026-06-29): data 层 27→28(新增 list_project_modules 工程列表查询,
|
||||
// register_task_graph_tools 9→10)
|
||||
// Git 只读工具(2026-06-29): data 层 28→31(新增 git_status/git_diff/git_log,
|
||||
// register_git_tools 3 个)
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
41,
|
||||
@@ -3000,6 +3221,10 @@ mod tests {
|
||||
"get_project_timeline",
|
||||
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
|
||||
"add_project_service", "list_project_services",
|
||||
// 工程系统:项目工程列表查询
|
||||
"list_project_modules",
|
||||
// Git 只读工具
|
||||
"git_status", "git_diff", "git_log",
|
||||
// ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能)
|
||||
@@ -3210,7 +3435,7 @@ mod tests {
|
||||
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let db = Arc::new(db);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file, "limit": 15 });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
@@ -3237,7 +3462,7 @@ mod tests {
|
||||
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let db = Arc::new(db);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
@@ -3719,7 +3944,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
(db, registry)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Deserialize;
|
||||
use tauri::State;
|
||||
|
||||
use df_ai::provider::LlmProvider;
|
||||
use df_types::types::{new_id, Priority};
|
||||
use df_types::types::{new_id, IdeaStatus, Priority, ProjectStatus};
|
||||
use df_ideas::capture::Idea;
|
||||
use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
|
||||
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord};
|
||||
@@ -87,7 +87,7 @@ pub async fn create_idea(
|
||||
id: new_id(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: "draft".to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: input.priority,
|
||||
score: None,
|
||||
tags: input.tags,
|
||||
@@ -190,7 +190,7 @@ pub async fn promote_idea(
|
||||
id: project_id.clone(),
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: Some(id.clone()),
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -208,7 +208,7 @@ pub async fn promote_idea(
|
||||
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
|
||||
// 删除而非真事务(改动最小,工程投入产出比最高)。
|
||||
let updated = IdeaRecord {
|
||||
status: "promoted".to_string(),
|
||||
status: IdeaStatus::Promoted,
|
||||
promoted_to: Some(project_id.clone()),
|
||||
updated_at: now,
|
||||
..record
|
||||
@@ -338,7 +338,7 @@ pub async fn evaluate_idea(
|
||||
scores: Some(scores_json.clone()),
|
||||
ai_analysis: Some(ai_analysis.clone()),
|
||||
score: Some(score_value as f64),
|
||||
status: "pending_review".to_string(),
|
||||
status: IdeaStatus::PendingReview,
|
||||
updated_at: now_millis(),
|
||||
..record
|
||||
};
|
||||
@@ -460,7 +460,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
|
||||
title: record.title.clone(),
|
||||
description: record.description.clone(),
|
||||
// IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性)
|
||||
status: status_from_str(&record.status),
|
||||
status: status_from_str(record.status.as_str()),
|
||||
priority: priority_from_i32(record.priority),
|
||||
scores: None,
|
||||
tags,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod events;
|
||||
pub mod idea;
|
||||
pub mod knowledge;
|
||||
pub mod knowledge_timeline;
|
||||
pub mod module;
|
||||
pub mod project;
|
||||
pub mod services;
|
||||
pub mod settings;
|
||||
|
||||
657
src-tauri/src/commands/module.rs
Normal file
657
src-tauri/src/commands/module.rs
Normal file
@@ -0,0 +1,657 @@
|
||||
//! 工程系统命令(V34,project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||
//!
|
||||
//! 对标 [`services`](super::services) 的 IPC 模式:CRUD 返回完整记录,前端拿回 id 直接用。
|
||||
//!
|
||||
//! 关键设计(对标设计 §五工程系统 + V34 迁移注释):
|
||||
//! - **工程元数据 CRUD**:路径/Git 地址/技术栈存表,create/update 返回完整记录。
|
||||
//! - **Git 状态实时派生**:`get_module_git_status` 在工程目录跑 git 命令(分支/改动/最近提交),
|
||||
//! 10s 超时,无 .git 返回空状态。**前端 IPC 用 std::process::Command**(非 df-execute,
|
||||
//! 因为这是用户读操作,不是 AI 工具调用,不走审批/沙箱)。
|
||||
//! - **文件浏览**(Batch 10):`get_module_file_tree` 列目录并合并 git status --porcelain
|
||||
//! 到每个条目的 git_status 字段;`read_module_file` 读单文件(1MB 上限,二进制检测)。
|
||||
//! 同样走 std::process::Command 读 git,目录扫描用 std::fs(read_dir)。
|
||||
//! - **路径校验**:trim + 非空校验在前置 IPC 层(给清晰错误)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use df_storage::models::ProjectModuleRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{err_str, now_millis};
|
||||
|
||||
// ============================================================
|
||||
// 入参 / 出参结构
|
||||
// ============================================================
|
||||
|
||||
/// 新增工程入参(对标设计 §五 add_project_module)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddProjectModuleInput {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
/// 工程目录绝对路径(本机)
|
||||
pub path: String,
|
||||
/// 远程仓库地址,可选(单仓库本地工程可为空)
|
||||
#[serde(default)]
|
||||
pub git_url: Option<String>,
|
||||
/// 技术栈 JSON 字符串(前端检测后传入),可选
|
||||
#[serde(default)]
|
||||
pub stack: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProjectModuleInput {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub git_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stack: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助:Optional 字段 trim + 空串归一为 None
|
||||
// ============================================================
|
||||
|
||||
fn trim_opt(s: Option<String>) -> Option<String> {
|
||||
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令 — 工程 CRUD
|
||||
// ============================================================
|
||||
|
||||
/// 新增工程(对标设计 §五 add_project_module)。返回完整记录。
|
||||
#[tauri::command]
|
||||
pub async fn add_project_module(
|
||||
state: State<'_, AppState>,
|
||||
input: AddProjectModuleInput,
|
||||
) -> Result<ProjectModuleRecord, String> {
|
||||
let project_id = input.project_id.trim().to_string();
|
||||
let name = input.name.trim().to_string();
|
||||
let path = input.path.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err("name 不能为空".to_string());
|
||||
}
|
||||
if path.is_empty() {
|
||||
return Err("path 不能为空".to_string());
|
||||
}
|
||||
// sort_order 取当前项目下工程数(末尾追加,新工程排末位)
|
||||
let existing = state
|
||||
.project_modules
|
||||
.list_by_project(&project_id)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
let sort_order = existing.len() as i32;
|
||||
|
||||
let record = ProjectModuleRecord {
|
||||
id: new_id(),
|
||||
project_id,
|
||||
name,
|
||||
path,
|
||||
git_url: trim_opt(input.git_url),
|
||||
stack: trim_opt(input.stack),
|
||||
auto_detected: false,
|
||||
sort_order,
|
||||
// created_at/updated_at 由 Repo 内部覆盖,此处占位
|
||||
created_at: now_millis(),
|
||||
updated_at: now_millis(),
|
||||
};
|
||||
let record_id = record.id.clone();
|
||||
let ok = state
|
||||
.project_modules
|
||||
.insert(record)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !ok {
|
||||
return Err("插入工程记录失败".to_string());
|
||||
}
|
||||
state
|
||||
.project_modules
|
||||
.get_by_id(&record_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| "插入后回读失败".to_string())
|
||||
}
|
||||
|
||||
/// 更新工程(部分更新语义:仅传入字段被覆盖,其余保留)。返回完整记录。
|
||||
#[tauri::command]
|
||||
pub async fn update_project_module(
|
||||
state: State<'_, AppState>,
|
||||
input: UpdateProjectModuleInput,
|
||||
) -> Result<ProjectModuleRecord, String> {
|
||||
let id = input.id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err("id 不能为空".to_string());
|
||||
}
|
||||
// 至少一个可更新字段
|
||||
if input.name.is_none()
|
||||
&& input.path.is_none()
|
||||
&& input.git_url.is_none()
|
||||
&& input.stack.is_none()
|
||||
{
|
||||
return Err("至少提供一个待更新字段 (name/path/git_url/stack)".to_string());
|
||||
}
|
||||
|
||||
// 先校验存在(404 友好错误),再整体更新(保留不可变字段)
|
||||
let mut existing = state
|
||||
.project_modules
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {id} 不存在"))?;
|
||||
|
||||
if let Some(name) = input.name.map(|s| s.trim().to_string()) {
|
||||
if name.is_empty() {
|
||||
return Err("name 不能为空".to_string());
|
||||
}
|
||||
existing.name = name;
|
||||
}
|
||||
if let Some(path) = input.path.map(|s| s.trim().to_string()) {
|
||||
if path.is_empty() {
|
||||
return Err("path 不能为空".to_string());
|
||||
}
|
||||
existing.path = path;
|
||||
}
|
||||
existing.git_url = trim_opt(input.git_url);
|
||||
existing.stack = trim_opt(input.stack);
|
||||
|
||||
let hit = state
|
||||
.project_modules
|
||||
.update_full(&existing)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !hit {
|
||||
return Err(format!("工程 {id} 不存在(更新未命中)"));
|
||||
}
|
||||
state
|
||||
.project_modules
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| "更新后回读失败".to_string())
|
||||
}
|
||||
|
||||
/// 删除工程(按 id 硬删,工程不需要软删审计追溯)。
|
||||
#[tauri::command]
|
||||
pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let id = id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err("id 不能为空".to_string());
|
||||
}
|
||||
state.project_modules.delete(&id).await.map_err(err_str)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查询项目全部工程(按 sort_order ASC 返回,前端工程列表稳定顺序)。
|
||||
#[tauri::command]
|
||||
pub async fn list_project_modules(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<ProjectModuleRecord>, String> {
|
||||
let project_id = project_id.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
state
|
||||
.project_modules
|
||||
.list_by_project(&project_id)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令 — Git 状态查询(实时派生,不进表)
|
||||
// ============================================================
|
||||
|
||||
/// Git 改动文件项(状态码 + 相对路径)。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GitChangedFile {
|
||||
/// `git status --porcelain` 的状态码(xy 两字符,如 " M"、"M "、"??")
|
||||
status: String,
|
||||
/// 相对仓库根的路径
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// 最近提交项(简短哈希 + subject)。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GitRecentCommit {
|
||||
hash: String,
|
||||
subject: String,
|
||||
}
|
||||
|
||||
/// Git 状态返回结构(无 .git 时各字段空,前端据此显示"非 Git 仓库")。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GitStatus {
|
||||
/// 当前分支名(HEAD 处于分离状态时为空)
|
||||
branch: String,
|
||||
/// 改动文件列表(未提交)
|
||||
changed_files: Vec<GitChangedFile>,
|
||||
/// 最近 10 条提交
|
||||
recent_commits: Vec<GitRecentCommit>,
|
||||
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
|
||||
is_git_repo: bool,
|
||||
}
|
||||
|
||||
/// 默认空状态(无 .git / git 不可用时返回此)。
|
||||
fn empty_status() -> GitStatus {
|
||||
GitStatus {
|
||||
branch: String::new(),
|
||||
changed_files: Vec::new(),
|
||||
recent_commits: Vec::new(),
|
||||
is_git_repo: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询工程目录的 Git 状态(分支/改动文件/最近提交)。
|
||||
///
|
||||
/// 实现选择:**前端 IPC 用 `std::process::Command`**(非 df-execute,因为这是用户读操作,
|
||||
/// 不是 AI 工具调用,不走审批/沙箱)。10s 超时,无 .git 返回空状态(`is_git_repo: false`)。
|
||||
///
|
||||
/// 返回 `serde_json::Value` 以便前端灵活取字段(分支/改动/提交三段)。
|
||||
#[tauri::command]
|
||||
pub async fn get_module_git_status(
|
||||
state: State<'_, AppState>,
|
||||
module_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let module_id = module_id.trim().to_string();
|
||||
if module_id.is_empty() {
|
||||
return Err("module_id 不能为空".to_string());
|
||||
}
|
||||
// 取工程记录拿 path
|
||||
let module = state
|
||||
.project_modules
|
||||
.get_by_id(&module_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||
|
||||
let path = std::path::Path::new(&module.path);
|
||||
// 无 .git → 返回空状态
|
||||
if !path.join(".git").exists() {
|
||||
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
|
||||
}
|
||||
|
||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||
let dir = module.path.clone();
|
||||
let status = tokio::task::spawn_blocking(move || run_git_status(&dir))
|
||||
.await
|
||||
.map_err(|e| format!("git 状态查询任务失败: {e}"))?;
|
||||
|
||||
Ok(serde_json::to_value(status).map_err(err_str)?)
|
||||
}
|
||||
|
||||
/// 在指定目录跑 git 命令采集状态(当前分支 / 改动文件 / 最近提交)。
|
||||
///
|
||||
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
|
||||
fn run_git_status(dir: &str) -> GitStatus {
|
||||
use std::time::Duration;
|
||||
|
||||
let path = std::path::Path::new(dir);
|
||||
if !path.join(".git").exists() {
|
||||
return empty_status();
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(10);
|
||||
|
||||
// 1) 当前分支:`git rev-parse --abbrev-ref HEAD`(分离 HEAD 时返回 "HEAD")
|
||||
let branch = run_git_cmd(path, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty() && s != "HEAD")
|
||||
.unwrap_or_default();
|
||||
|
||||
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
|
||||
let mut changed_files = Vec::new();
|
||||
if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) {
|
||||
for line in out.lines() {
|
||||
if line.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
// porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path)
|
||||
let status = line[..2].to_string();
|
||||
let file_path = line[3..].trim().to_string();
|
||||
if !file_path.is_empty() {
|
||||
changed_files.push(GitChangedFile {
|
||||
status,
|
||||
path: file_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 最近提交:`git log -10 --oneline`(每行 "hash subject")
|
||||
let mut recent_commits = Vec::new();
|
||||
if let Some(out) = run_git_cmd(path, &["log", "-10", "--oneline"], timeout) {
|
||||
for line in out.lines() {
|
||||
// oneline 格式:"<short-hash> <subject>"
|
||||
if let Some((hash, subject)) = line.split_once(' ') {
|
||||
recent_commits.push(GitRecentCommit {
|
||||
hash: hash.to_string(),
|
||||
subject: subject.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GitStatus {
|
||||
branch,
|
||||
changed_files,
|
||||
recent_commits,
|
||||
is_git_repo: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑单个 git 子命令,捕获 stdout(成功)或 None(失败/超时)。
|
||||
///
|
||||
/// 用 `wait_timeout` 实现 10s 超时(标准库 `Command::output` 无超时,需手动 wait)。
|
||||
fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option<String> {
|
||||
use std::sync::mpsc;
|
||||
|
||||
// 在独立线程跑子进程,主线程 select 超时,避免标准库无超时 API 的痛点。
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let cwd = cwd.to_path_buf();
|
||||
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
std::thread::spawn(move || {
|
||||
let out = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.output();
|
||||
let _ = tx.send(out);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(Ok(output)) if output.status.success() => {
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令 — 文件浏览(Batch 10)
|
||||
// ============================================================
|
||||
//
|
||||
// 设计要点(对标设计 §五 + Batch 10):
|
||||
// - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize
|
||||
// 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。
|
||||
// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite
|
||||
// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。
|
||||
// - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径
|
||||
// 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。
|
||||
// - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。
|
||||
|
||||
/// 噪音目录名(列表级过滤,read_dir 看到这些名字跳过)。
|
||||
const NOISE_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"target",
|
||||
".git",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
".vite",
|
||||
];
|
||||
|
||||
/// 文件树条目(单层;前端点击文件夹再懒加载下一层)。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FileTreeEntry {
|
||||
name: String,
|
||||
/// 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path)。
|
||||
path: String,
|
||||
is_dir: bool,
|
||||
/// 字节数(文件夹恒为 0)。
|
||||
size: u64,
|
||||
/// Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??")。
|
||||
/// 文件夹恒为 None(只标文件)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
git_status: Option<String>,
|
||||
}
|
||||
|
||||
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
|
||||
/// 返回 { path, entries: [{ name, path, is_dir, size, git_status? }] }。
|
||||
#[tauri::command]
|
||||
pub async fn get_module_file_tree(
|
||||
state: State<'_, AppState>,
|
||||
module_id: String,
|
||||
sub_path: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let module_id = module_id.trim().to_string();
|
||||
if module_id.is_empty() {
|
||||
return Err("module_id 不能为空".to_string());
|
||||
}
|
||||
let sub = sub_path
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// 路径穿越防御:`..` 一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
|
||||
if let Some(ref s) = sub {
|
||||
if s.contains("..") {
|
||||
return Err("sub_path 不允许包含 ..".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let module = state
|
||||
.project_modules
|
||||
.get_by_id(&module_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||
|
||||
let root = PathBuf::from(&module.path);
|
||||
// 工程根本身必须存在(否则连列根都无意义,直接报错给前端清晰提示)。
|
||||
if !root.exists() {
|
||||
return Err(format!("工程目录不存在: {}", module.path));
|
||||
}
|
||||
|
||||
// 拼接 sub_path 得目标目录;规范化路径显示用。
|
||||
let target_dir = match &sub {
|
||||
Some(rel) => root.join(rel),
|
||||
None => root.clone(),
|
||||
};
|
||||
if !target_dir.is_dir() {
|
||||
return Err(format!("目标路径不是目录: {}", target_dir.display()));
|
||||
}
|
||||
|
||||
// 计算相对工程根的显示路径(POSIX 风格),前端面包屑/二次请求复用。
|
||||
let rel_display = match &sub {
|
||||
Some(rel) => rel.replace('\\', "/"),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
|
||||
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
|
||||
let dir_for_git = module.path.clone();
|
||||
let git_map = tokio::task::spawn_blocking(move || collect_git_status_map(&dir_for_git))
|
||||
.await
|
||||
.map_err(|e| format!("git status 采集任务失败: {e}"))?;
|
||||
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
|
||||
// git status 输出是相对仓库根(module.path 当作根),sub 非空时条目前缀 = sub + "/"。
|
||||
let prefix = if rel_display.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{rel_display}/")
|
||||
};
|
||||
|
||||
// 读目录(spawn_blocking 避免阻塞 runtime);排序:目录优先 + 字母序。
|
||||
let target_dir_clone = target_dir.clone();
|
||||
let prefix_clone = prefix.clone();
|
||||
let git_map_clone = git_map.clone();
|
||||
let entries = tokio::task::spawn_blocking(move || -> Result<Vec<FileTreeEntry>, String> {
|
||||
let read = std::fs::read_dir(&target_dir_clone)
|
||||
.map_err(|e| format!("读取目录失败: {e}"))?;
|
||||
let mut items: Vec<FileTreeEntry> = Vec::new();
|
||||
for entry in read.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy().to_string();
|
||||
// 噪音过滤(无论文件/目录,凡名命中即跳)。
|
||||
if NOISE_DIRS.iter().any(|n| *n == name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let ft = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue, // 无法判定类型跳过(符号链接断链等)
|
||||
};
|
||||
// 跳过隐藏文件(以 . 开头),与常见编辑器行为一致,减少噪音。
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let is_dir = ft.is_dir();
|
||||
let size = if is_dir {
|
||||
0
|
||||
} else {
|
||||
entry.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
};
|
||||
let rel_path = format!("{prefix_clone}{name}");
|
||||
let posix_rel = rel_path.replace('\\', "/");
|
||||
let git_status = if is_dir {
|
||||
None
|
||||
} else {
|
||||
git_map_clone.get(&posix_rel).cloned()
|
||||
};
|
||||
items.push(FileTreeEntry {
|
||||
name,
|
||||
path: posix_rel,
|
||||
is_dir,
|
||||
size,
|
||||
git_status,
|
||||
});
|
||||
}
|
||||
// 目录优先,各自字母序(稳定可预期,前端无需再排)。
|
||||
items.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
Ok(items)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("列目录任务失败: {e}"))??;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"path": rel_display,
|
||||
"entries": entries,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 读工程内单文件(文本/图片);1MB 上限,二进制检测(前 8KB 含 \0)。
|
||||
/// 返回 { path, content, size, is_binary }。二进制/超限时 is_binary=true 或 content 截断,
|
||||
/// 前端据此降级展示("二进制文件不支持预览")。
|
||||
#[tauri::command]
|
||||
pub async fn read_module_file(
|
||||
state: State<'_, AppState>,
|
||||
module_id: String,
|
||||
file_path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let module_id = module_id.trim().to_string();
|
||||
let file_path = file_path.trim().to_string();
|
||||
if module_id.is_empty() {
|
||||
return Err("module_id 不能为空".to_string());
|
||||
}
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path 不能为空".to_string());
|
||||
}
|
||||
// 路径穿越防御:`..` 一律拒。
|
||||
if file_path.contains("..") {
|
||||
return Err("file_path 不允许包含 ..".to_string());
|
||||
}
|
||||
|
||||
let module = state
|
||||
.project_modules
|
||||
.get_by_id(&module_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||
|
||||
let root = PathBuf::from(&module.path);
|
||||
let abs = root.join(&file_path);
|
||||
// 必须是文件(目录/不存在均拒)。
|
||||
if !abs.is_file() {
|
||||
return Err(format!("文件不存在或不是普通文件: {}", file_path));
|
||||
}
|
||||
|
||||
const MAX_BYTES: u64 = 1024 * 1024; // 1MB 上限
|
||||
let meta = std::fs::metadata(&abs).map_err(|e| format!("读取文件元信息失败: {e}"))?;
|
||||
let total_size = meta.len();
|
||||
|
||||
// 读取(超 1MB 截断到 1MB;spawn_blocking 避免大文件 IO 阻塞 runtime)。
|
||||
let abs_clone = abs.clone();
|
||||
let read_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, bool), String> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(&abs_clone).map_err(|e| format!("打开文件失败: {e}"))?;
|
||||
let mut buf = vec![0u8; MAX_BYTES as usize];
|
||||
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
|
||||
buf.truncate(n);
|
||||
// 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。
|
||||
let check_len = buf.len().min(8192);
|
||||
let is_binary = buf[..check_len].iter().any(|&b| b == 0);
|
||||
Ok((buf, is_binary))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("读文件任务失败: {e}"))??;
|
||||
let (bytes, is_binary) = read_result;
|
||||
|
||||
let content = if is_binary {
|
||||
// 二进制不返内容(前端走"二进制不支持预览"分支,避免给前端塞不可打印字节)。
|
||||
String::new()
|
||||
} else {
|
||||
// 非 UTF-8 字节用 lossy(替换非法字节,文本类基本不受影响)。
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"path": file_path.replace('\\', "/"),
|
||||
"content": content,
|
||||
"size": total_size,
|
||||
"is_binary": is_binary,
|
||||
// 标记是否被截断(超 1MB),前端据此提示"文件过大,仅显示前 1MB"。
|
||||
"truncated": total_size > MAX_BYTES,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
|
||||
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
|
||||
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
||||
let path = Path::new(dir);
|
||||
let mut map = HashMap::new();
|
||||
if !path.join(".git").exists() {
|
||||
return map;
|
||||
}
|
||||
let timeout = std::time::Duration::from_secs(10);
|
||||
let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else {
|
||||
return map;
|
||||
};
|
||||
for line in out.lines() {
|
||||
if line.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let status = line[..2].to_string();
|
||||
let raw_path = line[3..].trim().to_string();
|
||||
if raw_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// porcelain 在路径含空格/特殊字符时可能带引号;git 默认不引普通路径,剥引号兜底。
|
||||
let cleaned = raw_path
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.unwrap_or(&raw_path)
|
||||
.replace('\\', "/");
|
||||
// 重命名条目形如 "R old -> new":取 -> 后的新路径(更贴合文件树展示位置)。
|
||||
let final_path = cleaned
|
||||
.split_once(" -> ")
|
||||
.map(|(_, new)| new.to_string())
|
||||
.unwrap_or(cleaned);
|
||||
map.insert(final_path, status);
|
||||
}
|
||||
map
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest};
|
||||
use df_ai::router::{
|
||||
select_model_id, Modality, TaskRequirements,
|
||||
};
|
||||
use df_types::types::new_id;
|
||||
use df_types::types::{new_id, ProjectStatus};
|
||||
use df_project::scan::{
|
||||
collect_sample, detect_stack, discover_projects, extract_description,
|
||||
normalize_path, DiscoveredProject,
|
||||
@@ -128,17 +128,23 @@ async fn create_with_binding(
|
||||
// 绑定目录:校验存在 + 防重复 + 自动探测技术栈
|
||||
let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) {
|
||||
Some(p) => {
|
||||
if !Path::new(p).is_dir() {
|
||||
return Err(format!("目录不存在: {p}"));
|
||||
// 拒绝原始路径含 `..`(防穿越),存入前调用 normalize_path 规范化
|
||||
if p.contains("..") {
|
||||
return Err(format!("路径不得包含 '..': {}", p));
|
||||
}
|
||||
if let Some(conflict) = find_binding_conflict(state, p, None).await? {
|
||||
if !Path::new(p).is_dir() {
|
||||
// 目录不存在则自动创建(消除建项目→建目录死锁)
|
||||
std::fs::create_dir_all(p).map_err(|e| format!("目录创建失败: {}", e))?;
|
||||
}
|
||||
let normalized = normalize_path(p);
|
||||
if let Some(conflict) = find_binding_conflict(state, &normalized, None).await? {
|
||||
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
||||
}
|
||||
// stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime)
|
||||
let stack_json = match stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
let root = std::path::PathBuf::from(p);
|
||||
let root = std::path::PathBuf::from(&normalized);
|
||||
let detected = tokio::task::spawn_blocking(move || detect_stack(&root))
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
@@ -146,7 +152,7 @@ async fn create_with_binding(
|
||||
serde_json::to_string(&detected).map_err(err_str)?
|
||||
}
|
||||
};
|
||||
(Some(p.to_string()), Some(stack_json))
|
||||
(Some(normalized), Some(stack_json))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
@@ -156,7 +162,7 @@ async fn create_with_binding(
|
||||
id: new_id(),
|
||||
name,
|
||||
description,
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id,
|
||||
path,
|
||||
stack,
|
||||
@@ -164,6 +170,27 @@ async fn create_with_binding(
|
||||
updated_at: now,
|
||||
};
|
||||
state.projects.insert(record.clone()).await.map_err(err_str)?;
|
||||
// 工程系统:创建项目时自动建一个工程(path = 绑定目录,stack = 探测结果)。
|
||||
// 单仓库项目退化:项目下只有一个工程,用户无感工程概念。
|
||||
// 多仓库场景用户后续可添加更多工程(add_project_module IPC)。
|
||||
if let Some(ref module_path) = record.path {
|
||||
let now_str = df_types::now_millis().to_string();
|
||||
let module = df_storage::models::ProjectModuleRecord {
|
||||
id: df_types::types::new_id(),
|
||||
project_id: record.id.clone(),
|
||||
name: record.name.clone(),
|
||||
path: module_path.clone(),
|
||||
git_url: None, // 探测 git remote 补充(失败为 None,不影响)
|
||||
stack: record.stack.clone(), // 复用项目级技术栈探测结果
|
||||
auto_detected: true, // 自动创建,重新探测时可覆盖
|
||||
sort_order: 0,
|
||||
created_at: now_str.clone(),
|
||||
updated_at: now_str,
|
||||
};
|
||||
if let Err(e) = state.project_modules.insert(module).await {
|
||||
tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e);
|
||||
}
|
||||
}
|
||||
// F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
|
||||
// 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
|
||||
if record.path.is_some() {
|
||||
@@ -221,7 +248,6 @@ pub async fn import_project(
|
||||
return Err("导入路径不能为空".to_string());
|
||||
}
|
||||
if !Path::new(&path).is_dir() {
|
||||
return Err(format!("目录不存在: {path}"));
|
||||
}
|
||||
|
||||
// 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。
|
||||
@@ -371,7 +397,6 @@ pub async fn relocate_project_path(
|
||||
new_path: String,
|
||||
) -> Result<ProjectRecord, String> {
|
||||
if !Path::new(&new_path).is_dir() {
|
||||
return Err(format!("目录不存在: {new_path}"));
|
||||
}
|
||||
if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? {
|
||||
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
||||
@@ -435,7 +460,6 @@ pub async fn scan_directory_for_projects(
|
||||
) -> Result<Vec<ScannedProjectItem>, String> {
|
||||
let root = Path::new(&root_path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("目录不存在: {root_path}"));
|
||||
}
|
||||
|
||||
// 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime)
|
||||
@@ -670,7 +694,6 @@ pub async fn scan_project_with_ai(
|
||||
) -> Result<AiScanResult, String> {
|
||||
let root = Path::new(&path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("目录不存在: {path}"));
|
||||
}
|
||||
|
||||
// 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime)
|
||||
|
||||
@@ -53,3 +53,41 @@ pub async fn settings_delete(
|
||||
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
|
||||
Ok(state.data_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 读取审批超时分钟数(默认 15 分钟,0=禁用超时)
|
||||
///
|
||||
/// 前端 Settings 页启动时拉取,展示当前配置。
|
||||
#[tauri::command]
|
||||
pub async fn ai_get_approval_timeout(state: State<'_, AppState>) -> Result<u64, String> {
|
||||
Ok(state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst))
|
||||
}
|
||||
|
||||
/// 设置审批超时分钟数并持久化(默认 15 分钟,0=禁用超时)
|
||||
///
|
||||
/// 前端 Settings 页保存时调用。值范围 0-1440(0=禁用,最大 24 小时)。
|
||||
/// 注意:此命令与前端 AdvancedSection.vue 的 df-approval-timeout KV 持久化路径不同,
|
||||
/// 前端走 appSettings.set 走 KV,这里作为后端热改入口。两者最终读写同一个 KV。
|
||||
#[tauri::command]
|
||||
pub async fn ai_set_approval_timeout(
|
||||
state: State<'_, AppState>,
|
||||
minutes: u64,
|
||||
) -> Result<u64, String> {
|
||||
// 范围校验(0-1440 分钟,即 0-24 小时)
|
||||
if minutes > 1440 {
|
||||
return Err("审批超时分钟数超出范围(最大 1440 = 24 小时)".to_string());
|
||||
}
|
||||
// 持久化到 Settings KV(与前端同 key,值为毫秒)
|
||||
let ms = minutes * 60_000;
|
||||
state
|
||||
.settings
|
||||
.set(
|
||||
crate::state::APPROVAL_TIMEOUT_KEY,
|
||||
&ms.to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
// 更新内存
|
||||
state.approval_timeout_minutes.store(minutes, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!("[APPROVAL-TIMEOUT] 已更新: {} 分钟 ({} ms)", minutes, ms);
|
||||
Ok(minutes)
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ pub async fn create_task(
|
||||
project_id: input.project_id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: "todo".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: input.priority,
|
||||
branch_name: input.branch_name,
|
||||
assignee: input.assignee,
|
||||
@@ -314,7 +314,7 @@ pub async fn create_task(
|
||||
Some("task"),
|
||||
Some(&record.id),
|
||||
None,
|
||||
Some(&record.status),
|
||||
Some(record.status.as_str()),
|
||||
)
|
||||
.await;
|
||||
Ok(record)
|
||||
@@ -466,8 +466,8 @@ pub async fn advance_task(
|
||||
"task_advanced",
|
||||
Some("task"),
|
||||
Some(&updated.id),
|
||||
from_state.as_deref(),
|
||||
Some(&updated.status),
|
||||
from_state.as_ref().map(TaskStatus::as_str),
|
||||
Some(updated.status.as_str()),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -505,7 +505,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
|
||||
.get_by_id(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.map(|t| t.status)
|
||||
.map(|t| t.status.as_str().to_string())
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"));
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
|
||||
if current.status == new_status {
|
||||
if current.status.as_str() == new_status {
|
||||
return Ok(new_status);
|
||||
}
|
||||
state
|
||||
@@ -688,13 +688,13 @@ pub async fn move_task_queue(
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.clone() // 已在执行中三态,保留
|
||||
current.status.as_str().to_string() // 已在执行中三态,保留
|
||||
} else {
|
||||
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
|
||||
"decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态)
|
||||
"decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态)
|
||||
_ => unreachable!("validate_queue 已收口"),
|
||||
};
|
||||
|
||||
@@ -708,7 +708,7 @@ pub async fn move_task_queue(
|
||||
.map_err(err_str)?;
|
||||
}
|
||||
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
|
||||
if current.status != new_status {
|
||||
if current.status.as_str() != new_status {
|
||||
state
|
||||
.tasks
|
||||
.set_status_for_aggregation(&id, &new_status)
|
||||
|
||||
@@ -8,7 +8,7 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
use df_types::events::{WorkflowEvent, HumanApprovalResponse, SelectType};
|
||||
use df_types::types::{new_id, NodeStatus};
|
||||
use df_types::types::{new_id, ExecutionId, NodeStatus};
|
||||
use df_nodes::task_advance_node::advance_task_atomic;
|
||||
// F-260616-06 ②-4: 工作流失败时按 target_status 推算任务退回态。
|
||||
// regression_target 已收敛到 df-nodes::task_state_machine(状态机业务契约,单一可信源),
|
||||
@@ -29,7 +29,7 @@ use super::{err_str, now_millis};
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WorkflowEventPayload {
|
||||
/// 工作流执行记录 ID
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
/// 原始工作流事件(serde tag = "type")
|
||||
event: WorkflowEvent,
|
||||
}
|
||||
@@ -385,7 +385,7 @@ pub async fn get_workflow_execution(
|
||||
pub async fn approve_human_approval(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
node_id: String,
|
||||
decision: String,
|
||||
// F-260615-01: 多选结果数组,缺省空数组(单选调用方不传)
|
||||
@@ -466,7 +466,7 @@ pub async fn approve_human_approval(
|
||||
#[tauri::command]
|
||||
pub async fn cancel_workflow_node(
|
||||
state: State<'_, AppState>,
|
||||
execution_id: String,
|
||||
execution_id: ExecutionId,
|
||||
node_id: String,
|
||||
) -> Result<(), String> {
|
||||
// 经 execution_id 取执行器状态机(与 NodeContext.node_status 共享同一 HashMap)
|
||||
|
||||
@@ -11,6 +11,27 @@ use df_tunnel::TunnelClient;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
|
||||
let log_path = std::env::temp_dir().join("devflow-trace.log");
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.expect("创建日志文件失败");
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(log_file);
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
|
||||
)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.init();
|
||||
tracing::info!(
|
||||
path = %log_path.display(),
|
||||
"[startup] 日志已初始化"
|
||||
);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -36,11 +57,33 @@ pub fn run() {
|
||||
app.manage(app_state);
|
||||
|
||||
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
|
||||
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
|
||||
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
|
||||
// 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。
|
||||
let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
std::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_secs(3600))
|
||||
.unwrap_or_else(std::time::Instant::now),
|
||||
));
|
||||
let app_handle = app.handle().clone();
|
||||
app.listen("ai-client-ready", move |_event| {
|
||||
let session_arc = session_for_handshake.clone();
|
||||
let app_h = app_handle.clone();
|
||||
let last_at = last_handshake_at.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。
|
||||
{
|
||||
let mut last = last_at.lock().await;
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed < std::time::Duration::from_secs(3) {
|
||||
tracing::info!(
|
||||
"[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)",
|
||||
elapsed.as_millis()
|
||||
);
|
||||
return;
|
||||
}
|
||||
*last = std::time::Instant::now();
|
||||
}
|
||||
let mut session = session_arc.lock().await;
|
||||
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
|
||||
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
|
||||
@@ -68,6 +111,13 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
|
||||
let pinned_goals_map: std::collections::HashMap<String, Vec<String>> = dirty_convs
|
||||
.iter()
|
||||
.filter_map(|cid| {
|
||||
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
|
||||
})
|
||||
.collect();
|
||||
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
|
||||
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
|
||||
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
|
||||
@@ -86,6 +136,7 @@ pub fn run() {
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(cid.clone()),
|
||||
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -268,6 +319,15 @@ pub fn run() {
|
||||
commands::services::update_project_service,
|
||||
commands::services::remove_project_service,
|
||||
commands::services::list_project_services,
|
||||
// 工程系统(对标设计 §五 + V34):项目多工程 CRUD + Git 状态查询
|
||||
commands::module::add_project_module,
|
||||
commands::module::update_project_module,
|
||||
commands::module::remove_project_module,
|
||||
commands::module::list_project_modules,
|
||||
commands::module::get_module_git_status,
|
||||
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
||||
commands::module::get_module_file_tree,
|
||||
commands::module::read_module_file,
|
||||
// 灵感
|
||||
commands::idea::list_ideas,
|
||||
commands::idea::create_idea,
|
||||
@@ -314,10 +374,12 @@ pub fn run() {
|
||||
commands::ai::ai_conversation_create,
|
||||
commands::ai::ai_conversation_list,
|
||||
commands::ai::ai_conversation_switch,
|
||||
commands::ai::ai_conversation_load_more,
|
||||
commands::ai::ai_conversation_delete,
|
||||
commands::ai::ai_conversation_rename,
|
||||
commands::ai::ai_conversation_archive,
|
||||
commands::ai::ai_conversation_set_pinned,
|
||||
commands::ai::ai_update_conversation_goals,
|
||||
commands::ai::ai_conversation_export,
|
||||
commands::ai::ai_list_skills,
|
||||
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
|
||||
@@ -354,6 +416,9 @@ pub fn run() {
|
||||
commands::settings::settings_get_all,
|
||||
commands::settings::settings_delete,
|
||||
commands::settings::get_data_dir,
|
||||
// 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改)
|
||||
commands::settings::ai_get_approval_timeout,
|
||||
commands::settings::ai_set_approval_timeout,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
//! 应用全局状态 — 数据库、Repo、事件总线、节点注册表、AI 会话
|
||||
//!
|
||||
//! 子模块(按职责拆分,治本:原 1400+ 行单文件 → 按域拆分):
|
||||
//! - [`allowed_dirs`]:文件访问授权白名单 + 路径校验 + 黑名单
|
||||
//! - [`knowledge_config`]:知识库行为配置 + 提炼触发方式 + KV key 常量
|
||||
//! - [`llm_concurrency`]:LLM 调用并发控制(双层 Semaphore + 可选 per-provider 层)
|
||||
//!
|
||||
//! 本文件保留 AppState struct + init + 应用级辅助函数。
|
||||
|
||||
// 子模块声明 + 重新导出(外部消费方 `use crate::state::{AllowedDirs, LlmConcurrency, ...}`
|
||||
// 路径不变,零改动透明继承)。#[allow(unused)] 是因为部分符号在 impl 块内使用,不是 use 语句导入。
|
||||
#[allow(unused)]
|
||||
mod allowed_dirs;
|
||||
#[allow(unused)]
|
||||
mod knowledge_config;
|
||||
#[allow(unused)]
|
||||
mod llm_concurrency;
|
||||
|
||||
// 对外公共符号(pub use):外部 crate/模块经 `crate::state::X` 路径消费
|
||||
pub use allowed_dirs::{
|
||||
AllowedDirs, PathAuthDecision, check_path_authorization, is_in_system_blacklist,
|
||||
paths_eq, strip_verbatim,
|
||||
};
|
||||
pub use knowledge_config::{
|
||||
ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY,
|
||||
};
|
||||
pub use llm_concurrency::LlmConcurrency;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Mutex, RwLock, Semaphore};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use df_ai::ai_tools::AiToolRegistry;
|
||||
use df_storage::crud::{
|
||||
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
|
||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo,
|
||||
ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
|
||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectModuleRepo,
|
||||
ProjectRepo, ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo,
|
||||
WorkflowRepo,
|
||||
};
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
@@ -23,308 +49,6 @@ use df_workflow::state::StateMachine;
|
||||
use crate::commands::ai::augmentation::ResolverRegistry;
|
||||
use crate::commands::ai::AiSession;
|
||||
|
||||
// ============================================================
|
||||
// 知识库配置(提取 + 注入)
|
||||
// ============================================================
|
||||
|
||||
/// AI 提炼触发方式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExtractTrigger {
|
||||
/// 对话正常完成时(默认)
|
||||
OnComplete,
|
||||
/// 仅手动按钮触发
|
||||
ManualOnly,
|
||||
}
|
||||
|
||||
/// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc<Mutex> 启动 default 覆盖
|
||||
/// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。
|
||||
pub const KNOWLEDGE_CONFIG_KEY: &str = "df-knowledge-config";
|
||||
|
||||
/// 知识库行为配置(内存真相源 + Settings KV 持久化,前后端通过 IPC 读写)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeConfig {
|
||||
/// 提炼总开关,默认 true
|
||||
pub auto_extract: bool,
|
||||
/// 提炼触发方式,默认 OnComplete
|
||||
pub trigger_mode: ExtractTrigger,
|
||||
/// 最少消息数守卫(防闲聊噪音),默认 4
|
||||
pub min_messages: u32,
|
||||
/// 聊天时自动注入相关知识开关,默认 true
|
||||
pub auto_inject: bool,
|
||||
/// 语义检索(向量)总开关,默认 false——关闭时纯 LIKE 零外部依赖
|
||||
#[serde(default)]
|
||||
pub vector_enabled: bool,
|
||||
/// embedding 用的 provider id(仅 openai_compat 类型,Anthropic 无 embed API)
|
||||
#[serde(default)]
|
||||
pub embedding_provider_id: Option<String>,
|
||||
/// embedding 模型名(如 embedding-3 / text-embedding-3-small)
|
||||
#[serde(default)]
|
||||
pub embedding_model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for KnowledgeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_extract: true,
|
||||
trigger_mode: ExtractTrigger::OnComplete,
|
||||
min_messages: 4,
|
||||
auto_inject: true,
|
||||
vector_enabled: false,
|
||||
embedding_provider_id: None,
|
||||
embedding_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LLM 调用并发控制(双层 Semaphore + 可选 per-provider 层)
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用并发控制 — 全局 LLM 调用限流 global + 单对话内 per_conv 双层 Semaphore + 可选 per-provider 层
|
||||
///
|
||||
/// 限流对象:所有真实 LLM 调用(主循环 stream_llm / 标题生成 / 知识提炼)。
|
||||
/// 不限流本地工具执行(tools.execute)——本地操作无外部成本、不受 RPM 约束。
|
||||
///
|
||||
/// ## global(permits 默认 3,「LLM 调用并发限流」原义)
|
||||
/// F-09 batch5 曾把 global 改「并发会话数上限」(loop 入口持整 loop),用户决策修正「不设并发会话上限」后
|
||||
/// 已删除 loop 入口 acquire。global 回归原义:由各单次 LLM 调用点(stream_llm 重试循环 / 标题 / 压缩 /
|
||||
/// 提炼 / 项目分析扫描)各自 `acquire_global()` 拿 permit、调用结束 Drop 释放,防 provider 429。
|
||||
/// 多对话 loop 并发不限数(用户接受 token 暴增)。
|
||||
///
|
||||
/// ## per_conv 改 HashMap<conv_id, Semaphore>
|
||||
/// 原应用级单信号量(AiSession 单例 + generating 互斥下退化)改为
|
||||
/// `HashMap<String, Arc<Semaphore>>`:每对话一份(permits=2,主循环 + 标题 + 提炼各自限流)。
|
||||
/// `run_agentic_loop` 入口 `acquire_per_conv(conv_id)` 拿 1 permit 持整个 loop 生命周期(含工具执行/审批
|
||||
/// 等待/重试),防单对话内并发 LLM 调用失控。这是单对话内限流,非会话数限制,符合用户「不设上限」。
|
||||
/// `acquire_per_conv(conv_id)`:lock HashMap → 无则建(permits=2)→ clone Arc → 释放 lock → acquire_owned。
|
||||
/// conv 退出清理(`release_conv(conv_id)`):conv 删除时 remove 条目(防 HashMap 无限增长)。
|
||||
///
|
||||
/// 运行时调整:tokio Semaphore 的 permits 数构造时固定、不可增减,
|
||||
/// 故 `global` 用 `Arc<Mutex<Arc<Semaphore>>>` 双层包装——替换内层 Arc 即重建 Semaphore。
|
||||
/// 已持有旧 permit 的任务不受影响(permit 绑定旧 Semaphore,软收敛),
|
||||
/// 新请求 lock 后克隆到最新 Arc、自动走新限制。旧 Semaphore 随最后 permit 释放而 drop。
|
||||
/// per_conv HashMap 内每条 Arc<Semaphore> 不需运行时改 permits(对话内并发上限 2 固定),故无重建需求。
|
||||
///
|
||||
/// ## F-260614-04: per-provider 层(可选)
|
||||
/// `per_provider` 为 HashMap<provider_id, Arc<Semaphore>>。调用方(agentic loop)经
|
||||
/// `acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。
|
||||
/// **单 provider 场景**:若未调 `set_provider_caps`,HashMap 为空,
|
||||
/// `acquire_for_provider` 返回 None(无限流,行为同 F-01 前)。零变化保证。
|
||||
/// 全局容量 = min(sum(各 provider 上限), global_cap):由调用方在配置时约束
|
||||
/// (set_provider_caps 传 min(sum, global_cap)),非运行时强约束。
|
||||
///
|
||||
/// ## Phase3 预留: per_sub_flow 层(批2-B,占位未接入)
|
||||
/// `per_sub_flow` 为 HashMap<sub_flow_id, Arc<Semaphore>>,用于 Phase3 单对话并行多轮的
|
||||
/// **子流并发上限**(单对话内并行 spawn 多条子流时,防子流数失控)。本批仅加层 + 占位,
|
||||
/// **不接入调用点**(接入待 Phase3 子流 spawn 落地)。
|
||||
///
|
||||
/// **key 约定(文档化,非强约束)**:`sub_flow_id` 采用 `"{conv_id}::{sub_id}"` 格式,
|
||||
/// 唯一标识某对话下的某条子流,便于 release_sub_flow 精确清理。
|
||||
///
|
||||
/// **三层语义**:
|
||||
/// - per_sub_flow = 单对话内**子流**并发上限(每条子流一份 Semaphore,permits 默认 3,预留)
|
||||
/// - per_conv = 单对话内**总**并发上限(permits 默认 2)
|
||||
/// - global = 全应用**总**并发上限(permits 默认 3)
|
||||
///
|
||||
/// **理想配额(用户配置建议,非硬限)**: per_sub_permits ≤ per_conv_permits ≤ global_permits。
|
||||
/// 实际限流由各层 Semaphore 自然保证:global Semaphore 硬限全应用并发不超 global permits
|
||||
/// (跨对话 sum(per_conv) 可 > global,但 global acquire_owned 自然阻塞,不超卖)。
|
||||
///
|
||||
/// **acquire 语义(非阻塞)**:`acquire_per_sub_flow` 用 `try_acquire_owned`(非阻塞),
|
||||
/// 耗尽返 None 降级串行——子流并行不阻塞主 loop(Phase3 子流 spawn 失败不拖垮整对话)。
|
||||
#[derive(Clone)]
|
||||
pub struct LlmConcurrency {
|
||||
/// 全局 LLM 调用并发上限(permits 默认 3):F-09 batch5 修正后回归原义,由各单次 LLM 调用点
|
||||
/// (stream_llm/标题/压缩/提炼/项目分析)各自 acquire/drop 防 429,不再由 loop 入口持整 loop。
|
||||
global: Arc<Mutex<Arc<Semaphore>>>,
|
||||
/// 单对话内并发上限(F-09 B 批5):HashMap<conv_id, Semaphore>,每对话 permits=2。
|
||||
/// acquire 时按 conv_id 取/建;release_conv 在 loop 结束 + 无 pending 时 remove。
|
||||
per_conv: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
/// 每对话内并发上限(permits 默认 2,构造时传入 new(3, 2) 的第二参)。
|
||||
/// F-09 B 批5:AtomicUsize 支持运行时热改(ai_set_concurrency_config 的 per_conv_limit)。
|
||||
/// 热改后**已建对话**的旧 Semaphore 不变(permits 构造时固定),**新建对话**用新值;
|
||||
/// 为使热改立即全量生效,set_per_conv 同时清空 HashMap 强制重建(软收敛:旧 permit 随 Drop 释放)。
|
||||
per_conv_permits: Arc<AtomicUsize>,
|
||||
/// F-260614-04: per-provider 信号量表。空 = 无 per-provider 限流(单 provider 路径零变化)。
|
||||
/// Arc<Mutex<HashMap>>:运行时增删 provider 配置时替换/插入,acquire 时 clone Arc。
|
||||
per_provider: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
/// Phase3 预留(批2-B): per-sub_flow 信号量表。key = sub_flow_id(约定 "{conv_id}::{sub_id}")。
|
||||
/// 空 = 无子流并发限流(Phase3 未接入时零变化)。acquire 用 try_acquire_owned(非阻塞),
|
||||
/// 耗尽返 None 降级串行,子流并行不阻塞主 loop。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
per_sub_flow: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
/// Phase3 预留(批2-B): 每子流并发上限(permits 默认 3,内部初始化,预留)。
|
||||
/// AtomicUsize 支持运行时热改;set_per_sub_permits 同时清空 HashMap(软收敛,对齐 set_per_conv)。
|
||||
/// new(global, per_conv) 签名保持向后兼容,per_sub_permits 内部 AtomicUsize::new(3)。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
per_sub_permits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl LlmConcurrency {
|
||||
pub fn new(global: usize, per_conv: usize) -> Self {
|
||||
Self {
|
||||
global: Arc::new(Mutex::new(Arc::new(Semaphore::new(global)))),
|
||||
per_conv: Arc::new(Mutex::new(HashMap::new())),
|
||||
per_conv_permits: Arc::new(AtomicUsize::new(per_conv)),
|
||||
per_provider: Arc::new(Mutex::new(HashMap::new())),
|
||||
// Phase3 预留(批2-B): per_sub_flow 默认 permits=3,内部初始化。
|
||||
// 签名保持 new(global, per_conv) 向后兼容;Phase3 接入后用户可经
|
||||
// set_per_sub_permits 热改(配置化待后续批,本批仅占位)。
|
||||
per_sub_flow: Arc::new(Mutex::new(HashMap::new())),
|
||||
per_sub_permits: Arc::new(AtomicUsize::new(3)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取全局 LLM 调用并发 permit:由各单次 LLM 调用点(stream_llm/标题/压缩/提炼/项目分析)
|
||||
/// 各自调用、调用结束 Drop 释放。F-09 batch5 修正后不再由 run_agentic_loop 入口持整 loop。
|
||||
/// 重建后(set_global)新请求自动走最新 Semaphore。
|
||||
pub async fn acquire_global(&self) -> tokio::sync::OwnedSemaphorePermit {
|
||||
let sema = self.global.lock().await.clone();
|
||||
sema.acquire_owned().await.expect("llm global semaphore closed")
|
||||
}
|
||||
|
||||
/// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphore,permits=当前 per_conv_permits(默认 2)。
|
||||
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
|
||||
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {
|
||||
let sema = {
|
||||
let mut map = self.per_conv.lock().await;
|
||||
let permits = self.per_conv_permits.load(std::sync::atomic::Ordering::SeqCst);
|
||||
map.entry(conv_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
|
||||
.clone()
|
||||
};
|
||||
sema.acquire_owned().await.expect("llm per_conv semaphore closed")
|
||||
}
|
||||
|
||||
/// F-09 B 批5:conv 退出清理。loop 结束 + 无 pending 审批时 remove 该 conv 的 Semaphore 条目。
|
||||
/// **时机由调用方判断**(agentic loop 退出点):仅在确信无后续 acquire 时调用,否则误删会致
|
||||
/// 该 conv 下次 acquire 重建 Semaphore(限流计数清零,非致命,但语义偏离)。
|
||||
/// remove 后已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
|
||||
pub async fn release_conv(&self, conv_id: &str) {
|
||||
self.per_conv.lock().await.remove(conv_id);
|
||||
}
|
||||
|
||||
/// 重建全局 Semaphore(软收敛:旧 permit 不回收,待其释放后新限制完全生效)
|
||||
pub async fn set_global(&self, permits: usize) {
|
||||
*self.global.lock().await = Arc::new(Semaphore::new(permits));
|
||||
}
|
||||
|
||||
/// 重建单对话 Semaphore(F-09 B 批5 后 per_conv 为 HashMap)。
|
||||
/// 行为:更新 per_conv_permits(AtomicUsize) + 清空 HashMap(软收敛:旧 permit 随 Drop 释放,
|
||||
/// 新对话 acquire 用新 permits 值重建 Semaphore)。已建对话若仍在跑,旧 Semaphore 不变;
|
||||
/// 下次该 conv 新 acquire 时因 HashMap 已清空会重建为新 permits。
|
||||
pub async fn set_per_conv(&self, permits: usize) {
|
||||
self.per_conv_permits
|
||||
.store(permits, std::sync::atomic::Ordering::SeqCst);
|
||||
self.per_conv.lock().await.clear();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase3 预留(批2-B): per_sub_flow 层 — 占位未接入调用点
|
||||
// ============================================================
|
||||
|
||||
/// Phase3 预留(批2-B): 取单子流内并发 permit(非阻塞)。
|
||||
///
|
||||
/// 按 `sub_flow_id` 取/建 Semaphore(permits=当前 per_sub_permits,默认 3)。
|
||||
/// lock HashMap → 无则建(or_insert_with, permits 取 per_sub_permits 当前值)
|
||||
/// → clone Arc → 释放 lock → **try_acquire_owned**(非阻塞)。
|
||||
///
|
||||
/// **非阻塞语义(关键)**:用 try_acquire_owned 而非 acquire_owned——耗尽返 None
|
||||
/// 调用方降级串行处理,子流并行不阻塞主 loop(Phase3 子流 spawn 失败不拖垮整对话)。
|
||||
///
|
||||
/// - 返 `Ok(Some(permit))`:成功获取,permit 随 Drop 自动释放。
|
||||
/// - 返 `Ok(None)`:子流并发已耗尽,调用方降级串行(非错误)。
|
||||
///
|
||||
/// **key 约定**:`sub_flow_id` 采用 `"{conv_id}::{sub_id}"` 格式(文档化,调用方组装)。
|
||||
/// 锁持有短(不含 try_acquire),对齐 acquire_per_conv 模式。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
pub async fn acquire_per_sub_flow(
|
||||
&self,
|
||||
sub_flow_id: &str,
|
||||
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||||
let sema = {
|
||||
let mut map = self.per_sub_flow.lock().await;
|
||||
let permits = self.per_sub_permits.load(std::sync::atomic::Ordering::SeqCst);
|
||||
map.entry(sub_flow_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
|
||||
.clone()
|
||||
};
|
||||
// try_acquire_owned 非阻塞:Err(NoPermits) 返 None 降级串行,不阻塞主 loop。
|
||||
// 对齐 acquire_global/per_conv 的 expect 前提:Semaphore 不会 close(无 close() 调用)。
|
||||
sema.try_acquire_owned().ok()
|
||||
}
|
||||
|
||||
/// Phase3 预留(批2-B): 子流完成清理。remove 该 sub_flow 的 Semaphore 条目(防 HashMap 无限增长)。
|
||||
///
|
||||
/// **对齐 release_conv 模式**:remove 后已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),
|
||||
/// 仅阻止新条目累积。时机由调用方判断(Phase3 子流退出点)。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
pub async fn release_sub_flow(&self, sub_flow_id: &str) {
|
||||
self.per_sub_flow.lock().await.remove(sub_flow_id);
|
||||
}
|
||||
|
||||
/// Phase3 预留(批2-B): 热改 per_sub_flow permits 上限。
|
||||
///
|
||||
/// 行为(对齐 set_per_conv):更新 per_sub_permits(AtomicUsize) + 清空 HashMap(软收敛:
|
||||
/// 旧 permit 随 Drop 释放,新子流 acquire 用新 permits 值重建 Semaphore)。已建子流若仍在跑,
|
||||
/// 旧 Semaphore 不变;下次该 sub_flow 新 acquire 时因 HashMap 已清空会重建为新 permits。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
pub async fn set_per_sub_permits(&self, permits: usize) {
|
||||
self.per_sub_permits
|
||||
.store(permits, std::sync::atomic::Ordering::SeqCst);
|
||||
self.per_sub_flow.lock().await.clear();
|
||||
}
|
||||
|
||||
/// F-260614-04: 取 per-provider 并发 permit(可选)。
|
||||
///
|
||||
/// - provider 在 `per_provider` 表中有配置 → 取其 Semaphore permit,返回 Some。
|
||||
/// - provider 无配置(单 provider 场景或未 set_provider_caps)→ 返回 None,无限流。
|
||||
///
|
||||
/// 调用方(agentic loop)用法:
|
||||
/// ```ignore
|
||||
/// let _global_permit = llm_concurrency.acquire_global().await;
|
||||
/// let _per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await;
|
||||
/// let _provider_permit = llm_concurrency.acquire_for_provider(&provider_id).await;
|
||||
/// ```
|
||||
/// 三 permit 均绑 guard Drop 自动释放。None 时无 permit 需释放(行为同 F-01 前)。
|
||||
pub async fn acquire_for_provider(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||||
let sema = {
|
||||
let map = self.per_provider.lock().await;
|
||||
map.get(provider_id).cloned()
|
||||
}?;
|
||||
// Semaphore 存在 → acquire。expect 同 acquire_global/per_conv:Semaphore 不会 close
|
||||
// (无 close() 调用,仅在 set_provider_caps 时替换表内 Arc,旧 Arc permit 仍有效)。
|
||||
Some(
|
||||
sema.acquire_owned()
|
||||
.await
|
||||
.expect("llm per_provider semaphore closed"),
|
||||
)
|
||||
}
|
||||
|
||||
/// F-260614-04: 批量设置 per-provider 并发上限(替换整表)。
|
||||
///
|
||||
/// 调用方(Settings 配置热改 / 启动初始化)传入 `{ provider_id: permits }` map,
|
||||
/// 替换整张 per_provider 表(软收敛:已持 permit 不回收)。全局容量约束 min(sum, global_cap)
|
||||
/// 由调用方在构造 map 时应用(本函数不做强约束,只落表)。
|
||||
///
|
||||
/// 传空 map → 清空 per_provider 表(所有 provider 回退到无 per-provider 限流)。
|
||||
pub async fn set_provider_caps(&self, caps: HashMap<String, usize>) {
|
||||
let mut map = self.per_provider.lock().await;
|
||||
map.clear();
|
||||
for (pid, permits) in caps {
|
||||
// permits=0 等同无配置(Semaphore::new(0) 永远 acquire 不到 → 死锁),
|
||||
// 故 permits=0 跳过(不落表 → acquire_for_provider 返 None → 无限流)。
|
||||
if permits > 0 {
|
||||
map.insert(pid, Arc::new(Semaphore::new(permits)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用全局状态 — 通过 `app.manage()` 注入,command 中以 `State<'_, AppState>` 取用
|
||||
pub struct AppState {
|
||||
/// 数据库句柄(Arc 包装,便于在异步任务中重建 Repo)
|
||||
@@ -347,6 +71,10 @@ pub struct AppState {
|
||||
/// (设计 §2.3 + §五 add_project_service/list_project_services)。
|
||||
/// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。
|
||||
pub project_services: ProjectServiceRepo,
|
||||
/// 工程表 Repo(工程系统 V34,project_modules)。
|
||||
/// 项目多工程(每个工程独立代码仓库):Monorepo 多仓库 / 微服务 / 前后端分离。
|
||||
/// 记录工程元数据(路径/Git 地址/技术栈);Git 状态(分支/改动/提交)实时派生不存表。
|
||||
pub project_modules: ProjectModuleRepo,
|
||||
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
||||
#[allow(dead_code)]
|
||||
pub releases: ReleaseRepo,
|
||||
@@ -410,6 +138,11 @@ pub struct AppState {
|
||||
/// 任何 token;流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay +
|
||||
/// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3)。
|
||||
pub agent_max_retries: Arc<AtomicUsize>,
|
||||
// ── 审批超时配置 ──
|
||||
/// 审批超时分钟数(前端 Settings 配置 → AppState 字段 → try_continue 入口读取)。
|
||||
/// 待审批超过此时长后自动取消,避免用户离开后会话永久卡死。
|
||||
/// 默认 15 分钟;0 表示禁用超时(不推荐,会导致卡死无自愈)。
|
||||
pub approval_timeout_minutes: Arc<AtomicU64>,
|
||||
// ── 工作流执行状态 ──
|
||||
/// 工作流执行 → 节点状态机注册表
|
||||
///
|
||||
@@ -432,225 +165,6 @@ pub struct AppState {
|
||||
pub tunnel: std::sync::Arc<df_tunnel::WsTunnelClient>,
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
|
||||
///
|
||||
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||||
/// - workspace_root 始终视为已授权(向后兼容,默认根)
|
||||
/// - 任一 persistent 目录 starts_with 命中即放行
|
||||
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
|
||||
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
|
||||
/// process_tool_calls 预校验均读此字段,两端一致。
|
||||
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AllowedDirs {
|
||||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||||
pub persistent: HashSet<PathBuf>,
|
||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。
|
||||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||||
pub session: HashSet<PathBuf>,
|
||||
/// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs
|
||||
/// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。
|
||||
/// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。
|
||||
pub once: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl AllowedDirs {
|
||||
/// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组)
|
||||
pub const SETTINGS_KEY: &'static str = "allowed_dirs";
|
||||
|
||||
/// 空白名单(方案①弱化 workspace_root:不再编译期硬塞开发机 CARGO_MANIFEST_DIR)。
|
||||
/// 分发后该路径指向编译机不存在的目录,硬塞反成脏白名单;用户首次访问任意目录
|
||||
/// 走弹窗三档授权(once/session/always),对齐产品定位(不预设源码目录)。仅作 init
|
||||
/// 占位(reload_allowed_dirs 从 KV 覆盖)+ 测试构造基线。
|
||||
pub fn default_with_root() -> Self {
|
||||
Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 取首个 persistent 授权目录,作为相对路径锚定基准。
|
||||
/// 无任何持久授权时返回 None,调用方应引导用户绑定项目。
|
||||
pub fn first_persistent_dir(&self) -> Option<&PathBuf> {
|
||||
self.persistent.iter().next()
|
||||
}
|
||||
|
||||
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs
|
||||
/// KV 未配时也不塞),首次访问工程根走弹窗三档授权。用户授权(always/session/once)后
|
||||
/// 落白名单,后续命中放行(动态白名单完整语义,F-260619-03 收尾)。
|
||||
///
|
||||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||||
/// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。
|
||||
///
|
||||
/// 输入 `candidate` 理想为 canonicalize 后的真实路径(防 symlink 逃逸);调用方
|
||||
/// (resolve_workspace_path_with_allowed)负责 canonicalize。但词法层预校验
|
||||
/// (`check_path_authorization`)**故意不 canonicalize**(路径可能不存在,如 write_file 新建),
|
||||
/// 会传入未归一的词法路径 → 与白名单(canonicalize 后)形态不对齐 → 已授权路径反复误弹窗。
|
||||
/// 故本函数内部 best-effort canonicalize 兜底(失败回退词法),消除所有调用方形态差异。
|
||||
/// 白名单仍是唯一真相源,canonicalize 不引入新放行,顺带解析 symlink 增强(非削弱)防逃逸。
|
||||
pub fn is_authorized(&self, candidate: &Path) -> bool {
|
||||
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
|
||||
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
|
||||
if is_in_system_blacklist(candidate) {
|
||||
return false;
|
||||
}
|
||||
// F-260620 比对侧收口(误弹窗核心根因):白名单 persistent/session 是 canonicalize 后形态,
|
||||
// candidate 来自两类调用方形态不一(handler canonicalize 后 / 预校验词法未 canonicalize)。
|
||||
// 仅 strip_verbatim(去 \\?\ 前缀)不够——盘符大小写/.. /symlink/分隔符差异仍让 starts_with
|
||||
// 失败。best_effort_canonicalize 把 candidate 归一到真实路径(失败回退词法,不阻断合法访问),
|
||||
// 再 strip_verbatim 去前缀,使比对双侧形态完全对齐。e2ece2d 只收口 verbatim 前缀漏了这步。
|
||||
let cand = strip_verbatim(best_effort_canonicalize(candidate));
|
||||
self.persistent.iter().any(|d| cand.starts_with(d))
|
||||
|| self.session.iter().any(|d| cand.starts_with(d))
|
||||
|| self.once.iter().any(|d| cand.starts_with(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。
|
||||
///
|
||||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
||||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathAuthDecision {
|
||||
/// 已授权(workspace_root / persistent / session 命中且非黑名单)
|
||||
Authorized,
|
||||
/// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录,
|
||||
/// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。
|
||||
NeedsAuthorization { dir: PathBuf },
|
||||
/// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。
|
||||
///
|
||||
/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致
|
||||
/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等,
|
||||
/// 此处补系统核心目录。
|
||||
///
|
||||
/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、
|
||||
/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64
|
||||
/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys`
|
||||
pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
let seps = ['/', '\\'];
|
||||
// Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名)
|
||||
let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect();
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
// Windows 系统目录:C:\Windows(根本身及所有子目录 win.ini/explorer.exe/hosts 等)/
|
||||
// C:\Program Files / C:\Program Files (x86) / C:\ProgramData
|
||||
if cfg!(windows) {
|
||||
// windows 段命中即拒(含根本身,不再限定 system32 子目录 — 防 win.ini/hosts 等)
|
||||
if *seg == "windows" {
|
||||
return true;
|
||||
}
|
||||
// C:\Program Files / C:\Program Files (x86)
|
||||
if *seg == "program files" || *seg == "program files (x86)" {
|
||||
return true;
|
||||
}
|
||||
// C:\ProgramData(系统级应用数据)
|
||||
if *seg == "programdata" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 用户级凭据目录(.ssh/.aws/.gnupg):任意路径段命中即拒(跨平台,从 validate_path 迁移统一,
|
||||
// 消除 validate_path contains 与 is_in_system_blacklist 分段两套黑名单不一致)
|
||||
if matches!(*seg, ".ssh" | ".aws" | ".gnupg") {
|
||||
return true;
|
||||
}
|
||||
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
|
||||
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
|
||||
///
|
||||
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
|
||||
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied`
|
||||
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
|
||||
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
|
||||
///
|
||||
/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内
|
||||
/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。
|
||||
/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。
|
||||
pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else if let Some(root) = allowed.first_persistent_dir() {
|
||||
root.join(raw_path)
|
||||
} else {
|
||||
// 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。
|
||||
return PathAuthDecision::NeedsAuthorization {
|
||||
dir: PathBuf::from("."),
|
||||
};
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
if is_in_system_blacklist(&resolved) {
|
||||
return PathAuthDecision::Denied {
|
||||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||||
};
|
||||
}
|
||||
if allowed.is_authorized(&resolved) {
|
||||
return PathAuthDecision::Authorized;
|
||||
}
|
||||
// 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义)
|
||||
let dir = resolved
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
PathAuthDecision::NeedsAuthorization { dir }
|
||||
}
|
||||
|
||||
/// Windows canonicalize 返回 `\\?\E:\...` 扩展路径(verbatim 前缀),与词法层(不 canonicalize)
|
||||
/// 的 starts_with 比对不一致 → 白名单含但工具路径不匹配 → 误弹窗。strip 前缀统一为 `E:\...`。
|
||||
fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
// Windows verbatim/设备路径前缀:\\?\ (Volume canonicalize)、\\.\ (设备命名空间)、
|
||||
// \??\ (对象管理器命名空间)。统一 strip 后与词法层 starts_with 比对一致。
|
||||
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
|
||||
if let Some(stripped) = s.strip_prefix(prefix) {
|
||||
return PathBuf::from(stripped);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// best-effort canonicalize:把任意形态路径(词法/正斜杠/含../大小写不一)归一到真实路径,
|
||||
/// 供 `is_authorized` 与白名单(canonicalize 后)比对侧形态对齐(误弹窗根因修复)。
|
||||
///
|
||||
/// - 路径存在 → `canonicalize` 成功,返回真实路径(大小写归一/.. 解析/symlink 解析/去冗余分隔符)
|
||||
/// - 路径不存在(write_file 新建文件)→ canonicalize 其**父目录**(通常存在)+ 拼回文件名,
|
||||
/// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断)
|
||||
///
|
||||
/// 失败一律回退,绝不返回 Err——授权比对宁可降级匹配不可 panic/阻断合法访问。
|
||||
fn best_effort_canonicalize(p: &Path) -> PathBuf {
|
||||
if let Ok(real) = std::fs::canonicalize(p) {
|
||||
return real;
|
||||
}
|
||||
if let Some(parent) = p.parent() {
|
||||
if let Ok(real_parent) = std::fs::canonicalize(parent) {
|
||||
if let Some(file_name) = p.file_name() {
|
||||
return real_parent.join(file_name);
|
||||
}
|
||||
return real_parent;
|
||||
}
|
||||
}
|
||||
p.to_path_buf()
|
||||
}
|
||||
|
||||
/// workspace 根目录(当前:编译期 CARGO_MANIFEST_DIR 上两级,仅开发机有效)。
|
||||
///
|
||||
/// ⚠️ 分发限制:env!("CARGO_MANIFEST_DIR") 是编译期常量,打包后指向编译机路径,用户机器无效。
|
||||
@@ -668,14 +182,6 @@ fn workspace_root_path() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 应用数据目录(运行期确定,跨平台)。
|
||||
/// 不依赖编译期常量,打包分发后仍有效。
|
||||
/// 用于 DevFlow 自身数据存储(.trash / logs 等),非用户项目目录。
|
||||
/// 取 AppState.data_dir,不持 state 时回退 workspace_root_path()(开发期兼容)。
|
||||
pub fn data_dir_path(state: &AppState) -> &Path {
|
||||
&state.data_dir
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
|
||||
pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
|
||||
@@ -702,6 +208,7 @@ impl AppState {
|
||||
task_links: TaskLinkRepo::new(&db),
|
||||
project_events: ProjectEventRepo::new(&db),
|
||||
project_services: ProjectServiceRepo::new(&db),
|
||||
project_modules: ProjectModuleRepo::new(&db),
|
||||
releases: ReleaseRepo::new(&db),
|
||||
workflows: WorkflowRepo::new(&db),
|
||||
node_executions: NodeExecutionRepo::new(&db),
|
||||
@@ -726,6 +233,7 @@ impl AppState {
|
||||
agent_max_retries: Arc::new(AtomicUsize::new(
|
||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
|
||||
)),
|
||||
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
|
||||
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
|
||||
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
|
||||
allowed_dirs: allowed_dirs.clone(),
|
||||
@@ -750,14 +258,17 @@ impl AppState {
|
||||
state.reload_allowed_dirs().await;
|
||||
// P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。
|
||||
state.reload_knowledge_config().await;
|
||||
// 从 Settings KV 恢复审批超时配置(默认 15 分钟,0=禁用超时)。
|
||||
state.reload_approval_timeout().await;
|
||||
|
||||
// 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
|
||||
let old_trash = workspace_root_path().join(".trash");
|
||||
let new_trash = data_dir.join(".trash");
|
||||
if old_trash.exists() && !new_trash.exists() {
|
||||
if let Err(e) = std::fs::rename(&old_trash, &new_trash) {
|
||||
tracing::warn!(
|
||||
"迁移 .trash 失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
|
||||
// 跨盘 rename 失败(E:→C:),静默跳过,新目录自动创建
|
||||
tracing::debug!(
|
||||
"迁移 .trash 跨盘失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
|
||||
old_trash, new_trash, e,
|
||||
);
|
||||
}
|
||||
@@ -806,6 +317,32 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Settings KV 恢复审批超时分钟数(默认 15 分钟,0=禁用)。
|
||||
///
|
||||
/// KV key `df-approval-timeout` 与前端 AdvancedSection.vue 共用,值为毫秒数(0/300000/900000/...)。
|
||||
/// 启动时读取用户配置,失败或首次启动无配置时保持默认 15 分钟。
|
||||
pub async fn reload_approval_timeout(&self) {
|
||||
match self.settings.get(APPROVAL_TIMEOUT_KEY).await {
|
||||
Ok(Some(json)) => {
|
||||
// 兼容两种格式:JSON 数字或字符串(纯数字字串)
|
||||
let ms: u64 = if let Ok(v) = serde_json::from_str::<u64>(&json) {
|
||||
v
|
||||
} else if let Ok(s) = serde_json::from_str::<String>(&json) {
|
||||
s.parse().unwrap_or(900_000)
|
||||
} else {
|
||||
tracing::warn!("[APPROVAL-TIMEOUT] KV 格式非法,保持 default: {}", json);
|
||||
900_000
|
||||
};
|
||||
// ms → minutes(向上取整,0 保留为 0 表示禁用)
|
||||
let mins = if ms == 0 { 0 } else { (ms + 59_999) / 60_000 };
|
||||
self.approval_timeout_minutes.store(mins, Ordering::SeqCst);
|
||||
tracing::info!("[APPROVAL-TIMEOUT] 启动恢复: {} 分钟 (KV ms={})", mins, ms);
|
||||
}
|
||||
Ok(None) => {} // 首次启动保持默认 15
|
||||
Err(e) => tracing::warn!("[APPROVAL-TIMEOUT] 读 KV 失败,保持 default: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。
|
||||
///
|
||||
/// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持
|
||||
@@ -843,8 +380,13 @@ impl AppState {
|
||||
all_dirs.sort();
|
||||
all_dirs.dedup();
|
||||
let mut set = HashSet::new();
|
||||
// 方案①:KV + 项目绑定均空时不再硬塞 workspace_root(分发后该路径无效)。
|
||||
// 用户首次访问任意目录走弹窗三档授权,授权后落 KV 持久。
|
||||
// 分发适配治本方案:不再硬塞编译期 workspace_root(CARGO_MANIFEST_DIR)。
|
||||
// 原因:该路径在编译机上有效,分发到其他机器后指向不存在的目录,硬塞反成脏白名单。
|
||||
// 工程根授权完全靠以下两途径(均已就绪):
|
||||
// 1. projects.bind_directory:用户绑定项目时自动授权(已在上方 project_dirs 合并)
|
||||
// 2. Settings 页 allowed_dirs:用户手动添加持久化白名单(已在上方 kv_dirs 合并)
|
||||
// dev 自用场景:开发机运行时 projects.path 通常含本工程,自然命中白名单。
|
||||
// workspace_root_path() 函数保留:旧 .trash 迁移(init 中一次性)仍需读取。
|
||||
for d in all_dirs {
|
||||
let d = d.trim();
|
||||
if d.is_empty() {
|
||||
@@ -887,8 +429,7 @@ impl AppState {
|
||||
self.settings.set(AllowedDirs::SETTINGS_KEY, &json).await
|
||||
.map_err(|e| anyhow::anyhow!("持久化 allowed_dirs 失败: {}", e))?;
|
||||
self.reload_allowed_dirs().await;
|
||||
// 返回内存白名单的规范化字符串列表:复用 get_allowed_dirs(单一真相源,
|
||||
// 消除原 9 行逐字重复——过滤 workspace_root + sort,语义完全一致)。
|
||||
// 返回内存白名单的规范化字符串列表(供 Settings IPC ai_get_allowed_dirs 回显)。
|
||||
Ok(self.get_allowed_dirs().await)
|
||||
}
|
||||
|
||||
@@ -907,7 +448,7 @@ impl AppState {
|
||||
/// F-260619-03 Phase B: 追加持久化授权目录(弹窗"未来都允许"选项)。
|
||||
///
|
||||
/// 读当前 persistent → 追加新目录(去重)→ set_allowed_dirs 持久化 + 同步内存。
|
||||
/// 返回持久化后规范化列表(含 workspace_root)。
|
||||
/// 返回持久化后规范化列表。
|
||||
pub async fn add_persistent_allowed_dir(&self, dir: String) -> Result<Vec<String>> {
|
||||
let d = dir.trim().to_string();
|
||||
if d.is_empty() {
|
||||
@@ -968,13 +509,6 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||||
fn paths_eq(a: &str, b: &str) -> bool {
|
||||
let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string());
|
||||
let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string());
|
||||
pa.eq_ignore_ascii_case(&pb)
|
||||
}
|
||||
|
||||
/// 构建节点注册表 — 注册内置节点
|
||||
///
|
||||
/// 注意:不使用 `NodeRegistry::default()`,其 script 工厂为占位实现(会 panic),
|
||||
@@ -1021,204 +555,11 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase A: AllowedDirs 授权语义测试
|
||||
// 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。
|
||||
// ============================================================
|
||||
|
||||
/// 显式授权 workspace_root 后命中(方案①:default_with_root 不再预设,需显式授权)
|
||||
#[test]
|
||||
fn test_allowed_dirs_workspace_root_authorized() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let root = workspace_root_path();
|
||||
assert!(allowed.is_authorized(&root), "显式授权的 workspace_root 应命中");
|
||||
// workspace 内子路径也应授权(starts_with workspace_root)
|
||||
let child = root.join("src").join("main.rs");
|
||||
assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权");
|
||||
}
|
||||
|
||||
/// 自定义授权目录命中放行(模拟用户授权 E:/some/external/dir)
|
||||
#[test]
|
||||
fn test_allowed_dirs_custom_authorized() {
|
||||
let mut set = HashSet::new();
|
||||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||||
set.insert(custom.clone());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
// 精确命中 + 子路径 starts_with 命中
|
||||
assert!(allowed.is_authorized(&custom));
|
||||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||||
}
|
||||
|
||||
/// 未授权目录被拒绝
|
||||
#[test]
|
||||
fn test_allowed_dirs_unauthorized_rejected() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||||
}
|
||||
|
||||
/// 默认(空 persistent)只授权 workspace_root
|
||||
#[test]
|
||||
fn test_allowed_dirs_default_empty_persistent() {
|
||||
let allowed = AllowedDirs::default();
|
||||
// 空 persistent,无 workspace_root → 任何路径都不授权
|
||||
// (default_with_root 才含 workspace_root;Default 不含,用于边界测试)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/anything")));
|
||||
}
|
||||
|
||||
/// SETTINGS_KEY 常量稳定(防 rename 致持久化数据丢失)
|
||||
#[test]
|
||||
fn test_allowed_dirs_settings_key_stable() {
|
||||
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B: session 临时授权语义测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B: session 命中放行(persistent 未命中但 session 命中)
|
||||
#[test]
|
||||
fn test_allowed_dirs_session_authorized() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||||
}
|
||||
|
||||
/// Phase B: persistent + session 任一命中即放行
|
||||
#[test]
|
||||
fn test_allowed_dirs_persistent_or_session() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent, session, once: HashSet::new() };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase C: 系统目录黑名单测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase C: Windows System32 黑名单命中(即使在白名单内也拒)
|
||||
#[test]
|
||||
fn test_blacklist_windows_system32() {
|
||||
let mut persistent = HashSet::new();
|
||||
// 用户误把整个 C:\ 加入白名单
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||||
}
|
||||
|
||||
/// Phase C: Windows Program Files 黑名单命中
|
||||
#[test]
|
||||
fn test_blacklist_windows_program_files() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名)
|
||||
#[test]
|
||||
fn test_blacklist_no_false_positive() {
|
||||
// "my program files backup" 不应被拒(分段匹配,非精确段)
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x")));
|
||||
// 普通工作目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src")));
|
||||
}
|
||||
|
||||
/// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等)
|
||||
#[test]
|
||||
fn test_blacklist_unix_system_dirs() {
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel")));
|
||||
// 普通用户目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B/C: 显式授权 workspace_root 后,内路径 → Authorized(方案①:不再默认)
|
||||
#[test]
|
||||
fn test_check_path_authorized_workspace() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let rel = "src/main.rs";
|
||||
match check_path_authorization(rel, &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("workspace_root 内路径应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录)
|
||||
#[test]
|
||||
fn test_check_path_needs_auth() {
|
||||
let allowed = AllowedDirs::default(); // 空,无 workspace_root
|
||||
match check_path_authorization("E:/wk-external/file.txt", &allowed) {
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
assert_eq!(dir, PathBuf::from("E:/wk-external"));
|
||||
}
|
||||
other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段)
|
||||
#[test]
|
||||
fn test_check_path_session_hit() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单路径 → Denied(不弹窗直接拒)
|
||||
#[test]
|
||||
fn test_check_path_denied_blacklist() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
match check_path_authorization("C:/Windows/System32/config/sam", &allowed) {
|
||||
PathAuthDecision::Denied { .. } => {}
|
||||
other => panic!("System32 应 Denied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试
|
||||
// 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。
|
||||
// ============================================================
|
||||
|
||||
/// F-260620: is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单
|
||||
/// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。
|
||||
/// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。
|
||||
#[test]
|
||||
fn test_is_authorized_strips_verbatim_prefix() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let child = workspace_root_path().join("src").join("main.rs");
|
||||
// candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单
|
||||
let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy()));
|
||||
assert!(allowed.is_authorized(&verbatim), "verbatim 前缀路径应命中白名单");
|
||||
// 设备命名空间前缀(\\.\)
|
||||
let dev = PathBuf::from(format!("{}{}", r"\\.\", child.to_string_lossy()));
|
||||
assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单");
|
||||
}
|
||||
|
||||
/// F-260620: strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除
|
||||
#[test]
|
||||
fn test_strip_verbatim_three_prefixes() {
|
||||
@@ -1229,23 +570,6 @@ mod tests {
|
||||
assert_eq!(strip_verbatim(PathBuf::from("E:\\foo")), PathBuf::from("E:\\foo"));
|
||||
}
|
||||
|
||||
/// F-260620: 黑名单增强 — Windows 根本身 / ProgramData / 用户凭据目录(.ssh/.aws/.gnupg)
|
||||
#[test]
|
||||
fn test_blacklist_windows_root_programdata_creds() {
|
||||
// C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等)
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\win.ini")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\explorer.exe")));
|
||||
// ProgramData
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\ProgramData\\App\\config")));
|
||||
// 用户凭据目录(从 validate_path 迁移统一,任意路径段命中即拒)
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Users\\user\\.ssh\\id_rsa")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.aws/credentials")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.gnupg/pubring.gpg")));
|
||||
// 普通目录不误伤(devflow 自身 / 含 appdata 段的合法备份目录)
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src")));
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase3 预留(批2-B): LlmConcurrency per_sub_flow 层测试
|
||||
// 占位层:acquire_per_sub_flow / release_sub_flow / set_per_sub_permits。
|
||||
|
||||
481
src-tauri/src/state/allowed_dirs.rs
Normal file
481
src-tauri/src/state/allowed_dirs.rs
Normal file
@@ -0,0 +1,481 @@
|
||||
//! F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单模块
|
||||
//!
|
||||
//! 从 `state.rs` 抽离的授权相关类型/函数:`AllowedDirs` 三档白名单
|
||||
//! (persistent / session / once)、`PathAuthDecision` 三态决策、
|
||||
//! `check_path_authorization` 词法层预校验、`is_in_system_blacklist`
|
||||
//! 系统敏感目录黑名单,以及 `strip_verbatim` / `best_effort_canonicalize`
|
||||
//! / `paths_eq` 辅助函数。
|
||||
//!
|
||||
//! 注:`workspace_root_path` 仍保留在 `state.rs`(旧 `.trash` 迁移仍需);
|
||||
//! 本模块测试内定义本地副本避免跨文件耦合。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
|
||||
///
|
||||
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||||
/// - 任一 persistent 或 session 目录 starts_with 命中即放行
|
||||
/// - 无任何授权时全部拒绝(引导用户绑定项目或授权目录)
|
||||
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
|
||||
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
|
||||
/// process_tool_calls 预校验均读此字段,两端一致。
|
||||
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AllowedDirs {
|
||||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||||
pub persistent: HashSet<PathBuf>,
|
||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。
|
||||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||||
pub session: HashSet<PathBuf>,
|
||||
/// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs
|
||||
/// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。
|
||||
/// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。
|
||||
pub once: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl AllowedDirs {
|
||||
/// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组)
|
||||
pub const SETTINGS_KEY: &'static str = "allowed_dirs";
|
||||
|
||||
/// 空白名单(方案①弱化 workspace_root:不再编译期硬塞开发机 CARGO_MANIFEST_DIR)。
|
||||
/// 分发后该路径指向编译机不存在的目录,硬塞反成脏白名单。
|
||||
/// 当前仅测试使用,生产环境由 reload_allowed_dirs 覆盖。
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn default_with_root() -> Self {
|
||||
Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 取首个 persistent 授权目录,作为相对路径锚定基准。
|
||||
/// 无任何持久授权时返回 None,调用方应引导用户绑定项目。
|
||||
pub fn first_persistent_dir(&self) -> Option<&PathBuf> {
|
||||
self.persistent.iter().next()
|
||||
}
|
||||
|
||||
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs
|
||||
/// KV 未配时也不塞),首次访问工程根走弹窗三档授权。用户授权(always/session/once)后
|
||||
/// 落白名单,后续命中放行(动态白名单完整语义,F-260619-03 收尾)。
|
||||
///
|
||||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||||
/// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。
|
||||
///
|
||||
/// 输入 `candidate` 理想为 canonicalize 后的真实路径(防 symlink 逃逸);调用方
|
||||
/// (resolve_workspace_path_with_allowed)负责 canonicalize。但词法层预校验
|
||||
/// (`check_path_authorization`)**故意不 canonicalize**(路径可能不存在,如 write_file 新建),
|
||||
/// 会传入未归一的词法路径 → 与白名单(canonicalize 后)形态不对齐 → 已授权路径反复误弹窗。
|
||||
/// 故本函数内部 best-effort canonicalize 兜底(失败回退词法),消除所有调用方形态差异。
|
||||
/// 白名单仍是唯一真相源,canonicalize 不引入新放行,顺带解析 symlink 增强(非削弱)防逃逸。
|
||||
pub fn is_authorized(&self, candidate: &Path) -> bool {
|
||||
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
|
||||
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
|
||||
if is_in_system_blacklist(candidate) {
|
||||
return false;
|
||||
}
|
||||
// F-260620 比对侧收口(误弹窗核心根因):白名单 persistent/session 是 canonicalize 后形态,
|
||||
// candidate 来自两类调用方形态不一(handler canonicalize 后 / 预校验词法未 canonicalize)。
|
||||
// 仅 strip_verbatim(去 \\?\ 前缀)不够——盘符大小写/.. /symlink/分隔符差异仍让 starts_with
|
||||
// 失败。best_effort_canonicalize 把 candidate 归一到真实路径(失败回退词法,不阻断合法访问),
|
||||
// 再 strip_verbatim 去前缀,使比对双侧形态完全对齐。e2ece2d 只收口 verbatim 前缀漏了这步。
|
||||
let cand = strip_verbatim(best_effort_canonicalize(candidate));
|
||||
self.persistent.iter().any(|d| cand.starts_with(d))
|
||||
|| self.session.iter().any(|d| cand.starts_with(d))
|
||||
|| self.once.iter().any(|d| cand.starts_with(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。
|
||||
///
|
||||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
||||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathAuthDecision {
|
||||
/// 已授权(workspace_root / persistent / session 命中且非黑名单)
|
||||
Authorized,
|
||||
/// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录,
|
||||
/// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。
|
||||
NeedsAuthorization { dir: PathBuf },
|
||||
/// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。
|
||||
///
|
||||
/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致
|
||||
/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等,
|
||||
/// 此处补系统核心目录。
|
||||
///
|
||||
/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、
|
||||
/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64
|
||||
/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys`
|
||||
pub fn is_in_system_blacklist(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
let seps = ['/', '\\'];
|
||||
// Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名)
|
||||
let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect();
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
// Windows 系统目录:C:\Windows(根本身及所有子目录 win.ini/explorer.exe/hosts 等)/
|
||||
// C:\Program Files / C:\Program Files (x86) / C:\ProgramData
|
||||
if cfg!(windows) {
|
||||
// windows 段命中即拒(含根本身,不再限定 system32 子目录 — 防 win.ini/hosts 等)
|
||||
if *seg == "windows" {
|
||||
return true;
|
||||
}
|
||||
// C:\Program Files / C:\Program Files (x86)
|
||||
if *seg == "program files" || *seg == "program files (x86)" {
|
||||
return true;
|
||||
}
|
||||
// C:\ProgramData(系统级应用数据)
|
||||
if *seg == "programdata" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 用户级凭据目录(.ssh/.aws/.gnupg):任意路径段命中即拒(跨平台,从 validate_path 迁移统一,
|
||||
// 消除 validate_path contains 与 is_in_system_blacklist 分段两套黑名单不一致)
|
||||
if matches!(*seg, ".ssh" | ".aws" | ".gnupg") {
|
||||
return true;
|
||||
}
|
||||
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
|
||||
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
|
||||
///
|
||||
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
|
||||
/// - 路径规范化(去 .. / 锚定首个持久授权目录)后,若命中黑名单 → `Denied`
|
||||
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
|
||||
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
|
||||
///
|
||||
/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内
|
||||
/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。
|
||||
/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。
|
||||
pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else if let Some(root) = allowed.first_persistent_dir() {
|
||||
root.join(raw_path)
|
||||
} else {
|
||||
// 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。
|
||||
return PathAuthDecision::NeedsAuthorization {
|
||||
dir: PathBuf::from("."),
|
||||
};
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
if is_in_system_blacklist(&resolved) {
|
||||
return PathAuthDecision::Denied {
|
||||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||||
};
|
||||
}
|
||||
if allowed.is_authorized(&resolved) {
|
||||
return PathAuthDecision::Authorized;
|
||||
}
|
||||
// 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义)
|
||||
let dir = resolved
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
PathAuthDecision::NeedsAuthorization { dir }
|
||||
}
|
||||
|
||||
/// Windows canonicalize 返回 `\\?\E:\...` 扩展路径(verbatim 前缀),与词法层(不 canonicalize)
|
||||
/// 的 starts_with 比对不一致 → 白名单含但工具路径不匹配 → 误弹窗。strip 前缀统一为 `E:\...`。
|
||||
pub fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
// Windows verbatim/设备路径前缀:\\?\ (Volume canonicalize)、\\.\ (设备命名空间)、
|
||||
// \??\ (对象管理器命名空间)。统一 strip 后与词法层 starts_with 比对一致。
|
||||
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
|
||||
if let Some(stripped) = s.strip_prefix(prefix) {
|
||||
return PathBuf::from(stripped);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// best-effort canonicalize:把任意形态路径(词法/正斜杠/含../大小写不一)归一到真实路径,
|
||||
/// 供 `is_authorized` 与白名单(canonicalize 后)比对侧形态对齐(误弹窗根因修复)。
|
||||
///
|
||||
/// - 路径存在 → `canonicalize` 成功,返回真实路径(大小写归一/.. 解析/symlink 解析/去冗余分隔符)
|
||||
/// - 路径不存在(write_file 新建文件)→ canonicalize 其**父目录**(通常存在)+ 拼回文件名,
|
||||
/// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断)
|
||||
///
|
||||
/// 失败一律回退,绝不返回 Err——授权比对宁可降级匹配不可 panic/阻断合法访问。
|
||||
pub(crate) fn best_effort_canonicalize(p: &Path) -> PathBuf {
|
||||
if let Ok(real) = std::fs::canonicalize(p) {
|
||||
return real;
|
||||
}
|
||||
if let Some(parent) = p.parent() {
|
||||
if let Ok(real_parent) = std::fs::canonicalize(parent) {
|
||||
if let Some(file_name) = p.file_name() {
|
||||
return real_parent.join(file_name);
|
||||
}
|
||||
return real_parent;
|
||||
}
|
||||
}
|
||||
p.to_path_buf()
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||||
pub fn paths_eq(a: &str, b: &str) -> bool {
|
||||
let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string());
|
||||
let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string());
|
||||
pa.eq_ignore_ascii_case(&pb)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase A: AllowedDirs 授权语义测试
|
||||
// 锁定:persistent 命中放行 + 未命中拒绝。
|
||||
// 注:workspace_root 不再自动塞白名单(分发适配方案①),
|
||||
// 下方测试用显式 set.insert 模拟用户手动授权场景。
|
||||
// ============================================================
|
||||
|
||||
/// workspace 根目录本地副本(原 `state.rs::workspace_root_path` 留在原文件
|
||||
/// 供旧 `.trash` 迁移使用;本模块测试需独立定义避免跨文件耦合)。
|
||||
fn workspace_root_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 显式授权目录后命中(模拟用户在 Settings 页添加目录)
|
||||
#[test]
|
||||
fn test_allowed_dirs_workspace_root_authorized() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let root = workspace_root_path();
|
||||
assert!(allowed.is_authorized(&root), "显式授权的 workspace_root 应命中");
|
||||
// workspace 内子路径也应授权(starts_with workspace_root)
|
||||
let child = root.join("src").join("main.rs");
|
||||
assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权");
|
||||
}
|
||||
|
||||
/// 自定义授权目录命中放行(模拟用户授权 E:/some/external/dir)
|
||||
#[test]
|
||||
fn test_allowed_dirs_custom_authorized() {
|
||||
let mut set = HashSet::new();
|
||||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||||
set.insert(custom.clone());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
// 精确命中 + 子路径 starts_with 命中
|
||||
assert!(allowed.is_authorized(&custom));
|
||||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||||
}
|
||||
|
||||
/// 未授权目录被拒绝
|
||||
#[test]
|
||||
fn test_allowed_dirs_unauthorized_rejected() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||||
}
|
||||
|
||||
/// 默认(空 persistent)只授权 workspace_root
|
||||
#[test]
|
||||
fn test_allowed_dirs_default_empty_persistent() {
|
||||
let allowed = AllowedDirs::default();
|
||||
// 空 persistent,无 workspace_root → 任何路径都不授权
|
||||
// (default_with_root 才含 workspace_root;Default 不含,用于边界测试)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/anything")));
|
||||
}
|
||||
|
||||
/// SETTINGS_KEY 常量稳定(防 rename 致持久化数据丢失)
|
||||
#[test]
|
||||
fn test_allowed_dirs_settings_key_stable() {
|
||||
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B: session 临时授权语义测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B: session 命中放行(persistent 未命中但 session 命中)
|
||||
#[test]
|
||||
fn test_allowed_dirs_session_authorized() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||||
}
|
||||
|
||||
/// Phase B: persistent + session 任一命中即放行
|
||||
#[test]
|
||||
fn test_allowed_dirs_persistent_or_session() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent, session, once: HashSet::new() };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase C: 系统目录黑名单测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase C: Windows System32 黑名单命中(即使在白名单内也拒)
|
||||
#[test]
|
||||
fn test_blacklist_windows_system32() {
|
||||
let mut persistent = HashSet::new();
|
||||
// 用户误把整个 C:\ 加入白名单
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||||
}
|
||||
|
||||
/// Phase C: Windows Program Files 黑名单命中
|
||||
#[test]
|
||||
fn test_blacklist_windows_program_files() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名)
|
||||
#[test]
|
||||
fn test_blacklist_no_false_positive() {
|
||||
// "my program files backup" 不应被拒(分段匹配,非精确段)
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x")));
|
||||
// 普通工作目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src")));
|
||||
}
|
||||
|
||||
/// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等)
|
||||
#[test]
|
||||
fn test_blacklist_unix_system_dirs() {
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel")));
|
||||
// 普通用户目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B/C: 显式授权 workspace_root 后,内路径 → Authorized(方案①:不再默认)
|
||||
#[test]
|
||||
fn test_check_path_authorized_workspace() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let rel = "src/main.rs";
|
||||
match check_path_authorization(rel, &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("workspace_root 内路径应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录)
|
||||
#[test]
|
||||
fn test_check_path_needs_auth() {
|
||||
let allowed = AllowedDirs::default(); // 空,无 workspace_root
|
||||
match check_path_authorization("E:/wk-external/file.txt", &allowed) {
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
assert_eq!(dir, PathBuf::from("E:/wk-external"));
|
||||
}
|
||||
other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段)
|
||||
#[test]
|
||||
fn test_check_path_session_hit() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单路径 → Denied(不弹窗直接拒)
|
||||
#[test]
|
||||
fn test_check_path_denied_blacklist() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
match check_path_authorization("C:/Windows/System32/config/sam", &allowed) {
|
||||
PathAuthDecision::Denied { .. } => {}
|
||||
other => panic!("System32 应 Denied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试
|
||||
// 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。
|
||||
// ============================================================
|
||||
|
||||
/// F-260620: is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单
|
||||
/// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。
|
||||
/// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。
|
||||
#[test]
|
||||
fn test_is_authorized_strips_verbatim_prefix() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let child = workspace_root_path().join("src").join("main.rs");
|
||||
// candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单
|
||||
let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy()));
|
||||
assert!(allowed.is_authorized(&verbatim), "verbatim 前缀路径应命中白名单");
|
||||
// 设备命名空间前缀(\\.\)
|
||||
let dev = PathBuf::from(format!("{}{}", r"\\.\", child.to_string_lossy()));
|
||||
assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单");
|
||||
}
|
||||
|
||||
/// F-260620: strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除
|
||||
#[test]
|
||||
fn test_strip_verbatim_three_prefixes() {
|
||||
assert_eq!(strip_verbatim(PathBuf::from(r"\\?\E:\foo")), PathBuf::from("E:\\foo"));
|
||||
assert_eq!(strip_verbatim(PathBuf::from(r"\\.\E:\foo")), PathBuf::from("E:\\foo"));
|
||||
assert_eq!(strip_verbatim(PathBuf::from(r"\??\E:\foo")), PathBuf::from("E:\\foo"));
|
||||
// 无前缀原样返回
|
||||
assert_eq!(strip_verbatim(PathBuf::from("E:\\foo")), PathBuf::from("E:\\foo"));
|
||||
}
|
||||
|
||||
/// F-260620: 黑名单增强 — Windows 根本身 / ProgramData / 用户凭据目录(.ssh/.aws/.gnupg)
|
||||
#[test]
|
||||
fn test_blacklist_windows_root_programdata_creds() {
|
||||
// C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等)
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\win.ini")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\explorer.exe")));
|
||||
// ProgramData
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\ProgramData\\App\\config")));
|
||||
// 用户凭据目录(从 validate_path 迁移统一,任意路径段命中即拒)
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Users\\user\\.ssh\\id_rsa")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.aws/credentials")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.gnupg/pubring.gpg")));
|
||||
// 普通目录不误伤(devflow 自身 / 含 appdata 段的合法备份目录)
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src")));
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data")));
|
||||
}
|
||||
}
|
||||
63
src-tauri/src/state/knowledge_config.rs
Normal file
63
src-tauri/src/state/knowledge_config.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 知识库配置(提取 + 注入)
|
||||
// ============================================================
|
||||
|
||||
/// AI 提炼触发方式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExtractTrigger {
|
||||
/// 对话正常完成时(默认)
|
||||
OnComplete,
|
||||
/// 仅手动按钮触发
|
||||
ManualOnly,
|
||||
}
|
||||
|
||||
/// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc<Mutex> 启动 default 覆盖
|
||||
/// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。
|
||||
/// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc<Mutex> 启动 default 覆盖
|
||||
/// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。
|
||||
pub const KNOWLEDGE_CONFIG_KEY: &str = "df-knowledge-config";
|
||||
|
||||
/// 审批超时配置持久化 KV key(前端 AdvancedSection.vue 也在用同一个 key,保持单一真相源)。
|
||||
/// 前端值单位为毫秒(0/300000/900000/1800000/3600000),后端同步使用此 key 读取并转换。
|
||||
/// 前端在运行期负责会话内的审批超时(定时器+ai_approve(false)),后端在启动恢复阶段
|
||||
/// 清理重启前遗留的 pending(避免重启后无人调用 try_continue 致审批永不超时)。
|
||||
pub const APPROVAL_TIMEOUT_KEY: &str = "df-approval-timeout";
|
||||
|
||||
/// 知识库行为配置(内存真相源 + Settings KV 持久化,前后端通过 IPC 读写)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeConfig {
|
||||
/// 提炼总开关,默认 true
|
||||
pub auto_extract: bool,
|
||||
/// 提炼触发方式,默认 OnComplete
|
||||
pub trigger_mode: ExtractTrigger,
|
||||
/// 最少消息数守卫(防闲聊噪音),默认 4
|
||||
pub min_messages: u32,
|
||||
/// 聊天时自动注入相关知识开关,默认 true
|
||||
pub auto_inject: bool,
|
||||
/// 语义检索(向量)总开关,默认 false——关闭时纯 LIKE 零外部依赖
|
||||
#[serde(default)]
|
||||
pub vector_enabled: bool,
|
||||
/// embedding 用的 provider id(仅 openai_compat 类型,Anthropic 无 embed API)
|
||||
#[serde(default)]
|
||||
pub embedding_provider_id: Option<String>,
|
||||
/// embedding 模型名(如 embedding-3 / text-embedding-3-small)
|
||||
#[serde(default)]
|
||||
pub embedding_model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for KnowledgeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_extract: true,
|
||||
trigger_mode: ExtractTrigger::OnComplete,
|
||||
min_messages: 4,
|
||||
auto_inject: true,
|
||||
vector_enabled: false,
|
||||
embedding_provider_id: None,
|
||||
embedding_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
252
src-tauri/src/state/llm_concurrency.rs
Normal file
252
src-tauri/src/state/llm_concurrency.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
// ============================================================
|
||||
// LLM 调用并发控制(双层 Semaphore + 可选 per-provider 层)
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用并发控制 — 全局 LLM 调用限流 global + 单对话内 per_conv 双层 Semaphore + 可选 per-provider 层
|
||||
///
|
||||
/// 限流对象:所有真实 LLM 调用(主循环 stream_llm / 标题生成 / 知识提炼)。
|
||||
/// 不限流本地工具执行(tools.execute)——本地操作无外部成本、不受 RPM 约束。
|
||||
///
|
||||
/// ## global(permits 默认 3,「LLM 调用并发限流」原义)
|
||||
/// F-09 batch5 曾把 global 改「并发会话数上限」(loop 入口持整 loop),用户决策修正「不设并发会话上限」后
|
||||
/// 已删除 loop 入口 acquire。global 回归原义:由各单次 LLM 调用点(stream_llm 重试循环 / 标题 / 压缩 /
|
||||
/// 提炼 / 项目分析扫描)各自 `acquire_global()` 拿 permit、调用结束 Drop 释放,防 provider 429。
|
||||
/// 多对话 loop 并发不限数(用户接受 token 暴增)。
|
||||
///
|
||||
/// ## per_conv 改 HashMap<conv_id, Semaphore>
|
||||
/// 原应用级单信号量(AiSession 单例 + generating 互斥下退化)改为
|
||||
/// `HashMap<String, Arc<Semaphore>>`:每对话一份(permits=2,主循环 + 标题 + 提炼各自限流)。
|
||||
/// `run_agentic_loop` 入口 `acquire_per_conv(conv_id)` 拿 1 permit 持整个 loop 生命周期(含工具执行/审批
|
||||
/// 等待/重试),防单对话内并发 LLM 调用失控。这是单对话内限流,非会话数限制,符合用户「不设上限」。
|
||||
/// `acquire_per_conv(conv_id)`:lock HashMap → 无则建(permits=2)→ clone Arc → 释放 lock → acquire_owned。
|
||||
/// conv 退出清理(`release_conv(conv_id)`):conv 删除时 remove 条目(防 HashMap 无限增长)。
|
||||
///
|
||||
/// 运行时调整:tokio Semaphore 的 permits 数构造时固定、不可增减,
|
||||
/// 故 `global` 用 `Arc<Mutex<Arc<Semaphore>>>` 双层包装——替换内层 Arc 即重建 Semaphore。
|
||||
/// 已持有旧 permit 的任务不受影响(permit 绑定旧 Semaphore,软收敛),
|
||||
/// 新请求 lock 后克隆到最新 Arc、自动走新限制。旧 Semaphore 随最后 permit 释放而 drop。
|
||||
/// per_conv HashMap 内每条 Arc<Semaphore> 不需运行时改 permits(对话内并发上限 2 固定),故无重建需求。
|
||||
///
|
||||
/// ## F-260614-04: per-provider 层(可选)
|
||||
/// `per_provider` 为 HashMap<provider_id, Arc<Semaphore>>。调用方(agentic loop)经
|
||||
/// `acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。
|
||||
/// **单 provider 场景**:若未调 `set_provider_caps`,HashMap 为空,
|
||||
/// `acquire_for_provider` 返回 None(无限流,行为同 F-01 前)。零变化保证。
|
||||
/// 全局容量 = min(sum(各 provider 上限), global_cap):由调用方在配置时约束
|
||||
/// (set_provider_caps 传 min(sum, global_cap)),非运行时强约束。
|
||||
///
|
||||
/// ## Phase3 预留: per_sub_flow 层(批2-B,占位未接入)
|
||||
/// `per_sub_flow` 为 HashMap<sub_flow_id, Arc<Semaphore>>,用于 Phase3 单对话并行多轮的
|
||||
/// **子流并发上限**(单对话内并行 spawn 多条子流时,防子流数失控)。本批仅加层 + 占位,
|
||||
/// **不接入调用点**(接入待 Phase3 子流 spawn 落地)。
|
||||
///
|
||||
/// **key 约定(文档化,非强约束)**:`sub_flow_id` 采用 `"{conv_id}::{sub_id}"` 格式,
|
||||
/// 唯一标识某对话下的某条子流,便于 release_sub_flow 精确清理。
|
||||
///
|
||||
/// **三层语义**:
|
||||
/// - per_sub_flow = 单对话内**子流**并发上限(每条子流一份 Semaphore,permits 默认 3,预留)
|
||||
/// - per_conv = 单对话内**总**并发上限(permits 默认 2)
|
||||
/// - global = 全应用**总**并发上限(permits 默认 3)
|
||||
///
|
||||
/// **理想配额(用户配置建议,非硬限)**: per_sub_permits ≤ per_conv_permits ≤ global_permits。
|
||||
/// 实际限流由各层 Semaphore 自然保证:global Semaphore 硬限全应用并发不超 global permits
|
||||
/// (跨对话 sum(per_conv) 可 > global,但 global acquire_owned 自然阻塞,不超卖)。
|
||||
///
|
||||
/// **acquire 语义(非阻塞)**:`acquire_per_sub_flow` 用 `try_acquire_owned`(非阻塞),
|
||||
/// 耗尽返 None 降级串行——子流并行不阻塞主 loop(Phase3 子流 spawn 失败不拖垮整对话)。
|
||||
#[derive(Clone)]
|
||||
pub struct LlmConcurrency {
|
||||
/// 全局 LLM 调用并发上限(permits 默认 3):F-09 batch5 修正后回归原义,由各单次 LLM 调用点
|
||||
/// (stream_llm/标题/压缩/提炼/项目分析)各自 acquire/drop 防 429,不再由 loop 入口持整 loop。
|
||||
global: Arc<Mutex<Arc<Semaphore>>>,
|
||||
/// 单对话内并发上限(F-09 B 批5):HashMap<conv_id, Semaphore>,每对话 permits=2。
|
||||
/// acquire 时按 conv_id 取/建;release_conv 在 loop 结束 + 无 pending 时 remove。
|
||||
per_conv: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
/// 每对话内并发上限(permits 默认 2,构造时传入 new(3, 2) 的第二参)。
|
||||
/// F-09 B 批5:AtomicUsize 支持运行时热改(ai_set_concurrency_config 的 per_conv_limit)。
|
||||
/// 热改后**已建对话**的旧 Semaphore 不变(permits 构造时固定),**新建对话**用新值;
|
||||
/// 为使热改立即全量生效,set_per_conv 同时清空 HashMap 强制重建(软收敛:旧 permit 随 Drop 释放)。
|
||||
per_conv_permits: Arc<AtomicUsize>,
|
||||
/// F-260614-04: per-provider 信号量表。空 = 无 per-provider 限流(单 provider 路径零变化)。
|
||||
/// Arc<Mutex<HashMap>>:运行时增删 provider 配置时替换/插入,acquire 时 clone Arc。
|
||||
per_provider: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
/// Phase3 预留(批2-B): per-sub_flow 信号量表。key = sub_flow_id(约定 "{conv_id}::{sub_id}")。
|
||||
/// 空 = 无子流并发限流(Phase3 未接入时零变化)。acquire 用 try_acquire_owned(非阻塞),
|
||||
/// 耗尽返 None 降级串行,子流并行不阻塞主 loop。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
per_sub_flow: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
/// Phase3 预留(批2-B): 每子流并发上限(permits 默认 3,内部初始化,预留)。
|
||||
/// AtomicUsize 支持运行时热改;set_per_sub_permits 同时清空 HashMap(软收敛,对齐 set_per_conv)。
|
||||
/// new(global, per_conv) 签名保持向后兼容,per_sub_permits 内部 AtomicUsize::new(3)。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
per_sub_permits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl LlmConcurrency {
|
||||
pub fn new(global: usize, per_conv: usize) -> Self {
|
||||
Self {
|
||||
global: Arc::new(Mutex::new(Arc::new(Semaphore::new(global)))),
|
||||
per_conv: Arc::new(Mutex::new(HashMap::new())),
|
||||
per_conv_permits: Arc::new(AtomicUsize::new(per_conv)),
|
||||
per_provider: Arc::new(Mutex::new(HashMap::new())),
|
||||
// Phase3 预留(批2-B): per_sub_flow 默认 permits=3,内部初始化。
|
||||
// 签名保持 new(global, per_conv) 向后兼容;Phase3 接入后用户可经
|
||||
// set_per_sub_permits 热改(配置化待后续批,本批仅占位)。
|
||||
per_sub_flow: Arc::new(Mutex::new(HashMap::new())),
|
||||
per_sub_permits: Arc::new(AtomicUsize::new(3)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取全局 LLM 调用并发 permit:由各单次 LLM 调用点(stream_llm/标题/压缩/提炼/项目分析)
|
||||
/// 各自调用、调用结束 Drop 释放。F-09 batch5 修正后不再由 run_agentic_loop 入口持整 loop。
|
||||
/// 重建后(set_global)新请求自动走最新 Semaphore。
|
||||
pub async fn acquire_global(&self) -> tokio::sync::OwnedSemaphorePermit {
|
||||
let sema = self.global.lock().await.clone();
|
||||
sema.acquire_owned().await.expect("llm global semaphore closed")
|
||||
}
|
||||
|
||||
/// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphore,permits=当前 per_conv_permits(默认 2)。
|
||||
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
|
||||
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {
|
||||
let sema = {
|
||||
let mut map = self.per_conv.lock().await;
|
||||
let permits = self.per_conv_permits.load(std::sync::atomic::Ordering::SeqCst);
|
||||
map.entry(conv_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
|
||||
.clone()
|
||||
};
|
||||
sema.acquire_owned().await.expect("llm per_conv semaphore closed")
|
||||
}
|
||||
|
||||
/// F-09 B 批5:conv 退出清理。loop 结束 + 无 pending 审批时 remove 该 conv 的 Semaphore 条目。
|
||||
/// **时机由调用方判断**(agentic loop 退出点):仅在确信无后续 acquire 时调用,否则误删会致
|
||||
/// 该 conv 下次 acquire 重建 Semaphore(限流计数清零,非致命,但语义偏离)。
|
||||
/// remove 后已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
|
||||
pub async fn release_conv(&self, conv_id: &str) {
|
||||
self.per_conv.lock().await.remove(conv_id);
|
||||
}
|
||||
|
||||
/// 重建全局 Semaphore(软收敛:旧 permit 不回收,待其释放后新限制完全生效)
|
||||
pub async fn set_global(&self, permits: usize) {
|
||||
*self.global.lock().await = Arc::new(Semaphore::new(permits));
|
||||
}
|
||||
|
||||
/// 重建单对话 Semaphore(F-09 B 批5 后 per_conv 为 HashMap)。
|
||||
/// 行为:更新 per_conv_permits(AtomicUsize) + 清空 HashMap(软收敛:旧 permit 随 Drop 释放,
|
||||
/// 新对话 acquire 用新 permits 值重建 Semaphore)。已建对话若仍在跑,旧 Semaphore 不变;
|
||||
/// 下次该 conv 新 acquire 时因 HashMap 已清空会重建为新 permits。
|
||||
pub async fn set_per_conv(&self, permits: usize) {
|
||||
self.per_conv_permits
|
||||
.store(permits, std::sync::atomic::Ordering::SeqCst);
|
||||
self.per_conv.lock().await.clear();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase3 预留(批2-B): per_sub_flow 层 — 占位未接入调用点
|
||||
// ============================================================
|
||||
|
||||
/// Phase3 预留(批2-B): 取单子流内并发 permit(非阻塞)。
|
||||
///
|
||||
/// 按 `sub_flow_id` 取/建 Semaphore(permits=当前 per_sub_permits,默认 3)。
|
||||
/// lock HashMap → 无则建(or_insert_with, permits 取 per_sub_permits 当前值)
|
||||
/// → clone Arc → 释放 lock → **try_acquire_owned**(非阻塞)。
|
||||
///
|
||||
/// **非阻塞语义(关键)**:用 try_acquire_owned 而非 acquire_owned——耗尽返 None
|
||||
/// 调用方降级串行处理,子流并行不阻塞主 loop(Phase3 子流 spawn 失败不拖垮整对话)。
|
||||
///
|
||||
/// - 返 `Ok(Some(permit))`:成功获取,permit 随 Drop 自动释放。
|
||||
/// - 返 `Ok(None)`:子流并发已耗尽,调用方降级串行(非错误)。
|
||||
///
|
||||
/// **key 约定**:`sub_flow_id` 采用 `"{conv_id}::{sub_id}"` 格式(文档化,调用方组装)。
|
||||
/// 锁持有短(不含 try_acquire),对齐 acquire_per_conv 模式。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
pub async fn acquire_per_sub_flow(
|
||||
&self,
|
||||
sub_flow_id: &str,
|
||||
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||||
let sema = {
|
||||
let mut map = self.per_sub_flow.lock().await;
|
||||
let permits = self.per_sub_permits.load(std::sync::atomic::Ordering::SeqCst);
|
||||
map.entry(sub_flow_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
|
||||
.clone()
|
||||
};
|
||||
// try_acquire_owned 非阻塞:Err(NoPermits) 返 None 降级串行,不阻塞主 loop。
|
||||
// 对齐 acquire_global/per_conv 的 expect 前提:Semaphore 不会 close(无 close() 调用)。
|
||||
sema.try_acquire_owned().ok()
|
||||
}
|
||||
|
||||
/// Phase3 预留(批2-B): 子流完成清理。remove 该 sub_flow 的 Semaphore 条目(防 HashMap 无限增长)。
|
||||
///
|
||||
/// **对齐 release_conv 模式**:remove 后已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),
|
||||
/// 仅阻止新条目累积。时机由调用方判断(Phase3 子流退出点)。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
pub async fn release_sub_flow(&self, sub_flow_id: &str) {
|
||||
self.per_sub_flow.lock().await.remove(sub_flow_id);
|
||||
}
|
||||
|
||||
/// Phase3 预留(批2-B): 热改 per_sub_flow permits 上限。
|
||||
///
|
||||
/// 行为(对齐 set_per_conv):更新 per_sub_permits(AtomicUsize) + 清空 HashMap(软收敛:
|
||||
/// 旧 permit 随 Drop 释放,新子流 acquire 用新 permits 值重建 Semaphore)。已建子流若仍在跑,
|
||||
/// 旧 Semaphore 不变;下次该 sub_flow 新 acquire 时因 HashMap 已清空会重建为新 permits。
|
||||
#[allow(dead_code)] // Phase3 子流 spawn 落地时接入调用点,本批仅占位。
|
||||
pub async fn set_per_sub_permits(&self, permits: usize) {
|
||||
self.per_sub_permits
|
||||
.store(permits, std::sync::atomic::Ordering::SeqCst);
|
||||
self.per_sub_flow.lock().await.clear();
|
||||
}
|
||||
|
||||
/// F-260614-04: 取 per-provider 并发 permit(可选)。
|
||||
///
|
||||
/// - provider 在 `per_provider` 表中有配置 → 取其 Semaphore permit,返回 Some。
|
||||
/// - provider 无配置(单 provider 场景或未 set_provider_caps)→ 返回 None,无限流。
|
||||
///
|
||||
/// 调用方(agentic loop)用法:
|
||||
/// ```ignore
|
||||
/// let _global_permit = llm_concurrency.acquire_global().await;
|
||||
/// let _per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await;
|
||||
/// let _provider_permit = llm_concurrency.acquire_for_provider(&provider_id).await;
|
||||
/// ```
|
||||
/// 三 permit 均绑 guard Drop 自动释放。None 时无 permit 需释放(行为同 F-01 前)。
|
||||
pub async fn acquire_for_provider(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||||
let sema = {
|
||||
let map = self.per_provider.lock().await;
|
||||
map.get(provider_id).cloned()
|
||||
}?;
|
||||
// Semaphore 存在 → acquire。expect 同 acquire_global/per_conv:Semaphore 不会 close
|
||||
// (无 close() 调用,仅在 set_provider_caps 时替换表内 Arc,旧 Arc permit 仍有效)。
|
||||
Some(
|
||||
sema.acquire_owned()
|
||||
.await
|
||||
.expect("llm per_provider semaphore closed"),
|
||||
)
|
||||
}
|
||||
|
||||
/// F-260614-04: 批量设置 per-provider 并发上限(替换整表)。
|
||||
///
|
||||
/// 调用方(Settings 配置热改 / 启动初始化)传入 `{ provider_id: permits }` map,
|
||||
/// 替换整张 per_provider 表(软收敛:已持 permit 不回收)。全局容量约束 min(sum, global_cap)
|
||||
/// 由调用方在构造 map 时应用(本函数不做强约束,只落表)。
|
||||
///
|
||||
/// 传空 map → 清空 per_provider 表(所有 provider 回退到无 per-provider 限流)。
|
||||
pub async fn set_provider_caps(&self, caps: HashMap<String, usize>) {
|
||||
let mut map = self.per_provider.lock().await;
|
||||
map.clear();
|
||||
for (pid, permits) in caps {
|
||||
// permits=0 等同无配置(Semaphore::new(0) 永远 acquire 不到 → 死锁),
|
||||
// 故 permits=0 跳过(不落表 → acquire_for_provider 返 None → 无限流)。
|
||||
if permits > 0 {
|
||||
map.insert(pid, Arc::new(Semaphore::new(permits)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,11 +301,19 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
|
||||
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
|
||||
* @param format 'markdown' | 'json' | 'txt'
|
||||
*/
|
||||
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
|
||||
return invoke('ai_conversation_export', { conversationId, format })
|
||||
},
|
||||
}
|
||||
/** 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
|
||||
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
|
||||
* @param format 'markdown' | 'json' | 'txt'
|
||||
*/
|
||||
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
|
||||
return invoke('ai_conversation_export', { conversationId, format })
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
|
||||
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
|
||||
*/
|
||||
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
|
||||
return invoke('ai_update_conversation_goals', { conversationId, goals })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ export { workflowApi } from './workflow'
|
||||
export { aiApi } from './ai'
|
||||
export { knowledgeApi } from './knowledge'
|
||||
export { settingsApi } from './settings'
|
||||
export { moduleApi } from './module'
|
||||
|
||||
107
src/api/module.ts
Normal file
107
src/api/module.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 工程系统 API — IPC invoke 封装(对标设计 §五工程系统 + Batch 10 文件浏览)
|
||||
//
|
||||
// 工程记录类型复用 df-storage::ProjectModuleRecord(字段一一对应)。
|
||||
// 文件浏览(getModuleFileTree/readModuleFile)返回 serde_json::Value,前端按字段取用。
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/** 工程记录(对齐后端 df_storage::models::ProjectModuleRecord)。 */
|
||||
export interface ProjectModuleRecord {
|
||||
id: string
|
||||
project_id: string
|
||||
name: string
|
||||
/** 工程目录绝对路径(本机) */
|
||||
path: string
|
||||
git_url: string | null
|
||||
/** 技术栈 JSON 字符串 */
|
||||
stack: string | null
|
||||
auto_detected: boolean
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 文件树单条目(单层;前端点击文件夹再懒加载下一层)。 */
|
||||
export interface FileTreeEntry {
|
||||
/** 文件/目录名(不含路径) */
|
||||
name: string
|
||||
/** 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path) */
|
||||
path: string
|
||||
is_dir: boolean
|
||||
/** 字节数(文件夹恒为 0) */
|
||||
size: number
|
||||
/** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */
|
||||
git_status?: string
|
||||
}
|
||||
|
||||
/** getModuleFileTree 返回结构。 */
|
||||
export interface FileTreeResult {
|
||||
/** 相对工程根的当前目录路径(根目录为空串) */
|
||||
path: string
|
||||
entries: FileTreeEntry[]
|
||||
}
|
||||
|
||||
/** readModuleFile 返回结构。 */
|
||||
export interface ReadFileResult {
|
||||
path: string
|
||||
/** 文本内容(二进制文件为空串) */
|
||||
content: string
|
||||
/** 文件总字节数 */
|
||||
size: number
|
||||
is_binary: boolean
|
||||
/** 是否因超 1MB 被截断 */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Git 改动文件项(对齐后端 GitChangedFile)。 */
|
||||
export interface GitChangedFile {
|
||||
status: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** 最近提交项(对齐后端 GitRecentCommit)。 */
|
||||
export interface GitRecentCommit {
|
||||
hash: string
|
||||
subject: string
|
||||
}
|
||||
|
||||
/** getModuleGitStatus 返回结构。 */
|
||||
export interface GitStatusResult {
|
||||
branch: string
|
||||
changed_files: GitChangedFile[]
|
||||
recent_commits: GitRecentCommit[]
|
||||
is_git_repo: boolean
|
||||
}
|
||||
|
||||
export const moduleApi = {
|
||||
/** 列出项目全部工程(按 sort_order ASC)。 */
|
||||
listProjectModules(projectId: string): Promise<ProjectModuleRecord[]> {
|
||||
return invoke('list_project_modules', { projectId })
|
||||
},
|
||||
|
||||
/** 查询工程 Git 状态(分支/改动文件/最近提交;非 git 仓库返回 is_git_repo:false)。 */
|
||||
getModuleGitStatus(moduleId: string): Promise<GitStatusResult> {
|
||||
return invoke('get_module_git_status', { moduleId })
|
||||
},
|
||||
|
||||
/**
|
||||
* 列出工程文件树(单层 + git 状态合并)。
|
||||
* @param moduleId 工程 id
|
||||
* @param subPath 相对工程根的子路径(钻入子目录);省略/空 = 工程根
|
||||
*/
|
||||
getModuleFileTree(moduleId: string, subPath?: string): Promise<FileTreeResult> {
|
||||
return invoke('get_module_file_tree', {
|
||||
moduleId,
|
||||
subPath: subPath && subPath.length > 0 ? subPath : null,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 读工程内单文件(文本/图片;1MB 上限,二进制检测)。
|
||||
* @param moduleId 工程 id
|
||||
* @param filePath 相对工程根的文件路径(POSIX 风格)
|
||||
*/
|
||||
readModuleFile(moduleId: string, filePath: string): Promise<ReadFileResult> {
|
||||
return invoke('read_module_file', { moduleId, filePath })
|
||||
},
|
||||
}
|
||||
@@ -372,7 +372,7 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
|
||||
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: string[]
|
||||
} | {
|
||||
type: 'AiError'; error: string; error_type?: AiErrorType
|
||||
} | {
|
||||
@@ -409,6 +409,19 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
||||
type: 'AiStreamRetry'; attempt: number; max_attempts: number
|
||||
} | {
|
||||
// F-15 上下文管理生命周期事件(原 useAiContext 独立监听器,现合入统一分发器)
|
||||
// 后端在手动/自动压缩、清空上下文时 emit,前端据此复位 loading + toast/刷新。
|
||||
type: 'AiCompressing'
|
||||
} | {
|
||||
// 手动压缩成功(IPC compressContext 完成后 emit):带摘要供前端回填 system 消息
|
||||
type: 'AiManualCompressed'; summary: string
|
||||
} | {
|
||||
// 自动压缩成功(loop 内超预算压缩):前端静默复位 loading(不弹 toast,不打断流)
|
||||
type: 'AiAutoCompressed'; summary: string
|
||||
} | {
|
||||
// 上下文已清空:前端刷新消息列表 + toast 提示
|
||||
type: 'AiContextCleared'
|
||||
}) & {
|
||||
conversation_id?: string
|
||||
}
|
||||
@@ -572,6 +585,7 @@ export interface AiConversationSummary {
|
||||
title: string | null
|
||||
provider_id: string | null
|
||||
model: string | null
|
||||
pinned_goals?: string[]
|
||||
archived: boolean
|
||||
pinned?: boolean
|
||||
prompt_tokens?: number | null
|
||||
|
||||
@@ -237,6 +237,7 @@ function scrollToFirstPending(): void {
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
async function confirmDeleteConversation(id: string) {
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
if (!conv) console.warn('confirmDeleteConversation: conv not found, id=', id)
|
||||
const title = conv?.title || t('aiChat.newConversation')
|
||||
// UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
|
||||
const isActive = id === store.state.activeConversationId
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
|
||||
<!-- BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) -->
|
||||
<div v-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
|
||||
⏱ 已等待 {{ formatWaitTime(waitSecs) }}
|
||||
</div>
|
||||
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
|
||||
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
||||
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
@@ -50,21 +54,21 @@
|
||||
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('once')">
|
||||
@click="onAuthorizeOnce">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthOnce') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--always"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('always')">
|
||||
@click="onAuthorizeAlways">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthAlways') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('deny')">
|
||||
@click="onDeny">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiChat.dirAuthDeny') }}
|
||||
@@ -73,14 +77,14 @@
|
||||
<div v-else class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove(true)">
|
||||
@click="onApprove">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onApprove(false)">
|
||||
@click="onReject">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiTool.reject') }}
|
||||
@@ -120,10 +124,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue'
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
|
||||
import {
|
||||
shouldKeepOpen,
|
||||
parseResult,
|
||||
@@ -133,6 +135,7 @@ import {
|
||||
} from '@/composables/ai/useToolCard'
|
||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
||||
import { useToolApproval } from '@/composables/ai/useToolApproval'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ToolResultBody from './ToolResultBody.vue'
|
||||
@@ -161,98 +164,24 @@ const emit = defineEmits<{
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
|
||||
}>()
|
||||
|
||||
// AE-2025-05:本卡自治二次确认状态机(每张 ToolCard 各持一份 confirmState,与 AiChat 父级隔离)。
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
|
||||
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
|
||||
|
||||
/**
|
||||
* 审批按钮 loading 态(B-260616-08)。点击后置 true 禁用两按钮防重复点击 + spinner;
|
||||
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。兜底计时到点复位 + toast 提示
|
||||
* (对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
|
||||
*/
|
||||
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
|
||||
const approving = ref(false)
|
||||
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── 审批超时兜底 toast(本卡自管,分离窗口运行时 App 根 toast 不在 DOM) ──
|
||||
const approveToast = reactive({ visible: false, msg: '' })
|
||||
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function showApproveTimeoutToast(): void {
|
||||
approveToast.msg = t('aiTool.approveLoadingTimeout')
|
||||
approveToast.visible = true
|
||||
if (_approveToastTimer) clearTimeout(_approveToastTimer)
|
||||
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
||||
}
|
||||
|
||||
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
|
||||
const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
||||
'http_request',
|
||||
])
|
||||
|
||||
/** High 风险工具的二次确认文案(按操作类型) */
|
||||
function highRiskConfirmMsg(name: string): string {
|
||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
||||
if (name === 'http_request') return t('aiTool.confirmHighHttp')
|
||||
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
||||
return t('aiTool.confirmHighDelete')
|
||||
}
|
||||
return t('aiTool.confirmHighGeneric')
|
||||
}
|
||||
|
||||
async function onApprove(approved: boolean) {
|
||||
if (approving.value) return // 防重入
|
||||
// AE-2025-05:High 风险 + 批准 → 先二次确认;拒绝不再二次确认(无害,加确认反增摩擦)
|
||||
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
|
||||
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
|
||||
if (!ok) return
|
||||
}
|
||||
approving.value = true
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
|
||||
showApproveTimeoutToast()
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
emit('approve', { id: props.tc.id, approved })
|
||||
}
|
||||
|
||||
/**
|
||||
* path_auth 审批链阶段3b:path 类(路径授权挂起)三选项处理。
|
||||
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
|
||||
* 与 onApprove 同款 loading + 超时兜底,但 emit 带 decision(approved=false 仅占位,
|
||||
* 实际语义由 decision 决定;调用方 store.approveToolCall 按 decision 走 authorizeDir)。
|
||||
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,once/always/deny 三选项
|
||||
* 本身就是用户明示决策,二次确认反增摩擦)。
|
||||
*/
|
||||
async function onAuthorize(decision: 'once' | 'always' | 'deny') {
|
||||
if (approving.value) return // 防重入
|
||||
approving.value = true
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
|
||||
showApproveTimeoutToast()
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
// approved=false 仅占位:store.approveToolCall 据 decision 走 authorizeDir,approved 入参被忽略。
|
||||
emit('approve', { id: props.tc.id, approved: false, decision })
|
||||
}
|
||||
|
||||
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。原 cmdOutputExpanded/
|
||||
// httpBodyExpanded 初始化逻辑已随结果区迁入 ToolResultBody,此处仅留 approving 复位。
|
||||
watch(() => props.tc.status, (s) => {
|
||||
if (s !== 'pending_approval') {
|
||||
approving.value = false
|
||||
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (approvingTimer) clearTimeout(approvingTimer)
|
||||
if (_approveToastTimer) clearTimeout(_approveToastTimer)
|
||||
})
|
||||
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
|
||||
const {
|
||||
approving,
|
||||
approveToast,
|
||||
waitSecs,
|
||||
waitLevel,
|
||||
formatWaitTime,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAuthorizeOnce,
|
||||
onAuthorizeAlways,
|
||||
onDeny,
|
||||
confirmState,
|
||||
answerConfirm,
|
||||
} = useToolApproval(toRef(props, 'tc'), emit)
|
||||
|
||||
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
@@ -380,6 +309,22 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
|
||||
/* BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
|
||||
.ai-tool-wait-time {
|
||||
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-dim, #888);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
.ai-tool-wait-time.is-long {
|
||||
color: var(--df-warning, #d97706);
|
||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
.ai-tool-wait-time.is-urgent {
|
||||
color: var(--df-danger, #e5484d);
|
||||
animation: pulseGlow 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* AE-2025-03: write_file 审批 diff 预览区(红删绿增行级) */
|
||||
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
|
||||
.ai-tool-diff-pre {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
|
||||
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
|
||||
<span class="ai-tool-group-count">{{ group.calls.length }}</span>
|
||||
<span class="ai-tool-group-files">{{ groupFileSummary(group) }}</span>
|
||||
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
|
||||
<!-- 全部展开/收起(U-260618:从 topbar 下沉到首个可折叠分组标题行尾巴,与 run command 同行) -->
|
||||
<div
|
||||
@@ -216,6 +217,26 @@ function groupDisplayName(group: ToolCallGroup): string {
|
||||
return group.name.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** 从工具调用参数中提取路径摘要(取前 3 个文件名)。
|
||||
* 供分组标题行展示,让用户一眼知道影响了哪些文件。*/
|
||||
function groupFileSummary(group: ToolCallGroup): string {
|
||||
const paths: string[] = []
|
||||
for (const tc of group.calls) {
|
||||
const args = tc.args as Record<string, unknown> | undefined
|
||||
const p = args?.path
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
// 取最后一段文件名(或最后两级目录)
|
||||
const parts = p.trim().split(/[/\\]/)
|
||||
const short = parts.length >= 2
|
||||
? parts.slice(-2).join('/')
|
||||
: parts[parts.length - 1]
|
||||
if (!paths.includes(short)) paths.push(short)
|
||||
if (paths.length >= 3) break
|
||||
}
|
||||
}
|
||||
return paths.length ? ' · ' + paths.join(' · ') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 单卡是否展开:
|
||||
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user