优化: 清理无意义注释(走查编号前缀/过时历史标注/死代码注释)

This commit is contained in:
lxy
2026-08-15 18:05:43 +08:00
parent 5a4c821629
commit 5ac815b9ca
137 changed files with 735 additions and 737 deletions
+2 -2
View File
@@ -80,7 +80,7 @@ impl AnthropicCompatProvider {
/// - `api_key`: API 密钥(Anthropic 用 x-api-key 头,非 Bearer /// - `api_key`: API 密钥(Anthropic 用 x-api-key 头,非 Bearer
/// - `default_model`: 默认模型名称 /// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 OpenAI 共用 DRY)。 // reqwest Client 构建抽 crate::build_provider_client(与 OpenAI 共用 DRY)。
let client = crate::build_provider_client(); let client = crate::build_provider_client();
Self { Self {
client, client,
@@ -890,7 +890,7 @@ mod tests {
assert!(!c.finished); assert!(!c.finished);
} }
/// error 事件 → error=Some + finished=falseR-P1-1避免残缺响应被当正常完成入库, /// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库,
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致) /// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test] #[test]
fn anthropic_error_event_yields_error_not_finished() { fn anthropic_error_event_yields_error_not_finished() {
+2 -2
View File
@@ -61,12 +61,12 @@ pub(crate) struct AnthropicToolDef {
/// Anthropic 同步响应 /// Anthropic 同步响应
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AnthropicResponse { pub(crate) struct AnthropicResponse {
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning // Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
id: String, id: String,
pub model: String, pub model: String,
pub content: Vec<AnthropicContentBlock>, pub content: Vec<AnthropicContentBlock>,
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning // Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
stop_reason: Option<String>, stop_reason: Option<String>,
pub usage: AnthropicUsage, pub usage: AnthropicUsage,
+2 -2
View File
@@ -275,7 +275,7 @@ impl ContextManager {
self.messages.iter().map(|t| t.message.clone()).collect() self.messages.iter().map(|t| t.message.clone()).collect()
} }
/// 只读最近 N 条消息(尾部切片 clone)。BE2(AC-EFF-R2-1):断路器/探索熔断检查等只关心 /// 只读最近 N 条消息(尾部切片 clone)。BE2 断路器/探索熔断检查等只关心
/// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。 /// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。
/// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。 /// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。
pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> { pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> {
@@ -326,7 +326,7 @@ impl ContextManager {
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result /// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result
/// ///
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results /// 用于「重新生成」删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。 /// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
/// ///
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早 /// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
+1 -1
View File
@@ -34,7 +34,7 @@ use crate::provider::{ChatMessage, MessageRole, ToolCall};
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> { pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet; use std::collections::HashSet;
// step 0UX-09):过滤 truncated 软删消息,不进 LLM 上下文。 // step 0过滤 truncated 软删消息,不进 LLM 上下文。
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除, // 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。 // 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
let messages: Vec<ChatMessage> = messages let messages: Vec<ChatMessage> = messages
+5 -5
View File
@@ -1135,7 +1135,7 @@ mod tests {
#[test] #[test]
fn extract_key_info_single_huge_line_no_newline_compressed() { fn extract_key_info_single_huge_line_no_newline_compressed() {
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。 // 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let content = "x".repeat(50_000); let content = "x".repeat(50_000);
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "run_command");
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len()); assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
@@ -1149,7 +1149,7 @@ mod tests {
fn extract_key_info_json_huge_string_field_truncated() { fn extract_key_info_json_huge_string_field_truncated() {
// JSON 对象中大字符串字段(单行少行)逃逸压缩。 // JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。 // 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let large = "z".repeat(10_000); let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large); let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "run_command");
@@ -1171,7 +1171,7 @@ mod tests {
#[test] #[test]
fn extract_key_info_eleven_lines_triggers_compression() { fn extract_key_info_eleven_lines_triggers_compression() {
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记 // 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect(); let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n"); let content = lines.join("\n");
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "run_command");
@@ -1185,7 +1185,7 @@ mod tests {
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含) // 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含) // 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
// 错误行在中间区间 → 标注 [行 N] 插入 // 错误行在中间区间 → 标注 [行 N] 插入
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect(); let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail) // idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
lines[2] = "error in head zone".to_string(); lines[2] = "error in head zone".to_string();
@@ -1347,7 +1347,7 @@ mod tests {
assert_eq!(extract_pending_tc_id(""), None); assert_eq!(extract_pending_tc_id(""), None);
} }
// ── read_file 豁免压缩(BUG-260801: AI 定向读代码不应被折叠) ── // ── read_file 豁免压缩AI 定向读代码不应被折叠) ──
// //
// 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为 // 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为
// "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行, // "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行,
+1 -1
View File
@@ -56,7 +56,7 @@ use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。 // df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core; pub use df_ai_core;
/// SW-260618-10: 构建 Provider 共用的 reqwest ClientOpenAI/Anthropic Provider::new DRY)。 /// 构建 Provider 共用的 reqwest ClientOpenAI/Anthropic Provider::new DRY)。
/// ///
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的 /// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层 /// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
+1 -1
View File
@@ -239,7 +239,7 @@ mod tests {
// SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions), // SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions),
// /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。 // /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。
assert!(is_non_chat_model("sensenova-u1-fast")); assert!(is_non_chat_model("sensenova-u1-fast"));
assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关 assert!(is_non_chat_model("SenseNova-ast")); // 大小写无关
assert!(is_non_chat_model("sensenova-u1-pro")); assert!(is_non_chat_model("sensenova-u1-pro"));
// 通用图像生成命名也剔 // 通用图像生成命名也剔
assert!(is_non_chat_model("infographic-v1")); assert!(is_non_chat_model("infographic-v1"));
+1 -1
View File
@@ -47,7 +47,7 @@ impl OpenAICompatProvider {
/// - `api_key`: API 密钥 /// - `api_key`: API 密钥
/// - `default_model`: 默认模型名称 /// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 Anthropic 共用 DRY)。 // reqwest Client 构建抽 crate::build_provider_client(与 Anthropic 共用 DRY)。
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。 // connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。
let client = crate::build_provider_client(); let client = crate::build_provider_client();
Self { Self {
+2 -2
View File
@@ -67,7 +67,7 @@ pub(crate) struct OpenAiResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChoice { pub(crate) struct OpenAiChoice {
pub message: OpenAiMessageResp, pub message: OpenAiMessageResp,
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning // OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
pub finish_reason: Option<String>, pub finish_reason: Option<String>,
} }
@@ -84,7 +84,7 @@ pub(crate) struct OpenAiMessageResp {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiToolCallResp { pub(crate) struct OpenAiToolCallResp {
pub id: String, pub id: String,
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning // 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
#[serde(rename = "type")] #[serde(rename = "type")]
pub call_type: String, pub call_type: String,
+3 -3
View File
@@ -40,7 +40,7 @@ const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。 /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
/// ///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。 /// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
#[derive(Debug)] #[derive(Debug)]
pub enum AttemptOutcome<T> { pub enum AttemptOutcome<T> {
/// 成功,携带结果。 /// 成功,携带结果。
@@ -60,7 +60,7 @@ pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。 /// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
/// ///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。 /// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
pub fn is_status_retryable(status: u16) -> bool { pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status) status == 429 || (500..600).contains(&status)
} }
@@ -91,7 +91,7 @@ pub fn backoff_delay(attempt: u32) -> Duration {
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder) /// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder)
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。 /// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
/// ///
/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数 /// 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用 /// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
/// backoff_delay + is_status_retryable)。 /// backoff_delay + is_status_retryable)。
pub(crate) async fn retry_with_backoff<T, F, Fut>( pub(crate) async fn retry_with_backoff<T, F, Fut>(
+1 -1
View File
@@ -258,7 +258,7 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
} }
}; };
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。 // kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程, // 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。 // 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。 // 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
+1 -1
View File
@@ -296,7 +296,7 @@ pub(crate) fn extract_json(text: &str) -> String {
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde, // (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。) // 必然失败降级启发式,即便正则本可救回。)
} }
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象CR-40-2 // 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象。
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。 // (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。 // 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new(); static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
-1
View File
@@ -1,6 +1,5 @@
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。 //! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。 //! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
use serde::Serialize; use serde::Serialize;
+3 -3
View File
@@ -152,7 +152,7 @@ where
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await; let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
if let Some(name) = tool_name { if let Some(name) = tool_name {
// MC-5(MCP-6):仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、 // MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。 // create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) { if is_success_tool_call(&resp) {
fire_write_hook(config, &name); fire_write_hook(config, &name);
@@ -169,7 +169,7 @@ where
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败 /// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功 /// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
/// ///
/// MC-5(MCP-6):stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定, /// stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
/// 保证两 transport 对「业务失败不触发写回调」语义一致。 /// 保证两 transport 对「业务失败不触发写回调」语义一致。
pub(crate) fn is_success_tool_call(resp: &Response) -> bool { pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
if resp.error.is_some() { if resp.error.is_some() {
@@ -651,7 +651,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn main_loop_no_write_callback_on_business_failure() { async fn main_loop_no_write_callback_on_business_failure() {
// MC-5(MCP-6):业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。 // MC-5业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。 // create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
let ctx = test_ctx().await; let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
+1 -1
View File
@@ -163,7 +163,7 @@ async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await; let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调 // ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
// (桌面端据此刷新 GUI)。MC-5/MCP-6:业务失败(handler 返 isError=true)不触发, // (桌面端据此刷新 GUI)。MC-5/业务失败(handler 返 isError=true)不触发,
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。 // 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
if crate::server::is_success_tool_call(&resp) { if crate::server::is_success_tool_call(&resp) {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) { if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
+10 -10
View File
@@ -247,7 +247,7 @@ fn check_expected_updated_at(args: &Value, db_updated_at: &str) -> Result<(), Ca
} }
// ============================================================ // ============================================================
// 跨实体校验(防 B-260801-01:跨实体误操作) // 跨实体校验(防 跨实体误操作)
// ============================================================ // ============================================================
// //
// 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。 // 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。
@@ -352,7 +352,7 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// MC-6(MCP-4):name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create, // name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create,
// 防空白项目名进库——required 只保证传参,不保证非空白)。 // 防空白项目名进库——required 只保证传参,不保证非空白)。
let name = name.trim().to_string(); let name = name.trim().to_string();
if name.is_empty() { if name.is_empty() {
@@ -406,7 +406,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
let existing = match repo.get_by_id(&id).await { let existing = match repo.get_by_id(&id).await {
Ok(Some(p)) => p, Ok(Some(p)) => p,
Ok(None) => { Ok(None) => {
// 跨实体校验(B-260801-01):id 可能属于 task/idea,给精确错误防误操作 // 跨实体校验id 可能属于 task/idea,给精确错误防误操作
if let Some(msg) = cross_entity_err(&db, &id, "project").await { if let Some(msg) = cross_entity_err(&db, &id, "project").await {
return CallToolResult::error(msg); return CallToolResult::error(msg);
} }
@@ -421,7 +421,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
// 部分更新:name/description 缺省回退 existing,避免空默认清空数据 // 部分更新:name/description 缺省回退 existing,避免空默认清空数据
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone()); let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
// MC-1(MCP-1):status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task // status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
// 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。 // 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
// 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。 // 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
let status = existing.status; let status = existing.status;
@@ -517,7 +517,7 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")), Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e), Err(e) => return err_str(e),
} }
// MC-6(MCP-2):两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。 // MC-6两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。
// tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。 // tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await { if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
tracing::warn!( tracing::warn!(
@@ -578,7 +578,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// MC-6(MCP-4):title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。 // title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。
let title = title.trim().to_string(); let title = title.trim().to_string();
if title.is_empty() { if title.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error( return Box::pin(std::future::ready(CallToolResult::error(
@@ -669,7 +669,7 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let existing = match repo.get_by_id(&id).await { let existing = match repo.get_by_id(&id).await {
Ok(Some(t)) => t, Ok(Some(t)) => t,
Ok(None) => { Ok(None) => {
// 跨实体校验(B-260801-01):id 可能属于 project/idea,给精确错误防误操作 // 跨实体校验id 可能属于 project/idea,给精确错误防误操作
if let Some(msg) = cross_entity_err(&db, &id, "task").await { if let Some(msg) = cross_entity_err(&db, &id, "task").await {
return CallToolResult::error(msg); return CallToolResult::error(msg);
} }
@@ -795,7 +795,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
// MC-6(MCP-3):priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源, // priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源,
// 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。 // 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。
let priority = match df_storage::crud::normalize_priority( let priority = match df_storage::crud::normalize_priority(
args.get("priority").and_then(|v| v.as_i64()), args.get("priority").and_then(|v| v.as_i64()),
@@ -845,7 +845,7 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let existing = match repo.get_by_id(&id).await { let existing = match repo.get_by_id(&id).await {
Ok(Some(i)) => i, Ok(Some(i)) => i,
Ok(None) => { Ok(None) => {
// 跨实体校验(B-260801-01):id 可能属于 project/task,给精确错误防误操作 // 跨实体校验id 可能属于 project/task,给精确错误防误操作
if let Some(msg) = cross_entity_err(&db, &id, "idea").await { if let Some(msg) = cross_entity_err(&db, &id, "idea").await {
return CallToolResult::error(msg); return CallToolResult::error(msg);
} }
@@ -1451,7 +1451,7 @@ mod tests {
} }
} }
// ── 跨实体校验(B-260801-01):update_* 检测 id 属于其他实体时报精确错误 ── // ── 跨实体校验update_* 检测 id 属于其他实体时报精确错误 ──
// //
// 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时, // 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时,
// 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。 // 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。
+2 -2
View File
@@ -124,7 +124,7 @@ impl Node for AiNode {
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" }, "base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" } "api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" }
}, },
// SW-260802-01: schema 与 handler 行为对齐 — config 层 required=[] 正确,但 prompt 运行时必填。 // schema 与 handler 行为对齐 — config 层 required=[] 正确,但 prompt 运行时必填。
// prompt: execute → parse_params(ai_node_helpers.rs:189-200) 取 inputs["prompt"] > config.prompt, // prompt: execute → parse_params(ai_node_helpers.rs:189-200) 取 inputs["prompt"] > config.prompt,
// 两者皆无则 Err("缺少必填参数: prompt")。即 prompt 真必填,但可由上游节点注入, // 两者皆无则 Err("缺少必填参数: prompt")。即 prompt 真必填,但可由上游节点注入,
// JSON Schema 只校验 config 属性无法表达"二选一",故 required 留空 + description 标注兜底来源, // JSON Schema 只校验 config 属性无法表达"二选一",故 required 留空 + description 标注兜底来源,
@@ -427,7 +427,7 @@ mod tests {
// ai_self_review_node.rs(与被测代码同位,纯搬运)。 // ai_self_review_node.rs(与被测代码同位,纯搬运)。
// ============================================================ // ============================================================
// SW-260802-01: schema required 与 handler 行为对齐测试 // schema required 与 handler 行为对齐测试
// ============================================================ // ============================================================
// //
// 真实 bug(误判修正):原注释称"prompt/provider_id 均留空走兜底",实则 prompt 运行时必填 —— // 真实 bug(误判修正):原注释称"prompt/provider_id 均留空走兜底",实则 prompt 运行时必填 ——
+3 -3
View File
@@ -64,7 +64,7 @@ pub(crate) struct ResolvedProvider {
/// 无任何 provider → 友好错误「未配置 AI Provider」。 /// 无任何 provider → 友好错误「未配置 AI Provider」。
/// ///
/// modelconfig["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。 /// modelconfig["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。
/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并 /// provider 三件套 DRY —— resolve_provider + parse_params 合并
/// (AiNode/AiSelfReviewNode execute 逐字重复)。 /// (AiNode/AiSelfReviewNode execute 逐字重复)。
pub(crate) async fn resolve_and_parse( pub(crate) async fn resolve_and_parse(
db: &Arc<Database>, db: &Arc<Database>,
@@ -72,7 +72,7 @@ pub(crate) async fn resolve_and_parse(
inputs: &HashMap<String, NodeOutput>, inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> { ) -> anyhow::Result<AiNodeParams> {
let provider_cfg = resolve_provider(db, config).await?; let provider_cfg = resolve_provider(db, config).await?;
// P0-B: parse_params 缺 prompt 报错时兜底 —— 工作流推进链模板 AiNode config 无 prompt // parse_params 缺 prompt 报错时兜底 —— 工作流推进链模板 AiNode config 无 prompt
// (in_progress/testing/done 三链首节点 AiNode 仅带 provider_id, prompt 留空), // (in_progress/testing/done 三链首节点 AiNode 仅带 provider_id, prompt 留空),
// 原行为直接 Err 致整链必失败。兜底:从 config.task_id 读 TaskRecord 生成基于 // 原行为直接 Err 致整链必失败。兜底:从 config.task_id 读 TaskRecord 生成基于
// title/description 的执行 prompt;无 task_id 用通用默认。不改 LLM 调用结构, // title/description 的执行 prompt;无 task_id 用通用默认。不改 LLM 调用结构,
@@ -134,7 +134,7 @@ fn default_fallback_prompt() -> String {
"请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string() "请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string()
} }
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。 /// build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> { pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> {
df_ai::build_provider( df_ai::build_provider(
&p.provider.protocol, &p.provider.protocol,
+3 -3
View File
@@ -225,7 +225,7 @@ impl Node for AiSelfReviewNode {
// //
// 设计选型(三方案对比,见 commit/设计文档): // 设计选型(三方案对比,见 commit/设计文档):
// A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调, // A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调,
// 零 executor 核心改动,零依赖暂缓的条件引擎(T-260614-11)。最简。 // 零 executor 核心改动,零依赖暂缓的条件引擎。最简。
// B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前 // B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前
// 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件 // 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件
// 引擎 Phase1+2 才能用 → 拆波。 // 引擎 Phase1+2 才能用 → 拆波。
@@ -264,7 +264,7 @@ impl Node for AiSelfReviewNode {
"max_tokens": { "type": "integer" }, "max_tokens": { "type": "integer" },
"gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" } "gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
}, },
// SW-260802-01: schema 与 handler 行为对齐 — required 仅列 handler 真正强制必填的字段。 // schema 与 handler 行为对齐 — required 仅列 handler 真正强制必填的字段。
// task_id: execute 第 100-104 行缺 task_id 直接 Err("缺少必填参数: task_id"),真必填 → 保留。 // task_id: execute 第 100-104 行缺 task_id 直接 Err("缺少必填参数: task_id"),真必填 → 保留。
// provider_id: execute 调 resolve_and_parse → resolve_provider(ai_node_helpers.rs:100-108), // provider_id: execute 调 resolve_and_parse → resolve_provider(ai_node_helpers.rs:100-108),
// 空串走路径 2(老明文)/路径 3(默认 provider)兜底,运行时非必填 → 移出 required。 // 空串走路径 2(老明文)/路径 3(默认 provider)兜底,运行时非必填 → 移出 required。
@@ -703,7 +703,7 @@ mod tests {
} }
// ============================================================ // ============================================================
// SW-260802-01: schema required 与 handler 行为对齐测试 // schema required 与 handler 行为对齐测试
// ============================================================ // ============================================================
// //
// 真实 bug:schema `required=["task_id","provider_id"]` 与 handler 不一致 —— // 真实 bug:schema `required=["task_id","provider_id"]` 与 handler 不一致 ——
+7 -7
View File
@@ -56,7 +56,7 @@ impl Node for HumanNode {
let mut rx = ctx.event_bus.subscribe(); let mut rx = ctx.event_bus.subscribe();
// 2. 发送人工审批请求到事件总线 // 2. 发送人工审批请求到事件总线
// send 是 async fn(broadcast 同步发但 async 包装),必须 await 否则 Future 不 poll、Request 不进 channel(B-03b-R6) // send 是 async fn(broadcast 同步发但 async 包装),必须 await 否则 Future 不 poll、Request 不进 channel(b-R6)
let _ = ctx.event_bus let _ = ctx.event_bus
.send(WorkflowEvent::HumanApprovalRequest { .send(WorkflowEvent::HumanApprovalRequest {
execution_id: ctx.execution_id.clone(), execution_id: ctx.execution_id.clone(),
@@ -292,7 +292,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn request_is_emitted_to_bus() { async fn request_is_emitted_to_bus() {
// B-03b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线 // b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线
// (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败) // (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败)
let bus = EventBus::new(); let bus = EventBus::new();
let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放) let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放)
@@ -375,7 +375,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn invalid_decision_ignored_then_timeout() { async fn invalid_decision_ignored_then_timeout() {
// R-P1-6: options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点), // options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点),
// warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。 // warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。
let bus = EventBus::new(); let bus = EventBus::new();
let ctx = make_ctx( let ctx = make_ctx(
@@ -421,7 +421,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn empty_decision_ignored_then_timeout_even_with_empty_options() { async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。 // 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
let bus = EventBus::new(); let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 })); let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
let bus_clone = bus.clone(); let bus_clone = bus.clone();
@@ -441,7 +441,7 @@ mod tests {
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。 // 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。 // 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
// ===== B-03b-R8: executor 级端到端集成测试 ===== // ===== b-R8: executor 级端到端集成测试 =====
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。 // 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、 // 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。 // outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
@@ -470,7 +470,7 @@ mod tests {
} }
} }
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。 /// b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。 /// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
/// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。 /// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
#[tokio::test] #[tokio::test]
@@ -532,7 +532,7 @@ mod tests {
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed); assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
} }
/// B-03b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC) /// b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC)
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1) /// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1)
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。 /// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
#[tokio::test] #[tokio::test]
+4 -4
View File
@@ -35,7 +35,7 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A
/// 3. 状态机(三类拒绝,错误区分供前端分辨): /// 3. 状态机(三类拒绝,错误区分供前端分辨):
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作) /// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文) /// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
/// - 两类拒绝均附 legal_targets(from) 合法目标列表,供 LLM 下次选对目标态(AC-5) /// - 两类拒绝均附 legal_targets(from) 合法目标列表,供 LLM 下次选对目标态
/// - can_transition 闸门矩阵判否即此分支 /// - can_transition 闸门矩阵判否即此分支
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true /// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功) /// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
@@ -468,7 +468,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 // 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
@@ -484,7 +484,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 // 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", TaskStatus::Done)).await.unwrap(); repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
@@ -498,7 +498,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn same_status_rejected() { async fn same_status_rejected() {
// CR-01-D: 同态拒绝(from==to)与非法转换(跳态)错误类型区分。 // 同态拒绝(from==to)与非法转换(跳态)错误类型区分。
// 同态属空操作,归 Validation「相同状态,无需推进」; // 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
+1 -1
View File
@@ -13,7 +13,7 @@ use df_relay::{DefaultRelayServer, RelayError, RelayServer};
#[tokio::main] #[tokio::main]
async fn main() -> df_relay::Result<()> { async fn main() -> df_relay::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// RLY-5:token 未设置 → 启动即返回错误(exit code 1 + 明确消息),而非运行期 panic。 // token 未设置 → 启动即返回错误(exit code 1 + 明确消息),而非运行期 panic。
// relay.rs expected_token() 已改为返回 Option 兜底,但 fail-fast 比慢速拒连更早暴露配置错误。 // relay.rs expected_token() 已改为返回 Option 兜底,但 fail-fast 比慢速拒连更早暴露配置错误。
if std::env::var("DF_RELAY_TOKEN").is_err() { if std::env::var("DF_RELAY_TOKEN").is_err() {
return Err(RelayError::Start( return Err(RelayError::Start(
+7 -7
View File
@@ -15,7 +15,7 @@
//! `device_id` 并冒充该设备收发指令**。生产级 per-device token(配对时按 device 颁发独立 token //! `device_id` 并冒充该设备收发指令**。生产级 per-device token(配对时按 device 颁发独立 token
//! 并存储)涉及配对流程改造(需 df-miniapp/df-tunnel 配合),当前批次不实施,仅做最小加固: //! 并存储)涉及配对流程改造(需 df-miniapp/df-tunnel 配合),当前批次不实施,仅做最小加固:
//! - 连接建立时校验 `device_id` 格式(非空 + 长度上限,见 `validate_device_id`)。 //! - 连接建立时校验 `device_id` 格式(非空 + 长度上限,见 `validate_device_id`)。
//! - token 比较用常量时间比较(RLY-4),防时序侧信道。 //! - token 比较用常量时间比较,防时序侧信道。
//! 完整 per-device 鉴权留设计文档 Phase3。 //! 完整 per-device 鉴权留设计文档 Phase3。
//! //!
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。 //! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
@@ -41,13 +41,13 @@ use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result}; use crate::error::{RelayError, Result};
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。 /// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。 /// 不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。 /// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
fn expected_token() -> Option<String> { fn expected_token() -> Option<String> {
std::env::var("DF_RELAY_TOKEN").ok() std::env::var("DF_RELAY_TOKEN").ok()
} }
/// RLY-3:入站文本帧大小上限(1 MiB)。 /// 入站文本帧大小上限(1 MiB)。
/// 防恶意/异常客户端发超大帧耗尽内存与转发带宽;超限直接断开连接。 /// 防恶意/异常客户端发超大帧耗尽内存与转发带宽;超限直接断开连接。
const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024; const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024;
@@ -272,7 +272,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
let _ = socket_tx.close().await; let _ = socket_tx.close().await;
return; return;
} }
// RLY-5:token 未配置(服务启动时应已由 main 校验)→ 拒绝握手而非 panic // token 未配置(服务启动时应已由 main 校验)→ 拒绝握手而非 panic
let Some(expected) = expected_token() else { let Some(expected) = expected_token() else {
tracing::error!("DF_RELAY_TOKEN 未设置,拒绝握手"); tracing::error!("DF_RELAY_TOKEN 未设置,拒绝握手");
let _ = send_text( let _ = send_text(
@@ -283,7 +283,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
let _ = socket_tx.close().await; let _ = socket_tx.close().await;
return; return;
}; };
// RLY-4:常量时间比较防时序侧信道 // 常量时间比较防时序侧信道
if !constant_time_eq(&hello.token, &expected) { if !constant_time_eq(&hello.token, &expected) {
tracing::warn!( tracing::warn!(
device_id = %hello.device_id, device_id = %hello.device_id,
@@ -332,7 +332,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
maybe_msg = socket_rx.next() => { maybe_msg = socket_rx.next() => {
match maybe_msg { match maybe_msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
// RLY-3:入站帧超限(>1MiB)直接断开,防大帧耗尽内存/带宽 // 入站帧超限(>1MiB)直接断开,防大帧耗尽内存/带宽
if text.len() > MAX_FRAME_BYTES { if text.len() > MAX_FRAME_BYTES {
tracing::warn!( tracing::warn!(
conn_id = conn_id.0, conn_id = conn_id.0,
@@ -402,7 +402,7 @@ async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Re
} }
_ => return Err(RelayError::Client("握手首帧类型非法".into())), _ => return Err(RelayError::Client("握手首帧类型非法".into())),
}; };
// RLY-3:Hello 帧同样限长(Hello 结构很小,超限视为异常/恶意) // Hello 帧同样限长(Hello 结构很小,超限视为异常/恶意)
if text.len() > MAX_FRAME_BYTES { if text.len() > MAX_FRAME_BYTES {
return Err(RelayError::Client(format!( return Err(RelayError::Client(format!(
"Hello 帧超限({} B > {MAX_FRAME_BYTES} B)", "Hello 帧超限({} B > {MAX_FRAME_BYTES} B)",
+2 -2
View File
@@ -404,7 +404,7 @@ impl IdeaRepo {
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。 /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
/// ///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段), /// LW-6通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段),
/// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`, /// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。 /// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> { pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
@@ -430,7 +430,7 @@ impl IdeaRepo {
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。 /// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
/// ///
/// LW-8(BE-CMD-7):promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感, /// promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
/// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用 /// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用
/// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0, /// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0,
/// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。 /// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。
+3 -3
View File
@@ -45,7 +45,7 @@ pub struct ProjectQuery {
/// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。 /// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。
/// ///
/// BE-CMD-3:projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用 /// projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用
/// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义, /// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义,
/// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。 /// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。
const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"]; const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"];
@@ -387,7 +387,7 @@ impl ProjectRepo {
/// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。 /// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。
/// ///
/// DRY(R-PD-11):统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project /// DRY统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project
/// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由 /// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由
/// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。 /// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。
pub async fn find_path_conflict( pub async fn find_path_conflict(
@@ -464,7 +464,7 @@ impl ProjectRepo {
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。 /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
/// ///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站项目仍可改字段), /// LW-6通用 [`update_field`] 不过滤软删(回收站项目仍可改字段),
/// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`, /// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。 /// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> { pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
+4 -4
View File
@@ -461,7 +461,7 @@ impl TaskRepo {
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT), /// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。 /// 返回满足条件的总行数(忽略分页裁剪)。
/// ///
/// LW-5(BE-CMD-2):补齐 assignee/queue/parent_id/module_id 维度,与 list_by_query /// LW-5补齐 assignee/queue/parent_id/module_id 维度,与 list_by_query
/// 全维度对齐——此前 count 缺四维导致「count 超算、list 空页」翻页不一致 /// 全维度对齐——此前 count 缺四维导致「count 超算、list 空页」翻页不一致
/// (前端分页 total 与页数据对不上)。 /// (前端分页 total 与页数据对不上)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> { pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
@@ -493,7 +493,7 @@ impl TaskRepo {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1)); where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p)); params_vec.push(Box::new(p));
} }
// LW-5: assignee 维度(与 list_by_query 同 WHERE 构造,防 count/list 漂移) // assignee 维度(与 list_by_query 同 WHERE 构造,防 count/list 漂移)
if let Some(ref a) = assignee { if let Some(ref a) = assignee {
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1)); where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(a.clone())); params_vec.push(Box::new(a.clone()));
@@ -507,7 +507,7 @@ impl TaskRepo {
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
// LW-5: queue / parent_id / module_id 维度(知识图谱 V29 + 工程 V41) // queue / parent_id / module_id 维度(知识图谱 V29 + 工程 V41)
if let Some(ref q) = queue { if let Some(ref q) = queue {
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1)); where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(q.clone())); params_vec.push(Box::new(q.clone()));
@@ -815,7 +815,7 @@ impl TaskRepo {
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。 /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
/// ///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站任务仍可改字段), /// LW-6通用 [`update_field`] 不过滤软删(回收站任务仍可改字段),
/// 本方法收口软删防护,供命令层 `update_task` 使用——软删任务(回收站)返回 `false`, /// 本方法收口软删防护,供命令层 `update_task` 使用——软删任务(回收站)返回 `false`,
/// 调用方据此报「已删除」,杜绝回收站任务被字段更新复活/改动。 /// 调用方据此报「已删除」,杜绝回收站任务被字段更新复活/改动。
/// 字段名走同款 [`validate_column_name`] 白名单(防注入 + 按表隔离)。 /// 字段名走同款 [`validate_column_name`] 白名单(防注入 + 按表隔离)。
+2 -2
View File
@@ -704,7 +704,7 @@ fn migrate_v26(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V27: 审批状态统一 executed→completed(TD-260621-05) /// V27: 审批状态统一 executed→completed
/// ///
/// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行 /// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行
/// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配; /// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配;
@@ -1880,7 +1880,7 @@ mod tests {
assert_eq!(v, 20); assert_eq!(v, 20);
} }
/// V27:审批状态统一 executed→completed(TD-260621-05) /// V27:审批状态统一 executed→completed
/// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed) /// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed)
#[test] #[test]
fn v27_unifies_executed_to_completed() { fn v27_unifies_executed_to_completed() {
+2 -2
View File
@@ -74,7 +74,7 @@ pub enum WorkflowEvent {
}, },
/// 工作流执行完成 /// 工作流执行完成
/// ///
/// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行。 /// b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行。
/// 此前该变体不带 execution_id,AppState.event_bus 全局单例下并发工作流的终态事件 /// 此前该变体不带 execution_id,AppState.event_bus 全局单例下并发工作流的终态事件
/// 会被 forward 循环的 matches! 完成判定(workflow.rs:159-163 只看变体不看 exec_id)误判, /// 会被 forward 循环的 matches! 完成判定(workflow.rs:159-163 只看变体不看 exec_id)误判,
/// 致任一工作流终态触发所有 forward 循环 break(过早断他人链)。 /// 致任一工作流终态触发所有 forward 循环 break(过早断他人链)。
@@ -86,7 +86,7 @@ pub enum WorkflowEvent {
}, },
/// 工作流执行失败 /// 工作流执行失败
/// ///
/// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。 /// b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。
WorkflowFailed { WorkflowFailed {
#[serde(default)] #[serde(default)]
execution_id: ExecutionId, execution_id: ExecutionId,
+2 -2
View File
@@ -207,7 +207,7 @@ impl DagExecutor {
Err(e) => { Err(e) => {
let error_msg = e.to_string(); let error_msg = e.to_string();
// 已取消的节点(如 HumanNode 审批取消)跳过 set_failed: // 已取消的节点(如 HumanNode 审批取消)跳过 set_failed:
// Cancelled 已是终态,transition(Cancelled→Failed) 非法会 bail 致工作流崩溃(B-03b-R1) // Cancelled 已是终态,transition(Cancelled→Failed) 非法会 bail 致工作流崩溃(b-R1)
if self.state_machine.is_cancelled(&node_id) { if self.state_machine.is_cancelled(&node_id) {
// emit NodeCancelled(非 NodeFailed):取消语义有别于失败,前端按 type 归「取消」非「失败」 // emit NodeCancelled(非 NodeFailed):取消语义有别于失败,前端按 type 归「取消」非「失败」
self.event_bus self.event_bus
@@ -239,7 +239,7 @@ impl DagExecutor {
} }
let total = start.elapsed().as_millis() as u64; let total = start.elapsed().as_millis() as u64;
// B-03b-R10 ③: WorkflowCompleted 携带 execution_id,供 forward 循环按 exec_id 匹配 // b-R10 ③: WorkflowCompleted 携带 execution_id,供 forward 循环按 exec_id 匹配
// (全局 event_bus 单例下并发工作流的终态事件不再误触发他人 forward 的完成判定)。 // (全局 event_bus 单例下并发工作流的终态事件不再误触发他人 forward 的完成判定)。
self.event_bus self.event_bus
.send(WorkflowEvent::WorkflowCompleted { .send(WorkflowEvent::WorkflowCompleted {
+2 -2
View File
@@ -198,7 +198,7 @@ impl Node for CancelSelfNode {
} }
} }
/// B-03b-R1:取消的节点返回 Err 时,executor 跳过 set_failed(Cancelled→Failed transition 非法会 bail), /// b-R1:取消的节点返回 Err 时,executor 跳过 set_failed(Cancelled→Failed transition 非法会 bail),
/// 状态保持 Cancelled,run 返回取消相关 Err 而非状态转换错误。 /// 状态保持 Cancelled,run 返回取消相关 Err 而非状态转换错误。
#[tokio::test] #[tokio::test]
async fn test_cancelled_node_skips_set_failed() { async fn test_cancelled_node_skips_set_failed() {
@@ -248,7 +248,7 @@ impl Node for CancelSelfThenOkNode {
} }
} }
/// R-P1-3:Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled, /// Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled,
/// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail), /// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail),
/// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。 /// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -771,7 +771,7 @@ graph TD
--- ---
**🟡 P1**: **🟡 P1**:
- [ ] **SC-260811-P1-1** — 状态机最短路径推导:deferred→blocked 需 4 跳,AI 靠试错 47 次 advance_task。**建议**:错误信息附最短合法路径,或提供一步跳转(内部串多跳) - [x] **SC-260811-P1-1** ✅ 已销账(2026-08-15 核验,1b012bc 覆盖):`task_state_machine.rs:155 shortest_path` BFS 推导 + `task_advance_node.rs:81-84` 非法转换错误信息附最短路径提示(路径>2 跳时),+6 单测
**🔧 既有测试失败修复(2026-08-11)**:累计修 7 项既有失败,df-nodes 175 全过 / df-storage 166 全过: **🔧 既有测试失败修复(2026-08-11)**:累计修 7 项既有失败,df-nodes 175 全过 / df-storage 166 全过:
- ✅ `ai_self_review_node::clamp_dimension_scores_in_place` 类型保真:区间内整数保持整数(9 不转 9.0),仅越界 clamp——修 parse_review_json_valid_passes_through,同步调 clamps_out_of_range_scores 边界值断言(10 保持整数) - ✅ `ai_self_review_node::clamp_dimension_scores_in_place` 类型保真:区间内整数保持整数(9 不转 9.0),仅越界 clamp——修 parse_review_json_valid_passes_through,同步调 clamps_out_of_range_scores 边界值断言(10 保持整数)
@@ -165,7 +165,7 @@ pub(super) async fn maybe_auto_compress(
String::new() String::new()
}; };
// L0-3:保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing // 保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing
// 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义。 // 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义。
if active_msgs.is_empty() { if active_msgs.is_empty() {
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
+10 -10
View File
@@ -277,7 +277,7 @@ pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
/// 单轮并行失败爆发,又足够长以容忍偶发抖动。 /// 单轮并行失败爆发,又足够长以容忍偶发抖动。
pub const CIRCUIT_BREAKER_WINDOW: usize = 20; pub const CIRCUIT_BREAKER_WINDOW: usize = 20;
/// BE2(AC-EFF-R2-1):断路器/探索熔断读消息的尾部窗口大小。 /// BE2 断路器/探索熔断读消息的尾部窗口大小。
/// ///
/// count_recent_failures / check_stall_breaker 只关心最近若干条消息(末尾工具结果 + 其前驱 /// count_recent_failures / check_stall_breaker 只关心最近若干条消息(末尾工具结果 + 其前驱
/// assistant tool_calls / 最近几轮工具签名),用 ContextManager::recent_messages 只 clone 尾部 /// assistant tool_calls / 最近几轮工具签名),用 ContextManager::recent_messages 只 clone 尾部
@@ -570,7 +570,7 @@ async fn stream_with_retry(
/// 表达本轮的 4 种结束路径,供 loop 主框架据以 `return` 或继续后续处理。 /// 表达本轮的 4 种结束路径,供 loop 主框架据以 `return` 或继续后续处理。
/// ///
/// **不在本函数范围**(loop 内其它段): /// **不在本函数范围**(loop 内其它段):
/// - 停止/收敛/审批等待/熔断(L1815-L1917):这些在 process_tool_calls 之后, /// - 停止/收敛/审批等待/熔断这些在 process_tool_calls 之后,
/// 属「工具执行后路径」,与流式调用本身解耦,留 loop 主体。 /// 属「工具执行后路径」,与流式调用本身解耦,留 loop 主体。
/// - 正常完成(loop 外 L1957+):loop 退出后由 run_agentic_loop 直收尾。 /// - 正常完成(loop 外 L1957+):loop 退出后由 run_agentic_loop 直收尾。
enum RoundOutcome { enum RoundOutcome {
@@ -1096,12 +1096,12 @@ pub(crate) async fn run_agentic_loop(
// 单 provider 场景:池仅 1 enabled provider → select 返回单元素 Vec → 首位 = 唯一 provider, // 单 provider 场景:池仅 1 enabled provider → select 返回单元素 Vec → 首位 = 唯一 provider,
// 行为同 F-01 前(零变化)。空池(0 enabled)→ fallback 入参 provider_config(保启动行为)。 // 行为同 F-01 前(零变化)。空池(0 enabled)→ fallback 入参 provider_config(保启动行为)。
// //
// F-04b:select 返回的**完整有序 Vec** 即 fallback 序(主→备用)。本轮接入真实切换: // b:select 返回的**完整有序 Vec** 即 fallback 序(主→备用)。本轮接入真实切换:
// 流式重试块外层包 `for candidate in &candidates`,主 candidate InitFailed{retryable=true} // 流式重试块外层包 `for candidate in &candidates`,主 candidate InitFailed{retryable=true}
// 耗尽 → continue 切下一 candidate(stream_one_provider 内重建 build_provider + resolved_model // 耗尽 → continue 切下一 candidate(stream_one_provider 内重建 build_provider + resolved_model
// 在新 candidate.model_configs 上重算 select_model_id);Fatal(4xx 非429)立即放弃整轮。 // 在新 candidate.model_configs 上重算 select_model_id);Fatal(4xx 非429)立即放弃整轮。
// //
// 主候选的 model_configs 用于路由(F-01),其 provider_config 用于 build_provider。 // 主候选的 model_configs 用于路由,其 provider_config 用于 build_provider。
// compress_via_llm / 标题 / 后台 spawn 沿用主 candidate(非 fallback 范围,见各调用点注释)。 // compress_via_llm / 标题 / 后台 spawn 沿用主 candidate(非 fallback 范围,见各调用点注释)。
// //
// AiProviderRepo::new 仅 clone Arc<Database>(廉价),不复用 AppState.ai_providers // AiProviderRepo::new 仅 clone Arc<Database>(廉价),不复用 AppState.ai_providers
@@ -1212,7 +1212,7 @@ pub(crate) async fn run_agentic_loop(
} }
}; };
// 用主候选覆盖入参 provider_config(下游 build_provider / 路由 / 日志均用此)。 // 用主候选覆盖入参 provider_config(下游 build_provider / 路由 / 日志均用此)。
// mut:F-04b 切换 candidate 后更新为实际成功所用 provider(供后续 push/save/标题 spawn)。 // mut:b 切换 candidate 后更新为实际成功所用 provider(供后续 push/save/标题 spawn)。
let mut provider_config = primary_provider; let mut provider_config = primary_provider;
// 诊断日志:401/错误时据此定位是 url/type/model/key 哪项问题(只记长度不记明文) // 诊断日志:401/错误时据此定位是 url/type/model/key 哪项问题(只记长度不记明文)
tracing::info!( tracing::info!(
@@ -1250,7 +1250,7 @@ pub(crate) async fn run_agentic_loop(
// 用户指定模型 override 穿透(仅主对话生效,标题/扫描/灵感仍走路由)。 // 用户指定模型 override 穿透(仅主对话生效,标题/扫描/灵感仍走路由)。
// 兜底原则:override 非空且在该 provider model_configs 池中 → 用 override;否则用 resolved_model。 // 兜底原则:override 非空且在该 provider model_configs 池中 → 用 override;否则用 resolved_model。
// 绝不让 override 导致无模型(空/不在池 → 落回路由结果,行为不变)。 // 绝不让 override 导致无模型(空/不在池 → 落回路由结果,行为不变)。
// mut:F-04b 切换 candidate 后由 stream_one_provider 返回的 Success.resolved_model 覆盖 // mut:b 切换 candidate 后由 stream_one_provider 返回的 Success.resolved_model 覆盖
// (新 candidate 模型池不同,必须重选);此后 push/save 均用迭代最新值。 // (新 candidate 模型池不同,必须重选);此后 push/save 均用迭代最新值。
let mut resolved_model = match model_override.as_deref() { let mut resolved_model = match model_override.as_deref() {
Some(id) if !id.is_empty() Some(id) if !id.is_empty()
@@ -2238,7 +2238,7 @@ async fn count_recent_failures(
conv_id: &str, conv_id: &str,
fail_window: &mut std::collections::VecDeque<String>, fail_window: &mut std::collections::VecDeque<String>,
) -> (u32, String) { ) -> (u32, String) {
// BE2(AC-EFF-R2-1):只克隆尾部 BREAKER_TAIL_N 条,避免 all_messages_clone 全量深克隆 // BE2只克隆尾部 BREAKER_TAIL_N 条,避免 all_messages_clone 全量深克隆
// (长对话每轮 O(n) clone 浪费)。窗口覆盖末尾 Tool 结果与其前驱 assistant tool_calls // (长对话每轮 O(n) clone 浪费)。窗口覆盖末尾 Tool 结果与其前驱 assistant tool_calls
// (同轮工具回填通常 1~5 条,40 足富余;超窗工具映射退化为 unknown_tool,仅失败分组更粗,不误熔断)。 // (同轮工具回填通常 1~5 条,40 足富余;超窗工具映射退化为 unknown_tool,仅失败分组更粗,不误熔断)。
let messages = { let messages = {
@@ -2298,7 +2298,7 @@ async fn count_recent_failures(
/// ///
/// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。 /// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。
/// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`: /// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`:
/// 用 fxhash 风格简单 FNV-1a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key /// 用 fxhash 风格简单 a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key
/// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。 /// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。
/// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数, /// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数,
/// 此处兜底返回 `tool_name::unknown` 防御。 /// 此处兜底返回 `tool_name::unknown` 防御。
@@ -2327,7 +2327,7 @@ fn failure_key(tool_name: &str, content: &str) -> String {
format!("{}::{}:{:08x}", tool_name, bucket, h) format!("{}::{}:{:08x}", tool_name, bucket, h)
} }
/// FNV-1a 32-bit 哈希(零依赖,纯函数,断路器 key 归一专用;非密码学用途)。 /// a 32-bit 哈希(零依赖,纯函数,断路器 key 归一专用;非密码学用途)。
fn fnv1a_32(bytes: &[u8]) -> u32 { fn fnv1a_32(bytes: &[u8]) -> u32 {
let mut hash: u32 = 0x811c9dc5; let mut hash: u32 = 0x811c9dc5;
for &b in bytes { for &b in bytes {
@@ -2556,7 +2556,7 @@ async fn check_stall_breaker(
// arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。 // arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。
let signatures: Vec<String> = { let signatures: Vec<String> = {
let session = session_arc.lock().await; let session = session_arc.lock().await;
// BE2(AC-EFF-R2-1):只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用, // BE2只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用,
// 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。 // 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。
let messages = match session.conv_read(conv_id) { let messages = match session.conv_read(conv_id) {
Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N),
+1 -1
View File
@@ -28,7 +28,7 @@ use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCa
/// - mediumLow+Medium 自动 /// - mediumLow+Medium 自动
/// - all:全自动无审批(完全 AI 接管) /// - all:全自动无审批(完全 AI 接管)
/// ///
/// C-260627patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。 /// patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。
pub(super) fn classify_risk_and_auto( pub(super) fn classify_risk_and_auto(
risk_level: RiskLevel, risk_level: RiskLevel,
auto_exec_mode: &str, auto_exec_mode: &str,
+2 -2
View File
@@ -96,7 +96,7 @@ fn resolve_namespace_content(session: &AiSession, content: &str) -> String {
/// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_idprovider 每轮新 id /// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_idprovider 每轮新 id
/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。 /// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。
/// ///
/// **治本F-05**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史, /// **治本**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史,
/// 若发现**已落定**executed/failed/rejected,非 pending)的同类同参高危调用,返回其 /// 若发现**已落定**executed/failed/rejected,非 pending)的同类同参高危调用,返回其
/// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。 /// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。
/// ///
@@ -216,7 +216,7 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。 /// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。 /// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
/// ///
/// pub(crate):供 audit/mod.rs 轮内去重(AC-EFF-T1-2)复用同一 key 口径(同源同 key, /// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key,
/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。 /// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。
pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
if matches!(tool_name, "list_directory" | "list_dir") { if matches!(tool_name, "list_directory" | "list_dir") {
@@ -25,7 +25,7 @@ pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static
"update_project" => ("project", "update"), "update_project" => ("project", "update"),
"update_task" => ("task", "update"), "update_task" => ("task", "update"),
"update_idea" => ("idea", "update"), "update_idea" => ("idea", "update"),
// P0-2:advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端 // advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端
// listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。 // listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。
// 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。 // 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。
"advance_task" => ("task", "update"), "advance_task" => ("task", "update"),
+1 -1
View File
@@ -536,7 +536,7 @@ pub(crate) async fn process_tool_calls(
}; };
let _ = app_clone.emit("ai-chat-event", ev.clone()); let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// AR-11数据变更类工具执行成功后 emit df-data-changed 联动刷新 // 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop // write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(content)) (draft, risk_level, Ok(content))
+1 -1
View File
@@ -14,7 +14,7 @@ use df_storage::db::Database;
/// 查项目可读标签:id → "「项目名」(id=x)"。 /// 查项目可读标签:id → "「项目名」(id=x)"。
/// ///
/// AR-3审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。 /// 审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。
/// 三臂区分: /// 三臂区分:
/// - Ok(Some) → 项目名标签 /// - Ok(Some) → 项目名标签
/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id) /// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id)
+3 -3
View File
@@ -21,7 +21,7 @@ use super::risk_from_str;
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve → /// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。 /// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
/// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务 /// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务
/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂), /// 语义而非路由键)。conversation_id=None 的无主审批不建 per_conv(无 conv_id 可挂),
/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。 /// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。
pub async fn restore_pending_approvals(state: &AppState) { pub async fn restore_pending_approvals(state: &AppState) {
// 清理超 24 小时的残留 pending(旧会话遗留,不再有意义) // 清理超 24 小时的残留 pending(旧会话遗留,不再有意义)
@@ -64,7 +64,7 @@ pub async fn restore_pending_approvals(state: &AppState) {
continue; continue;
} }
// 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。 // 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。
// 无 conversation_id 的无主审批(R-9)不建 per_conv——无 conv_id 可挂,仍进 pending_approvals // 无 conversation_id 的无主审批不建 per_conv——无 conv_id 可挂,仍进 pending_approvals
// 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。 // 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。
if let Some(cid) = rec.conversation_id.as_deref() { if let Some(cid) = rec.conversation_id.as_deref() {
if !cid.is_empty() && convs_restored.insert(cid.to_string()) { if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
@@ -133,7 +133,7 @@ mod tests_multi_conv_restore {
("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())), ("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())),
("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())), ("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())),
("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())), ("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())),
("tc-4".to_string(), "create_task".to_string(), None), // 无主(R-9) ("tc-4".to_string(), "create_task".to_string(), None), // 无主
]; ];
// 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。 // 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。
+1 -1
View File
@@ -110,7 +110,7 @@ impl CommandSink {
/// 累积一行 + 触发 flush 判定(行加入 stdout/stderr 分桶)。 /// 累积一行 + 触发 flush 判定(行加入 stdout/stderr 分桶)。
/// ///
/// AC-EFF-S2-5:不立即 emit,先累积入缓冲;体积超阈值立即 flush,否则等 50ms 定时 flush /// 不立即 emit,先累积入缓冲;体积超阈值立即 flush,否则等 50ms 定时 flush
/// 或 Drop 兜底 flush。emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。 /// 或 Drop 兜底 flush。emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
fn emit(&self, kind: StreamKind, line: &str) { fn emit(&self, kind: StreamKind, line: &str) {
let over_threshold = { let over_threshold = {
+24 -24
View File
@@ -45,7 +45,7 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER /// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
/// "需要用户审批,等待确认")就地替换为终态文本。 /// "需要用户审批,等待确认")就地替换为终态文本。
/// ///
/// 背景(SW-260618-02):审批占位文本在 stop/clear/create/delete/force_send 等 /// 背景审批占位文本在 stop/clear/create/delete/force_send 等
/// 清 pending_approvals 时若不替换,会残留在 messages 里,下次发送把占位喂给 LLM。 /// 清 pending_approvals 时若不替换,会残留在 messages 里,下次发送把占位喂给 LLM。
/// 占位内容非工具结果语义,污染上下文 + 误导模型继续等待。 /// 占位内容非工具结果语义,污染上下文 + 误导模型继续等待。
/// ///
@@ -60,7 +60,7 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv, /// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。 /// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) { pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。 // 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传 // 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。 // &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
// 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。 // 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
@@ -258,7 +258,7 @@ fn append_volatile(volatile: String, seg: &str) -> String {
// 发送 / 审批 / 控制 // 发送 / 审批 / 控制
// ============================================================ // ============================================================
/// 重新生成最后一条 AI 回复(UX-02:消息操作栏「重新生成」) /// 重新生成最后一条 AI 回复消息操作栏「重新生成」)
/// ///
/// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的 /// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的
/// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑 /// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑
@@ -368,9 +368,9 @@ pub async fn ai_regenerate(
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) // loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照 // 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。 // F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。
// 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。 // 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。
@@ -386,7 +386,7 @@ pub async fn ai_regenerate(
Ok("ok".to_string()) Ok("ok".to_string())
} }
/// 查询后端真实 generating 状态(B-260615-22:方案 A 发送前 IPC 查后端真值) /// 查询后端真实 generating 状态方案 A 发送前 IPC 查后端真值)
/// ///
/// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护: /// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护:
/// 后端 loop 异常退出 / AiError 已复位 generating=false 时,前端若仅查本地 streaming /// 后端 loop 异常退出 / AiError 已复位 generating=false 时,前端若仅查本地 streaming
@@ -606,7 +606,7 @@ pub async fn ai_chat_send(
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。 // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone(); conv.agent_language = language.clone();
// F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。 // 新对话生命周期 iteration 从头计数(累计计数器复位)。
conv.iteration_used = 0; conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。run_agentic_loop 兜底校验 // F-01 阶段6: 记录用户指定模型 override(主对话专用)。run_agentic_loop 兜底校验
// (override 非空且在 provider model_configs 池中才用,否则落回路由结果)。 // (override 非空且在 provider model_configs 池中才用,否则落回路由结果)。
@@ -710,9 +710,9 @@ pub async fn ai_chat_send(
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) // loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照 // 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。 // F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。
// 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。 // 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。
@@ -745,8 +745,8 @@ pub async fn ai_approve(
// 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。 // 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。
// 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表, // 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表,
// 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。 // 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错) // 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
// BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把 // 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。 // Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await { let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
Ok(Some(rec)) => { Ok(Some(rec)) => {
@@ -799,7 +799,7 @@ pub async fn ai_approve(
// 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages // 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages
// (审计仍记 rejected),避免写错 conv。 // (审计仍记 rejected),避免写错 conv。
if let Some(ref cid) = approval.conversation_id { if let Some(ref cid) = approval.conversation_id {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 // BUG-2026-07-07/拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
// (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。 // (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。
let reject_payload = serde_json::json!({ let reject_payload = serde_json::json!({
"status": "rejected", "status": "rejected",
@@ -1184,7 +1184,7 @@ pub async fn ai_authorize_dir(
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ── // ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
if decision == "deny" { if decision == "deny" {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 // BUG-2026-07-07/拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
let err_msg = serde_json::json!({ let err_msg = serde_json::json!({
"status": "rejected", "status": "rejected",
"reason": "path_auth_denied", "reason": "path_auth_denied",
@@ -1460,7 +1460,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
// ── ③ 内存清(DB 成功后;内存 clear 不失败,回滚只需保证 DB 原子)── // ── ③ 内存清(DB 成功后;内存 clear 不失败,回滚只需保证 DB 原子)──
if let Some(ref id) = active_id { if let Some(ref id) = active_id {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// SW-260618-02:清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear, // 清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear,
// 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。 // 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。
// F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写; // F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写;
// pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。 // pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。
@@ -1712,7 +1712,7 @@ pub async fn ai_chat_compress_context(
Ok(()) Ok(())
} }
/// 编辑最后一条 user 消息并重新生成UX-09 /// 编辑最后一条 user 消息并重新生成
/// ///
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 → /// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →
/// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删, /// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删,
@@ -1752,7 +1752,7 @@ pub async fn ai_chat_edit(
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。 // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone(); conv.agent_language = language.clone();
// F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。 // 编辑重生成 = 新生命周期起点,iteration 从头计数。
conv.iteration_used = 0; conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。 // F-01 阶段6: 记录用户指定模型 override(主对话专用)。
conv.model_override = model_override.clone(); conv.model_override = model_override.clone();
@@ -1834,14 +1834,14 @@ pub async fn ai_chat_edit(
Ok("ok".to_string()) Ok("ok".to_string())
} }
/// 强制发送消息B-260616-02: L2 发送韧性;BUG-260617-04: 原子复位+占用消除双锁竞态) /// 强制发送消息L2 发送韧性;原子复位+占用消除双锁竞态)
/// ///
/// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时, /// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时,
/// 前端可调此命令强制复位 generating + 清审批,然后发送新消息。 /// 前端可调此命令强制复位 generating + 清审批,然后发送新消息。
/// ///
/// 旧实现先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock,两锁之间另一个发送 /// 旧实现先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock,两锁之间另一个发送
/// IPC(force_send/send)可插入占用 generating=true → ai_chat_send 因 generating=true 被 /// IPC(force_send/send)可插入占用 generating=true → ai_chat_send 因 generating=true 被
/// reject → 前端以为已强制发送但后端拒绝,状态不一致(BUG-260617-04) /// reject → 前端以为已强制发送但后端拒绝,状态不一致。
/// ///
/// 修法:把"软停止复位 + 新消息占用 generating"合并到**同一把锁**,消除竞态窗口; /// 修法:把"软停止复位 + 新消息占用 generating"合并到**同一把锁**,消除竞态窗口;
/// 其后 system prompt 构建 + spawn run_agentic_loop 与 ai_chat_send 同款(照 ai_chat_edit /// 其后 system prompt 构建 + spawn run_agentic_loop 与 ai_chat_send 同款(照 ai_chat_edit
@@ -1907,7 +1907,7 @@ pub async fn ai_chat_force_send(
// was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed), // was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。 // 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。
let was_gen = state.conv_states.is_active(&target); let was_gen = state.conv_states.is_active(&target);
// SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages // 强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages
// 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。 // 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。
finalize_pending_placeholders(&mut *session, &target, "已取消"); finalize_pending_placeholders(&mut *session, &target, "已取消");
// 阶段3a 单真相源合并:单表 retain(仅清目标 conv,保留其他 conv,kind 不区分)。 // 阶段3a 单真相源合并:单表 retain(仅清目标 conv,保留其他 conv,kind 不区分)。
@@ -1936,7 +1936,7 @@ pub async fn ai_chat_force_send(
// 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。 // 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。
// 注:stop_flag 保持 true(旧 loop 退出用),新 loop spawn 前由下方锁外等待窗口后置 false。 // 注:stop_flag 保持 true(旧 loop 退出用),新 loop spawn 前由下方锁外等待窗口后置 false。
conv.agent_language = language.clone(); conv.agent_language = language.clone();
// F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。 // 强制发送 = 新对话生命周期起点,iteration 从头计数。
conv.iteration_used = 0; conv.iteration_used = 0;
conv.model_override = model_override.clone(); conv.model_override = model_override.clone();
// user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send // user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send
@@ -2109,7 +2109,7 @@ pub async fn ai_chat_stop(
let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval; let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval;
if has_pending { if has_pending {
// 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用 // 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用
// SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。 // messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。
// 清 pending 前先终态化为"会话已停止"。仅清目标 conv 的 pending(retain 保留其他 conv)。 // 清 pending 前先终态化为"会话已停止"。仅清目标 conv 的 pending(retain 保留其他 conv)。
finalize_pending_placeholders(&mut *session, &target, "会话已停止"); finalize_pending_placeholders(&mut *session, &target, "会话已停止");
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str())); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
@@ -2152,7 +2152,7 @@ pub async fn ai_chat_stop(
conv.stop_flag.store(true, Ordering::SeqCst); conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.clone() conv.notify.clone()
}; };
// B-260615-14:置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select! // 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。 // 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。 // Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
notify.notify_one(); notify.notify_one();
@@ -2261,7 +2261,7 @@ pub async fn ai_chat_stop(
Ok(()) Ok(())
} }
/// 续跑 agentic 循环F-260616-03达 max_iterations 暂停态用户点「继续」) /// 续跑 agentic 循环达 max_iterations 暂停态用户点「继续」)
/// ///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 /// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag(防上一轮残留致续跑入口即退出) /// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag(防上一轮残留致续跑入口即退出)
@@ -2308,7 +2308,7 @@ pub async fn ai_continue_loop(
Ok("ok".to_string()) Ok("ok".to_string())
} }
/// 停止 agentic 循环并走完成流程F-260616-03达 max_iterations 暂停态用户点「停止」) /// 停止 agentic 循环并走完成流程达 max_iterations 暂停态用户点「停止」)
/// ///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 /// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。 /// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。
@@ -176,7 +176,7 @@ pub async fn ai_conversation_create(
let now = now_millis(); let now = now_millis();
// F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。 // F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。
// 旧实现(B-260615-10)生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀 // 旧实现生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀
// 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv), // 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv),
// 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。 // 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。
// //
@@ -682,7 +682,7 @@ pub async fn ai_conversation_archive(
Ok(()) Ok(())
} }
/// 置顶/取消置顶对话(UX-17:对话置顶) /// 置顶/取消置顶对话对话置顶)
/// ///
/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。 /// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。
/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。 /// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。
@@ -727,7 +727,7 @@ pub async fn ai_update_conversation_goals(
Ok(()) Ok(())
} }
/// 导出对话为指定格式(UX-18:对话导出) /// 导出对话为指定格式对话导出)
/// ///
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) /// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出 /// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出
+14 -14
View File
@@ -13,7 +13,7 @@
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`, //! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 //! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//! //!
//! 纯搬迁:不改 IPC 签名/行为(FR-S1 密钥迁移 / F-260614-04c 池配置编辑保留 / F-01 阶段5 探测 //! 纯搬迁:不改 IPC 签名/行为(FR-S1 密钥迁移 / c 池配置编辑保留 / F-01 阶段5 探测
//! 等逻辑原样保留)。 //! 等逻辑原样保留)。
use tauri::State; use tauri::State;
@@ -37,7 +37,7 @@ use super::super::secret::{
// 提供商管理 // 提供商管理
// ============================================================ // ============================================================
/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识。 /// api_key 脱敏:IPC 不传明文给前端,保留首尾各 4 字符便于辨识。
/// ///
/// `pub(crate)` 供 get_app_config AI 工具复用(查 DevFlow 自身 provider 配置时同样脱敏, /// `pub(crate)` 供 get_app_config AI 工具复用(查 DevFlow 自身 provider 配置时同样脱敏,
/// 不向 LLM 回灌明文 key,与 ai_list_providers IPC 走同一套脱敏规则,单点维护)。 /// 不向 LLM 回灌明文 key,与 ai_list_providers IPC 走同一套脱敏规则,单点维护)。
@@ -83,7 +83,7 @@ pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProvi
} }
} }
} }
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 // IPC 不传明文 api_key前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
for p in &mut providers { for p in &mut providers {
let real = if !p.api_key.is_empty() { let real = if !p.api_key.is_empty() {
@@ -111,7 +111,7 @@ pub async fn ai_save_provider(
// 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), // 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
// 编辑已有提供商时保留原 created_at,避免被覆盖 // 编辑已有提供商时保留原 created_at,避免被覆盖
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 // c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
// 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。 // 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。
let existing = match &id { let existing = match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?, Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?,
@@ -125,13 +125,13 @@ pub async fn ai_save_provider(
.map_err(err_str)? .map_err(err_str)?
.iter().any(|p| p.is_default), .iter().any(|p| p.is_default),
}; };
// F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库); // c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库);
// 新建默认进池(enabled=true,weight=50)。 // 新建默认进池(enabled=true,weight=50)。
let (enabled, weight) = match &existing { let (enabled, weight) = match &existing {
Some(p) => (p.enabled, p.weight), Some(p) => (p.enabled, p.weight),
None => (true, 50), None => (true, 50),
}; };
// FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。 // 密钥存 OS keyring,DB api_key 列恒空(不入明文)。
// api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。 // api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。
let provider_id = id.clone().unwrap_or_else(new_id); let provider_id = id.clone().unwrap_or_else(new_id);
if !api_key.is_empty() { if !api_key.is_empty() {
@@ -140,7 +140,7 @@ pub async fn ai_save_provider(
return Err(format!("密钥保存到系统钥匙串失败: {}", e)); return Err(format!("密钥保存到系统钥匙串失败: {}", e));
} }
} else if let Some(pid) = &id { } else if let Some(pid) = &id {
// 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1) // 空 key 编辑:保住密钥,防未迁移态静默丢失。
// 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key, // 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key,
// 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。 // 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。
// 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态); // 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态);
@@ -181,7 +181,7 @@ pub async fn ai_save_provider(
config: None, config: None,
created_at, created_at,
updated_at: now_millis(), updated_at: now_millis(),
// F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。 // c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。
enabled, enabled,
weight, weight,
}; };
@@ -194,7 +194,7 @@ pub async fn ai_save_provider(
Ok(id) Ok(id)
} }
/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。 /// c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。
/// ///
/// 与 `ai_save_provider` 的区别: /// 与 `ai_save_provider` 的区别:
/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥 /// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥
@@ -277,7 +277,7 @@ pub async fn ai_delete_provider(
provider_id: String, provider_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
state.ai_providers.delete(&provider_id).await.map_err(err_str)?; state.ai_providers.delete(&provider_id).await.map_err(err_str)?;
// CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, // DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方,
// 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写)
if let Err(e) = delete_provider_secret_async(provider_id.clone()).await { if let Err(e) = delete_provider_secret_async(provider_id.clone()).await {
tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e);
@@ -310,7 +310,7 @@ fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
/// ///
/// 流程: /// 流程:
/// 1. DB 取 AiProviderRecord(get_by_id) /// 1. DB 取 AiProviderRecord(get_by_id)
/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB, /// 2. 经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB,
/// 绝不进日志/返回值/错误信息) /// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调 /// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度) /// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
@@ -329,7 +329,7 @@ pub async fn ai_fetch_models(
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?; .ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息) // 内存解析密钥,绝不外泄(不入日志/返回值/错误信息)
let api_key = df_storage::secret::resolve_provider_secret(&provider); let api_key = df_storage::secret::resolve_provider_secret(&provider);
let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type); let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type);
@@ -381,7 +381,7 @@ pub async fn ai_fetch_models(
Ok(merged) Ok(merged)
} }
/// 一次性测试连接(P0-1):用**表单当前值**而非 DB 值测试,不写库。 /// 一次性测试连接用**表单当前值**而非 DB 值测试,不写库。
/// ///
/// 解决:`ai_fetch_models` 从 DB 读 provider,用户改了表单未保存就点「测试连接」时 /// 解决:`ai_fetch_models` 从 DB 读 provider,用户改了表单未保存就点「测试连接」时
/// 测的是旧值(base_url/api_key 已改但 DB 未落),给出误导性「成功/失败」。 /// 测的是旧值(base_url/api_key 已改但 DB 未落),给出误导性「成功/失败」。
@@ -416,7 +416,7 @@ pub async fn ai_test_connection(
.await .await
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", pid))?; .ok_or_else(|| format!("提供商不存在: {}", pid))?;
// FR-S1:内存解析,绝不外泄(不入日志/返回值/错误信息) // 内存解析,绝不外泄(不入日志/返回值/错误信息)
df_storage::secret::resolve_provider_secret(&provider) df_storage::secret::resolve_provider_secret(&provider)
}; };
+2 -2
View File
@@ -10,7 +10,7 @@
use df_ai::context_helpers::CompressedSummary; use df_ai::context_helpers::CompressedSummary;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化 // 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 // 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。 // 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{ use df_ai::router::{
@@ -329,7 +329,7 @@ mod tests {
assert_eq!(clean_summary(""), ""); assert_eq!(clean_summary(""), "");
} }
// ── BUG-2026-07-07/P0-1: LLM 退化重复输出检测 ── // ── BUG-2026-07-07/LLM 退化重复输出检测 ──
#[test] #[test]
fn clean_summary_rejects_degenerated_repetition() { fn clean_summary_rejects_degenerated_repetition() {
// 实测样本(9357c27c seq0):同一短语重复 41 次 // 实测样本(9357c27c seq0):同一短语重复 41 次
+7 -7
View File
@@ -94,7 +94,7 @@ pub(crate) fn accumulate_tokens(old: Option<i64>, add: u32) -> Option<i64> {
/// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增 /// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增
/// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。 /// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。
/// ///
/// B-260619-02:50KB → 8KB 收紧。治 GLM 1214 messages 非法根因——GLM anthropic 端点对 /// 50KB → 8KB 收紧。治 GLM 1214 messages 非法根因——GLM anthropic 端点对
/// 单条 tool_result content 限制更严(实测 ~10-20KB 即拒 1214),read_file 默认前 500 行源码 /// 单条 tool_result content 限制更严(实测 ~10-20KB 即拒 1214),read_file 默认前 500 行源码
/// 约 11-21KB,旧 50KB 阈值未触发→DB 存全量→reload/下轮重发→GLM 1214 整请求失败。 /// 约 11-21KB,旧 50KB 阈值未触发→DB 存全量→reload/下轮重发→GLM 1214 整请求失败。
/// 8KB 对齐 GLM 单条上限留余量;截断后 LLM 仍能据截断片段推理(优于 1214 直接拒收)。 /// 8KB 对齐 GLM 单条上限留余量;截断后 LLM 仍能据截断片段推理(优于 1214 直接拒收)。
@@ -235,14 +235,14 @@ async fn save_conversation_inner(
touch_updated_at: bool, touch_updated_at: bool,
) { ) {
// 取 messages + 懒创建首次落库所需的 provider_id/created_at // 取 messages + 懒创建首次落库所需的 provider_id/created_at
// 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段 // 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
// 标注(含原字节数),防大体量结果(read_file 1MB 洞 / list_directory 13782 项)落库后每轮重发 // 标注(含原字节数),防大体量结果(read_file 1MB 洞 / list_directory 13782 项)落库后每轮重发
// 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request // 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
// 仍读全量 messages。 // 仍读全量 messages。
// //
// per_conv.messages 是唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。 // per_conv.messages 是唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
// conv() 惰性建:save 路径 conv 必然已建;极端未建(如启动恢复无 live conv)建空态,save 空消息(幂等)。 // conv() 惰性建:save 路径 conv 必然已建;极端未建(如启动恢复无 live conv)建空态,save 空消息(幂等)。
// BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。 // lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
// 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级, // 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+ // 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
// 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。 // 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。
@@ -251,7 +251,7 @@ async fn save_conversation_inner(
// 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。 // 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
// 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow, // 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
// BE1(AC-EFF-L0-1):无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」, // BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。 // 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs) = { let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
@@ -313,7 +313,7 @@ async fn save_conversation_inner(
} }
} }
for m in &mut persist_msgs { for m in &mut persist_msgs {
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样), // tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。 // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。 // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
if !matches!(m.role, df_ai::provider::MessageRole::Tool) { if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
@@ -616,7 +616,7 @@ mod tests {
// 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 9192 - 3072 - 3072 = 3048) // 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 9192 - 3072 - 3072 = 3048)
assert!(result.contains("已截断")); assert!(result.contains("已截断"));
assert!(result.contains("省略 3048 字符")); assert!(result.contains("省略 3048 字符"));
// B-260619-02:截断提示含原字节数(9192 个 'x' = 9192 字节),让 LLM 感知非全文 // 截断提示含原字节数(9192 个 'x' = 9192 字节),让 LLM 感知非全文
assert!(result.contains("原 9192 字节")); assert!(result.contains("原 9192 字节"));
// 结果总长应远小于原长(20k 头 + 20k 尾 + 标注) // 结果总长应远小于原长(20k 头 + 20k 尾 + 标注)
assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000); assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000);
@@ -687,7 +687,7 @@ mod tests {
assert_eq!(truncate_parts_for_persist(&[]), None); assert_eq!(truncate_parts_for_persist(&[]), None);
} }
// ---------- P0-2: tool role content 不截断(防 JSON 结构破坏) ---------- // ---------- tool role content 不截断(防 JSON 结构破坏) ----------
/// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型 /// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型
fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage { fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage {
+1 -1
View File
@@ -795,7 +795,7 @@ mod tests {
); );
} }
// ── 真实 SenseNova U1-Fast 图像生成验证(#[ignore],手动跑) ── // ── 真实 SenseNova ast 图像生成验证(#[ignore],手动跑) ──
// //
// 端到端验证:用真实 db 副本(SenseNova provider 配置)+ OS keyring 解析 api_key + // 端到端验证:用真实 db 副本(SenseNova provider 配置)+ OS keyring 解析 api_key +
// 真实调 https://token.sensenova.cn/v1/images/generations 生成一张测试图并下载落地。 // 真实调 https://token.sensenova.cn/v1/images/generations 生成一张测试图并下载落地。
+9 -9
View File
@@ -94,7 +94,7 @@ pub use self::augmentation::build_resolver_registry;
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到) // 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================ // ============================================================
/// AI 错误分类(B-260615-42)供前端按错误源差异化提示(如 auth 引导填 key, /// AI 错误分类供前端按错误源差异化提示(如 auth 引导填 key,
/// network 提示检查连接,provider_config 引导检查 base_url/model)。 /// network 提示检查连接,provider_config 引导检查 base_url/model)。
/// ///
/// 设计:`Option<ErrorType>` 向后兼容——旧 emit 点若难精确分类填 `None`, /// 设计:`Option<ErrorType>` 向后兼容——旧 emit 点若难精确分类填 `None`,
@@ -178,7 +178,7 @@ pub enum AiChatEvent {
}, },
/// 错误 /// 错误
/// ///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。 /// `error_type` 为错误源分类,`None` 表示未分类(向后兼容旧 emit 点)。
AiError { AiError {
error: String, error: String,
/// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None /// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None
@@ -207,13 +207,13 @@ pub enum AiChatEvent {
AiAgentRound { round: u32, conversation_id: Option<String> }, AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」) /// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」)
AiHeartbeat { conversation_id: Option<String> }, AiHeartbeat { conversation_id: Option<String> },
/// 达最大轮次仍未收敛F-260616-03转暂停态询问用户继续/停止) /// 达最大轮次仍未收敛转暂停态询问用户继续/停止)
/// ///
/// 触发时 generating 仍为 true(仿审批等待),前端展示独立操作卡: /// 触发时 generating 仍为 true(仿审批等待),前端展示独立操作卡:
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮 /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false
AiMaxRoundsReached { conversation_id: Option<String> }, AiMaxRoundsReached { conversation_id: Option<String> },
/// 流式调用自动重试提示F-260616-07流前失败重试中) /// 流式调用自动重试提示流前失败重试中)
/// ///
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。 /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。 /// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
@@ -238,7 +238,7 @@ pub enum AiChatEvent {
AiCompressing { conversation_id: Option<String> }, AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。 /// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。
/// ///
/// 治 Task#1(BUG-260624-05):自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷, /// 治 Task#1自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷,
/// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。 /// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。
/// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。 /// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。
AiManualCompressed { conversation_id: Option<String>, summary: String }, AiManualCompressed { conversation_id: Option<String>, summary: String },
@@ -502,7 +502,7 @@ pub enum SessionState {
/// - `Write { dir }`write_file 在 `dir` 目录下(路径父目录归一化) /// - `Write { dir }`write_file 在 `dir` 目录下(路径父目录归一化)
/// - `Execute { dir }`run_command 在 `dir` 工作目录下 /// - `Execute { dir }`run_command 在 `dir` 工作目录下
/// ///
/// 命中即自动放行,跳过 pending 审批 + 二次确认AE-05,不写 audit 仍记一条 /// 命中即自动放行,跳过 pending 审批 + 二次确认,不写 audit 仍记一条
/// `decided_by=auto_trust` 的审计记录留痕。 /// `decided_by=auto_trust` 的审计记录留痕。
#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum TrustKey { pub enum TrustKey {
@@ -518,7 +518,7 @@ pub enum TrustKey {
/// run_command handler 自身仅 validate_path 不限定 workspace,信任目录用 LLM 传入或 /// run_command handler 自身仅 validate_path 不限定 workspace,信任目录用 LLM 传入或
/// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐) /// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐)
/// ///
/// FR-S1不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。 /// 不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。
pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> { pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
match tool { match tool {
"write_file" => { "write_file" => {
@@ -1164,10 +1164,10 @@ pub(crate) async fn execute_run_workflow_for_tool(
let config = serde_json::Value::Object(serde_json::Map::new()); let config = serde_json::Value::Object(serde_json::Map::new());
let name = format!("AI 推进任务 {}{}", task_id, target_status); let name = format!("AI 推进任务 {}{}", task_id, target_status);
// 转调工作流执行核心(B-260617-01:run_workflow_inner 是 run_workflow 命令的共用核心, // 转调工作流执行核心run_workflow_inner 是 run_workflow 命令的共用核心,
// 同 invoke('run_workflow') IPC 入口的执行逻辑,持 &AppState 非 tauri::State,绕开 // 同 invoke('run_workflow') IPC 入口的执行逻辑,持 &AppState 非 tauri::State,绕开
// tauri::State 在非命令上下文不可构造的限制)。 // tauri::State 在非命令上下文不可构造的限制)。
// CR-52: 本路径是 AI 工具经 ai_approve 审批后执行 → triggered_by="ai"(区别命令层 manual)。 // 本路径是 AI 工具经 ai_approve 审批后执行 → triggered_by="ai"(区别命令层 manual)。
// 返回 execution_id 字符串;失败(模板不存在/DAG 校验失败/DB 写入失败)上抛 Err。 // 返回 execution_id 字符串;失败(模板不存在/DAG 校验失败/DB 写入失败)上抛 Err。
let execution_id = crate::commands::workflow::run_workflow_inner( let execution_id = crate::commands::workflow::run_workflow_inner(
app, app,
+1 -1
View File
@@ -269,7 +269,7 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
} }
} }
// ── BE3(AC-EFF-L0-2): system prompt 会话内缓存 ── // ── BE3system prompt 会话内缓存 ──
// //
// 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 + // 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 +
// 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。 // 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。
+1 -1
View File
@@ -11,7 +11,7 @@
//! `secret.rs::build_provider_for` 同属"消费 AiProviderRecord 的 app 层逻辑",故放 src-tauri。 //! `secret.rs::build_provider_for` 同属"消费 AiProviderRecord 的 app 层逻辑",故放 src-tauri。
//! //!
//! ## 与 router 协同(职责切分,不重叠) //! ## 与 router 协同(职责切分,不重叠)
//! - **router.select_model_id**:在**单个 provider 的 model_configs 池**中选最优模型(F-01) //! - **router.select_model_id**:在**单个 provider 的 model_configs 池**中选最优模型。
//! - **ProviderPool::select**:在**多个 provider 间**选主 + 列出备用(本模块)。 //! - **ProviderPool::select**:在**多个 provider 间**选主 + 列出备用(本模块)。
//! 调用顺序:先 router 选模型 → 再 ProviderPool 选 provider 实例(模型在多 provider 间共享时)。 //! 调用顺序:先 router 选模型 → 再 ProviderPool 选 provider 实例(模型在多 provider 间共享时)。
//! //!
+21 -21
View File
@@ -114,7 +114,7 @@ pub(crate) enum StreamResult {
reasoning_content: Option<String>, reasoning_content: Option<String>,
}, },
/// Init 失败(provider.stream() Err:建连/鉴权/HTTP non-2xx,或流建立后空文本各类中断)。 /// Init 失败(provider.stream() Err:建连/鉴权/HTTP non-2xx,或流建立后空文本各类中断)。
/// UX-260618-15: **不再 emit AiError**——重试可恢复路径的 emit 权交调用方(agentic/mod.rs), /// **不再 emit AiError**——重试可恢复路径的 emit 权交调用方(agentic/mod.rs),
/// 由 agentic 在重试耗尽/Fatal 时统一 emit 最终 AiError(单气泡),避免 N 次重试 push N+1 气泡。 /// 由 agentic 在重试耗尽/Fatal 时统一 emit 最终 AiError(单气泡),避免 N 次重试 push N+1 气泡。
/// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect /// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect
/// 可重试;false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。 /// 可重试;false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
@@ -154,7 +154,7 @@ fn emit_ai_chat_event(app_handle: &AppHandle, ev: AiChatEvent) {
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
} }
/// AR-8: flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。 /// flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。
fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) { fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) {
if pending_delta.is_empty() { if pending_delta.is_empty() {
return; return;
@@ -196,16 +196,16 @@ pub(crate) async fn stream_llm(
conv_id: &str, conv_id: &str,
) -> StreamResult { ) -> StreamResult {
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断 /// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
/// BUG-2026-07-17:原 120s 太长,后端挂死时用户等太久。改为 45s,正常流式每 chunk 间隔 /// 原 120s 太长,后端挂死时用户等太久。改为 45s,正常流式每 chunk 间隔
/// 远不到 45s(即使模型思考间隙),超时即判为连接异常走保文/报错路径 /// 远不到 45s(即使模型思考间隙),超时即判为连接异常走保文/报错路径
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45); const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45);
/// BUG-2026-07-08: 首 chunk 超时(等首字节)。 /// 首 chunk 超时(等首字节)。
/// 正常 provider 首 token <3s(即使 48 工具定义 + 长上下文),10s 足够宽容; /// 正常 provider 首 token <3s(即使 48 工具定义 + 长上下文),10s 足够宽容;
/// 已有数据后中途静默仍用 STREAM_IDLE_TIMEOUT(120s,允许模型思考间隙)。 /// 已有数据后中途静默仍用 STREAM_IDLE_TIMEOUT(120s,允许模型思考间隙)。
const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10); const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10);
/// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog /// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// AR-8: 后端 delta 合批窗口。50ms 内的多次 AiTextDelta 合并为一条再 emit, /// 后端 delta 合批窗口。50ms 内的多次 AiTextDelta 合并为一条再 emit,
/// 降低 IPC 事件风暴(前端 rAF 只节流渲染不节流 IPC);合并后前端 currentText /// 降低 IPC 事件风暴(前端 rAF 只节流渲染不节流 IPC);合并后前端 currentText
/// 逐条累加,最终一致。 /// 逐条累加,最终一致。
const DELTA_FLUSH_INTERVAL: Duration = Duration::from_millis(50); const DELTA_FLUSH_INTERVAL: Duration = Duration::from_millis(50);
@@ -287,14 +287,14 @@ pub(crate) async fn stream_llm(
let mut finished_received = false; let mut finished_received = false;
let mut stopped = false; let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None; let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传) // DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = None; let mut reasoning_content_acc: Option<String> = None;
// AR-8: per-conv delta 合批累加器。pending_delta 累积未 flush 的文本, // per-conv delta 合批累加器。pending_delta 累积未 flush 的文本,
// next_flush_at 记录下次 flush 的绝对时刻(首个入 buffer 时置 now+50ms)。 // next_flush_at 记录下次 flush 的绝对时刻(首个入 buffer 时置 now+50ms)。
let mut pending_delta = String::new(); let mut pending_delta = String::new();
let mut next_flush_at: Option<tokio::time::Instant> = None; let mut next_flush_at: Option<tokio::time::Instant> = None;
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器 // heartbeat interval 提至 loop 外复用,避免每轮重建计时器
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。 // (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发 // tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 rx.recv() 抢分支错过首 chunk)。 // (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 rx.recv() 抢分支错过首 chunk)。
@@ -310,7 +310,7 @@ pub(crate) async fn stream_llm(
let mut first_chunk_done = false; let mut first_chunk_done = false;
loop { loop {
// B-260615-04:stop 即时打断。 // stop 即时打断。
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true; stopped = true;
break; break;
@@ -348,7 +348,7 @@ pub(crate) async fn stream_llm(
}; };
} }
// AR-8: 到点 flush 合批的 delta(单条 AiTextDelta 双写 emit + event_bus)。 // 到点 flush 合批的 delta(单条 AiTextDelta 双写 emit + event_bus)。
if let Some(deadline) = next_flush_at { if let Some(deadline) = next_flush_at {
if tokio::time::Instant::now() >= deadline { if tokio::time::Instant::now() >= deadline {
next_flush_at = None; next_flush_at = None;
@@ -360,7 +360,7 @@ pub(crate) async fn stream_llm(
// 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等) // 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog // 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) notify.notified():用户停止即时打断 // 3) notify.notified():用户停止即时打断
// 4) flush 定时器(sleep_until next_flush_at):尾部 delta 不因流静默而延迟(AR-8) // 4) flush 定时器(sleep_until next_flush_at):尾部 delta 不因流静默而延迟。
let flush_deadline = next_flush_at let flush_deadline = next_flush_at
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600)); .unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600));
let flush_fut = tokio::time::sleep_until(flush_deadline); let flush_fut = tokio::time::sleep_until(flush_deadline);
@@ -412,7 +412,7 @@ pub(crate) async fn stream_llm(
idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT; idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT;
if !chunk.delta.is_empty() { if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta); full_text.push_str(&chunk.delta);
// AR-8: 合批入 buffer,首个入 buffer 时置 flush 时刻(到点统一 emit 单条)。 // 合批入 buffer,首个入 buffer 时置 flush 时刻(到点统一 emit 单条)。
pending_delta.push_str(&chunk.delta); pending_delta.push_str(&chunk.delta);
if next_flush_at.is_none() { if next_flush_at.is_none() {
next_flush_at = Some(tokio::time::Instant::now() + DELTA_FLUSH_INTERVAL); next_flush_at = Some(tokio::time::Instant::now() + DELTA_FLUSH_INTERVAL);
@@ -514,20 +514,20 @@ pub(crate) async fn stream_llm(
} }
} }
} }
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02) // 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset
_ = heartbeat.tick() => { _ = heartbeat.tick() => {
emit_ai_chat_event(app_handle, AiChatEvent::AiHeartbeat { emit_ai_chat_event(app_handle, AiChatEvent::AiHeartbeat {
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
} }
// 3) 即时停止唤醒(B-260615-14) // 3) 即时停止唤醒
_ = notify.notified() => { _ = notify.notified() => {
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true; stopped = true;
break; break;
} }
} }
// 4) AR-8: delta 合批到点 flush(流静默/工具执行等待期也能按时发出尾部文本) // 4) delta 合批到点 flush(流静默/工具执行等待期也能按时发出尾部文本)
_ = &mut flush_fut => { _ = &mut flush_fut => {
if next_flush_at.is_some() { if next_flush_at.is_some() {
next_flush_at = None; next_flush_at = None;
@@ -589,7 +589,7 @@ pub(crate) async fn stream_llm(
/// - timeout / connect → retryable=true(瞬态,可重试) /// - timeout / connect → retryable=true(瞬态,可重试)
/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀) /// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀)
/// ///
/// CR-30-1: 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"), /// 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
/// 避免 stream_recv 与 agentic 各自重写状态码解析。 /// 避免 stream_recv 与 agentic 各自重写状态码解析。
fn classify_status_or_class(status_or_class: &str) -> bool { fn classify_status_or_class(status_or_class: &str) -> bool {
use df_ai::retry::is_status_retryable; use df_ai::retry::is_status_retryable;
@@ -612,7 +612,7 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
i += 1; i += 1;
} }
// 无三位数字码:按文本分类(timeout/connect/transport/decode 可重试,其余 unknown 保守不重试) // 无三位数字码:按文本分类(timeout/connect/transport/decode 可重试,其余 unknown 保守不重试)
// BUG-260617-01:Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀, // Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀,
// 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。 // 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。
// 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。 // 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。
// //
@@ -694,7 +694,7 @@ fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool {
"api-key", "api-key",
"api_key", "api_key",
"unauthorized", "unauthorized",
// 中文签名(BUG-260617-01:Anthropic 中文参数错误) // 中文签名Anthropic 中文参数错误)
"鉴权", "鉴权",
"认证失败", "认证失败",
"未授权", "未授权",
@@ -746,7 +746,7 @@ mod tests {
assert!(classify_status_or_class("connect")); assert!(classify_status_or_class("connect"));
} }
/// unknown → 保守 retryable=false(BUG-260617-01:防 Anthropic 中文参数错误等 Fatal 空耗重试) /// unknown → 保守 retryable=false防 Anthropic 中文参数错误等 Fatal 空耗重试)
#[test] #[test]
fn classify_unknown_not_retryable() { fn classify_unknown_not_retryable() {
assert!(!classify_status_or_class("unknown")); assert!(!classify_status_or_class("unknown"));
@@ -941,7 +941,7 @@ mod tests {
/// 状态码出现在更长数字串里也不误匹配(边界判断:前后必须非数字) /// 状态码出现在更长数字串里也不误匹配(边界判断:前后必须非数字)
#[test] #[test]
fn diag_does_not_match_status_inside_longer_digits() { fn diag_does_not_match_status_inside_longer_digits() {
// R-P2-6:边界判断后,白名单码嵌在长数字串内不再误命中(原窗口切片会从 14012 抠出 401) // 边界判断后,白名单码嵌在长数字串内不再误命中(原窗口切片会从 14012 抠出 401)
let e = anyhow::anyhow!("port 14012 used"); let e = anyhow::anyhow!("port 14012 used");
assert_eq!(extract_error_diag(&e).0, "unknown"); assert_eq!(extract_error_diag(&e).0, "unknown");
// 纯长数字串(无白名单码)同样不命中 // 纯长数字串(无白名单码)同样不命中
@@ -957,7 +957,7 @@ mod tests {
assert_eq!(s, "[anthropic-compat] AI 调用失败(HTTP 401): Unauthorized"); assert_eq!(s, "[anthropic-compat] AI 调用失败(HTTP 401): Unauthorized");
} }
/// R-P2-6:状态码紧贴中文字符(多字节 UTF-8)时仍能正确抠出 /// 状态码紧贴中文字符(多字节 UTF-8)时仍能正确抠出
/// (原按字节窗口切片在中文边界脆弱,改为 char 迭代后安全) /// (原按字节窗口切片在中文边界脆弱,改为 char 迭代后安全)
#[test] #[test]
fn diag_extracts_code_adjacent_to_multibyte_chars() { fn diag_extracts_code_adjacent_to_multibyte_chars() {
+2 -2
View File
@@ -8,7 +8,7 @@ use tokio::sync::Mutex;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// F-01 阶段5: 标题生成路由 — TaskRequirements(无需工具)。 // F-01 阶段5: 标题生成路由 — TaskRequirements(无需工具)。
// 标题是短文本摘要任务;池空/无匹配兜底 default_model(行为不变)。 // 标题是短文本摘要任务;池空/无匹配兜底 default_model(行为不变)。
// 注:路由已解耦(B-260618-03),原 Lite/Low 智力成本约束已删除,纯 weight 选模型。 // 注:路由已解耦,原 Lite/Low 智力成本约束已删除,纯 weight 选模型。
use df_ai::router::{ use df_ai::router::{
is_likely_image_model, select_model_id, Modality, TaskRequirements, is_likely_image_model, select_model_id, Modality, TaskRequirements,
}; };
@@ -121,7 +121,7 @@ pub(crate) async fn ensure_conversation_title(
); );
return; return;
} }
// B-260617-17: LLM 调用包 20s 超时,防信号量/网络挂起致后台 task 永久堆积; // LLM 调用包 20s 超时,防信号量/网络挂起致后台 task 永久堆积;
// 超时或返回 None 均保留上方已落的 extract 兜底,不覆盖。 // 超时或返回 None 均保留上方已落的 extract 兜底,不覆盖。
let title = match tokio::time::timeout( let title = match tokio::time::timeout(
std::time::Duration::from_secs(20), std::time::Duration::from_secs(20),
+20 -20
View File
@@ -33,7 +33,7 @@ use crate::state::AllowedDirs;
pub(crate) const MAX_LIST_RESULTS: usize = 50; pub(crate) const MAX_LIST_RESULTS: usize = 50;
/// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。 /// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源F-260616-04 /// 提取为常量便于在超时标注处引用同一来源。
/// ///
/// tool_registry 拆分:run_command handler 迁声明式(tools/file.rs),复用本常量保单真相源 /// tool_registry 拆分:run_command handler 迁声明式(tools/file.rs),复用本常量保单真相源
/// (避免字面量 60 漂移),故 pub(crate) 暴露给 tools 子模块。 /// (避免字面量 60 漂移),故 pub(crate) 暴露给 tools 子模块。
@@ -48,7 +48,7 @@ pub(crate) const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600;
/// AE-2025-03(路径 B):audit.rs 挂起审批前预读旧文件复用此函数生成 diff, /// AE-2025-03(路径 B):audit.rs 挂起审批前预读旧文件复用此函数生成 diff,
/// 供前端审批卡即时预览(write_file 审批不再只看裸 content)。故 pub(crate)。 /// 供前端审批卡即时预览(write_file 审批不再只看裸 content)。故 pub(crate)。
/// ///
/// BUG-260617-07: LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB /// LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB
/// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码 /// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码
/// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为 /// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为
/// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。 /// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。
@@ -118,7 +118,7 @@ pub(crate) fn generate_diff(old: &str, new: &str) -> String {
/// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过 /// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过
/// ///
/// 安全要点(BUG-260617-03): /// 安全要点
/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。 /// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。
/// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过, /// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过,
/// 解码后再走分段归一化校验。 /// 解码后再走分段归一化校验。
@@ -151,7 +151,7 @@ pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> {
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) { if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) {
anyhow::bail!("禁止访问敏感系统目录"); anyhow::bail!("禁止访问敏感系统目录");
} }
// F-260620: AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。 // AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
// AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog // AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog
// → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist // → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
// 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治—— // 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
@@ -180,7 +180,7 @@ pub(crate) fn truncate_output(s: &str, max: usize) -> (String, bool) {
} }
// ============================================================ // ============================================================
// patch_file 三模式共用底层F-260617-01 // patch_file 三模式共用底层
// //
// 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点), // 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 后两者均归约为「按行号区间 [start, end]1-based 含首尾)替换为 new_text」, // 后两者均归约为「按行号区间 [start, end]1-based 含首尾)替换为 new_text」,
@@ -407,7 +407,7 @@ pub(crate) fn resolve_workspace_path_with_allowed(
resolve_workspace_path_impl(path, allowed) resolve_workspace_path_impl(path, allowed)
} }
/// AE-2025-04mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数 /// mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
/// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数 /// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。 /// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
/// ///
@@ -631,7 +631,7 @@ fn register_local_proxy_tools(
/// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。 /// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
/// ///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。 /// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经 /// 例外:run_workflow handler 防御返回 Err,不持 db 不 clone,真正执行经
/// ai_approve → run_workflow_inner 另走完整 State 路径。 /// ai_approve → run_workflow_inner 另走完整 State 路径。
/// ///
/// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD /// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD
@@ -641,7 +641,7 @@ fn register_local_proxy_tools(
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/ /// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
/// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。 /// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。
/// ///
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。 /// 抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。 /// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
/// ///
/// SMELL-P0-2 续拆(2026-06-19):18 工具按业务表细分为 5 子函数: /// SMELL-P0-2 续拆(2026-06-19):18 工具按业务表细分为 5 子函数:
@@ -761,7 +761,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} }
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。 /// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。 /// 从 build_ai_tool_registry 抽出1091行单函数拆分,build 1090→~390 行)。
/// ///
/// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`, /// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`,
/// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。 /// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。
@@ -852,11 +852,11 @@ pub(crate) fn list_dir_recursive<'a>(
continue; continue;
} }
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物, // 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03) // 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文。
if skip_noise && is_noise_file(&name) { if skip_noise && is_noise_file(&name) {
continue; continue;
} }
// FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标, // 用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
// 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录 // 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
let file_type = entry.file_type().await?; let file_type = entry.file_type().await?;
let is_symlink = file_type.is_symlink(); let is_symlink = file_type.is_symlink();
@@ -929,7 +929,7 @@ pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
"create_idea" => Some("捕获灵感"), "create_idea" => Some("捕获灵感"),
"update_task" => Some("修改任务"), "update_task" => Some("修改任务"),
// advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool // advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
// 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息(UX-260618-14) // 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息
"run_workflow" => Some("触发工作流"), "run_workflow" => Some("触发工作流"),
"delete_task" => Some("删除任务"), "delete_task" => Some("删除任务"),
"update_idea" => Some("修改灵感"), "update_idea" => Some("修改灵感"),
@@ -948,7 +948,7 @@ pub(crate) fn is_noise_dir(name: &str) -> bool {
const NOISE_DIRS: &[&str] = &[ const NOISE_DIRS: &[&str] = &[
".git", "node_modules", "target", "dist", "build", ".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea", ".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory ".trash", // delete_file 软删除回收站,不污染 list_directory
]; ];
NOISE_DIRS.contains(&name) NOISE_DIRS.contains(&name)
} }
@@ -964,7 +964,7 @@ pub(crate) fn is_noise_file(name: &str) -> bool {
} }
// ============================================================ // ============================================================
// grep 工具底层F-260621 // grep 工具底层
// //
// FileGrepHit 单文件命中聚合:line_matches 按行号升序。output_mode 分派时: // FileGrepHit 单文件命中聚合:line_matches 按行号升序。output_mode 分派时:
// - content: 展平 line_matches 为 matches[{file,line,content,context?}] // - content: 展平 line_matches 为 matches[{file,line,content,context?}]
@@ -1101,7 +1101,7 @@ pub(crate) fn grep_recursive<'a>(
} }
continue; continue;
} }
// 文件处理(BUG-260625-01:提取为 grep_one_file,grep handler 单文件路径复用同一逻辑) // 文件处理提取为 grep_one_file,grep handler 单文件路径复用同一逻辑)
grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?; grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?;
} }
Ok(()) Ok(())
@@ -1109,7 +1109,7 @@ pub(crate) fn grep_recursive<'a>(
} }
/// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。 /// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。
/// 提取自 grep_recursive 循环体(BUG-260625-01),供 grep handler 单文件路径复用—— /// 提取自 grep_recursive 循环体,供 grep handler 单文件路径复用——
/// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。 /// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。
/// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。 /// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。
pub(crate) async fn grep_one_file( pub(crate) async fn grep_one_file(
@@ -1317,7 +1317,7 @@ mod tests {
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx());
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 // file 层 10→11(新增 grep 跨文件内容搜索工具)。
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。 // L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
// AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。 // AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。
// 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具: // 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具:
@@ -1332,7 +1332,7 @@ mod tests {
// register_git_tools 3 个) // register_git_tools 3 个)
// Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge, // Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge,
// register_git_tools 3→6) // register_git_tools 3→6)
// BUG-2026-07-07: 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。 // 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。
// 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。 // 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。
// update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具, // update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具,
// idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。 // idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。
@@ -1780,7 +1780,7 @@ mod tests {
} }
// ============================================================ // ============================================================
// patch_file 三模式共用底层测试F-260617-01 // patch_file 三模式共用底层测试
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。 // 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
// handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成, // handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成,
// 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心;old_text 模式逻辑 // 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心;old_text 模式逻辑
@@ -1942,7 +1942,7 @@ mod tests {
} }
// ============================================================ // ============================================================
// grep 工具测试F-260621 // grep 工具测试
// //
// compile_glob_to_regex:纯函数,无 fs 依赖。 // compile_glob_to_regex:纯函数,无 fs 依赖。
// grep_recursive:递归遍历临时目录树,覆盖基本匹配/正则/大小写/glob/噪音跳过/截断。 // grep_recursive:递归遍历临时目录树,覆盖基本匹配/正则/大小写/glob/噪音跳过/截断。
+23 -23
View File
@@ -81,7 +81,7 @@ pub fn register(
&snap, &snap,
)?; )?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限) // 单次 File::open 取 metadata+read 消除 TOCTOU原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let mut file = File::open(path).await let mut file = File::open(path).await
@@ -143,7 +143,7 @@ pub fn register(
})); }));
} }
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
// BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件, // 无 offset 时旧实现 content.clone() 全量返回大文件,
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。 // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。 // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
let line_count = content.lines().count(); let line_count = content.lines().count();
@@ -157,7 +157,7 @@ pub fn register(
} else { } else {
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义), // 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。 // 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit, // 旧实现固定 take(500) 忽略 limit,
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。 // 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
const DEFAULT_PREVIEW_LINES: usize = 500; const DEFAULT_PREVIEW_LINES: usize = 500;
let limit = args["limit"].as_u64() let limit = args["limit"].as_u64()
@@ -319,7 +319,7 @@ pub fn register(
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false); let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) // max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
let mut entries = Vec::new(); let mut entries = Vec::new();
@@ -348,7 +348,7 @@ pub fn register(
)?; )?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等) // encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节 // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8"); let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") { let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
@@ -360,7 +360,7 @@ pub fn register(
} else { } else {
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding); anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding);
}; };
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史) // 写入大小上限原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if bytes.len() > 1_048_576 { if bytes.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len()); anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
} }
@@ -378,8 +378,8 @@ pub fn register(
Err(_) => None, // 不存在(新建) Err(_) => None, // 不存在(新建)
}; };
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
// FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。 // parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目 // 原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。 // 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
if !snap.is_authorized(parent) { if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录"); anyhow::bail!("禁止在项目目录之外创建目录");
@@ -397,7 +397,7 @@ pub fn register(
let _ = tokio::fs::remove_file(&tmp).await; let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e)); return Err(anyhow::anyhow!("原子替换失败: {}", e));
} }
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要); // rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。 // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件) // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size.map(|s| s > 0).unwrap_or(false) { if old_size.map(|s| s > 0).unwrap_or(false) {
@@ -464,7 +464,7 @@ pub fn register(
let new_text = args["new_text"].as_str() let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor // 三模式互斥校验(old_text / replace_lines / anchor
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式) // 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false); let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false); let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
@@ -491,7 +491,7 @@ pub fn register(
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len()); anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
} }
// TD-260621-03:读改写整体锁内防 lost update。 // 读改写整体锁内防 lost update。
// 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件: // 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
// A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。 // A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
// 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。 // 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
@@ -528,7 +528,7 @@ pub fn register(
} }
} }
// F-260617-01: 三模式分派计算 new_content + match_count + warning // 三模式分派计算 new_content + match_count + warning
// old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处) // old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处)
// replace_lines 模式:行号区间 splice(越界 Err) // replace_lines 模式:行号区间 splice(越界 Err)
// anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err // anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err
@@ -548,7 +548,7 @@ pub fn register(
})); }));
} }
// L2: old_text 精确匹配(CAS 语义) // L2: old_text 精确匹配(CAS 语义)
// 失败时附相近片段提示(AC-5):把文件里与 old_text 相似的几行列给 LLM, // 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则 // 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
// 退回原提示,不误导。 // 退回原提示,不误导。
if !content.contains(old_text) { if !content.contains(old_text) {
@@ -656,7 +656,7 @@ pub fn register(
let modified = metadata.modified() let modified = metadata.modified()
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64); .map(|d| d.as_millis() as i64);
// is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02) // is_binary: 流式读前 8KB 检测 \x00
// 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192, // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
// >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。 // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
// 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测, // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
@@ -701,7 +701,7 @@ pub fn register(
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
if let Some(parent) = std::path::Path::new(path).parent() { if let Some(parent) = std::path::Path::new(path).parent() {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 // 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) { if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录"); anyhow::bail!("禁止在项目目录之外创建目录");
} }
@@ -779,7 +779,7 @@ pub fn register(
let backup_name = format!("{}-{}", new_id(), file_name); let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name); let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string(); let backup_path_str = backup_path.to_string_lossy().to_string();
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17, // 软删除跨盘降级同盘 rename 原子;跨盘(Windows EXDEV os error 17,
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子, // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
// 失败回滚删 backup 保源完整)。 // 失败回滚删 backup 保源完整)。
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理), // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
@@ -846,7 +846,7 @@ pub fn register(
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败) // 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
let to_target = std::path::Path::new(to_path); let to_target = std::path::Path::new(to_path);
if let Some(parent) = to_target.parent() { if let Some(parent) = to_target.parent() {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 // 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) { if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录"); anyhow::bail!("禁止在项目目录之外创建目录");
} }
@@ -959,7 +959,7 @@ pub fn register(
let mut matches_out: Vec<FileGrepHit> = Vec::new(); let mut matches_out: Vec<FileGrepHit> = Vec::new();
let mut total: usize = 0; let mut total: usize = 0;
let mut truncated = false; let mut truncated = false;
// BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。 // path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。 // 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。 // 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
let root_meta = tokio::fs::metadata(root).await let root_meta = tokio::fs::metadata(root).await
@@ -1103,7 +1103,7 @@ pub fn register(
let mut out = serde_json::json!({ let mut out = serde_json::json!({
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
}); });
// AC-5:total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。 // total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。 // 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total == 0 { if total == 0 {
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配"); out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
@@ -1156,7 +1156,7 @@ pub fn register(
timeout_secs: Some(timeout_secs), timeout_secs: Some(timeout_secs),
shell_type: Default::default(), shell_type: Default::default(),
}; };
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 // 超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
@@ -1391,13 +1391,13 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
} }
// ============================================================ // ============================================================
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选(AC-5) // read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
// ============================================================ // ============================================================
/// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。 /// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。
/// ///
/// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示, /// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示,
/// 治 LLM 拼错文件名/少写扩展名后盲目重试(AC-5)。低频错误路径(文件不存在),同步 std::fs::read_dir /// 治 LLM 拼错文件名/少写扩展名后盲目重试。低频错误路径(文件不存在),同步 std::fs::read_dir
/// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。 /// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。
fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<String> { fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<String> {
if needle.is_empty() { if needle.is_empty() {
@@ -1445,7 +1445,7 @@ fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<St
scored.into_iter().map(|(_, s)| s).collect() scored.into_iter().map(|(_, s)| s).collect()
} }
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选(AC-5) /// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选。
/// ///
/// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。 /// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。
fn file_not_found_error(path: &str) -> anyhow::Error { fn file_not_found_error(path: &str) -> anyhow::Error {
+2 -2
View File
@@ -34,7 +34,7 @@ use df_storage::db::Database;
/// 只读工具(status/diff/log)直接调本函数拿退出码做差异语义:git diff 退 1 = 有差异是 /// 只读工具(status/diff/log)直接调本函数拿退出码做差异语义:git diff 退 1 = 有差异是
/// **合法成功**、git log 退 128 = 非 git 仓是失败;单纯 bool(exit==0)无法区分。 /// **合法成功**、git log 退 128 = 非 git 仓是失败;单纯 bool(exit==0)无法区分。
/// ///
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout /// 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, /// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime /// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。 /// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
@@ -42,7 +42,7 @@ use df_storage::db::Database;
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 /// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。 /// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
/// ///
/// BUG-2026-08-02: 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作 /// 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作
/// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。 /// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。
/// 改返 (String, bool),上层据 success 判定真实成败。 /// 改返 (String, bool),上层据 success 判定真实成败。
async fn exec_git_exit(working_dir: &str, args: &[&str]) -> (String, Option<i32>) { async fn exec_git_exit(working_dir: &str, args: &[&str]) -> (String, Option<i32>) {
+1 -1
View File
@@ -111,7 +111,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let repo = df_storage::crud::ProjectRepo::new(&db); let repo = df_storage::crud::ProjectRepo::new(&db);
let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?; let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?;
// F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory // 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory
// 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs // 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs
// (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。 // (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。
// 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。 // 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。
+3 -3
View File
@@ -150,7 +150,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 透传 affected:0 行(id 不存在 / 已软删)→ updated=false + title 空, // 透传 affected:0 行(id 不存在 / 已软删)→ updated=false + title 空,
// 让 AI 知道 id 不对而非假成功(对齐 update_idea)。 // 让 AI 知道 id 不对而非假成功(对齐 update_idea)。
let updated = repo.update_field(id, field, value).await?; let updated = repo.update_field(id, field, value).await?;
// UX-260619-04: 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord, // 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord,
// 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。 // 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。
let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default(); let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default();
Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": updated })) Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": updated }))
@@ -158,7 +158,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
); );
// F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。 // F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。
// D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落 // 推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落
// df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC // df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC
// commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。 // commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。
// 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。 // 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。
@@ -203,7 +203,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} }
); );
// FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除) // delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
// tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project) // tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
declare_tool!( declare_tool!(
registry, registry,
+1 -1
View File
@@ -53,7 +53,7 @@ fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> {
return None; return None;
} }
let host = url.host_str()?; let host = url.host_str()?;
// BUG-2026-07-07: port_or_known_default() 对 https 返 Some(443)、http 返 Some(80), // port_or_known_default() 对 https 返 Some(443)、http 返 Some(80),
// 致 authority 总是带默认端口(:443/:80),base URL 形如 "https://host:443" 而非规范 // 致 authority 总是带默认端口(:443/:80),base URL 形如 "https://host:443" 而非规范
// "https://host"。下游 Gitea API 调用虽大多容忍,但测试断言与可读性受影响。 // "https://host"。下游 Gitea API 调用虽大多容忍,但测试断言与可读性受影响。
// 修复:仅当端口非 scheme 默认端口时才拼入 authority(显式非默认端口才保留)。 // 修复:仅当端口非 scheme 默认端口时才拼入 authority(显式非默认端口才保留)。
+9 -9
View File
@@ -34,7 +34,7 @@ fn default_priority() -> i32 {
/// 列出灵感。 /// 列出灵感。
/// ///
/// **双路径向后兼容**(F-260621-02): /// **双路径向后兼容**
/// - 旧调用方仅传 `status`(`ideaApi.list(status)`)→ 转 IdeaQuery 仅带 status,走 /// - 旧调用方仅传 `status`(`ideaApi.list(status)`)→ 转 IdeaQuery 仅带 status,走
/// `list_by_query`(白名单 status 列 WHERE),与原 `query("status", s)` 等价。 /// `list_by_query`(白名单 status 列 WHERE),与原 `query("status", s)` 等价。
/// - 新调用方传 `query`(`ideaApi.list(query)`)→ 多条件(status/keyword/order_by/limit/offset)。 /// - 新调用方传 `query`(`ideaApi.list(query)`)→ 多条件(status/keyword/order_by/limit/offset)。
@@ -73,7 +73,7 @@ pub async fn create_idea(
state: State<'_, AppState>, state: State<'_, AppState>,
input: CreateIdeaInput, input: CreateIdeaInput,
) -> Result<IdeaRecord, String> { ) -> Result<IdeaRecord, String> {
// IDEA-FIX-03: priority 值域校验 ∈ 0..=3 (对标 tasks B-260615-15)。 // priority 值域校验 ∈ 0..=3 (对标 tasks B-260615-15)。
// priority_from_i32 对越界值兜底归并(>=3→Critical),但 IPC 入口应显式拒非法值, // priority_from_i32 对越界值兜底归并(>=3→Critical),但 IPC 入口应显式拒非法值,
// 防 LLM/前端传 99 等被静默吞为 Critical。 // 防 LLM/前端传 99 等被静默吞为 Critical。
if !(0..=3).contains(&input.priority) { if !(0..=3).contains(&input.priority) {
@@ -121,7 +121,7 @@ pub async fn update_idea(
field: String, field: String,
value: String, value: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
// BE-CMD-4:status 值合法性校验(防任意值进库)+ 拒绝经 update_field 直达 promoted // status 值合法性校验(防任意值进库)+ 拒绝经 update_field 直达 promoted
// (半立项:绕过 promote_idea 不建项目不写 promoted_to,须走立项流程)。 // (半立项:绕过 promote_idea 不建项目不写 promoted_to,须走立项流程)。
if field == "status" { if field == "status" {
if IdeaStatus::from_db_str(value.trim()).is_none() { if IdeaStatus::from_db_str(value.trim()).is_none() {
@@ -142,7 +142,7 @@ pub async fn update_idea(
serde_json::from_str::<serde_json::Value>(&value) serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| format!("{field} 不是合法 JSON: {e}"))?; .map_err(|e| format!("{field} 不是合法 JSON: {e}"))?;
} }
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。 // update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。
let updated = state let updated = state
.ideas .ideas
.update_field_active(&id, &field, &value) .update_field_active(&id, &field, &value)
@@ -230,7 +230,7 @@ pub async fn promote_idea(
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// LW-8(BE-CMD-7):CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。 // LW-8CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。
// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」, // 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」,
// 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。 // 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。
// 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目。 // 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目。
@@ -386,7 +386,7 @@ async fn evaluate_one(
engine: &df_ideas::adversarial::AdversarialEngine, engine: &df_ideas::adversarial::AdversarialEngine,
) -> Result<IdeaRecord, String> { ) -> Result<IdeaRecord, String> {
let id = record.id.clone(); let id = record.id.clone();
// LW-7(BE-CMD-6):终态灵感不可再评估(防无条件覆盖 pending_review 打回终态)。 // LW-7终态灵感不可再评估(防无条件覆盖 pending_review 打回终态)。
// promoted(已立项)/archived(已归档)是终态,评估会把 status 覆盖回 pending_review, // promoted(已立项)/archived(已归档)是终态,评估会把 status 覆盖回 pending_review,
// 破坏「已立项/已归档不可回退」语义。软删灵感由 evaluate_idea/batch 的存在性检查已过滤。 // 破坏「已立项/已归档不可回退」语义。软删灵感由 evaluate_idea/batch 的存在性检查已过滤。
if matches!(record.status, IdeaStatus::Promoted | IdeaStatus::Archived) { if matches!(record.status, IdeaStatus::Promoted | IdeaStatus::Archived) {
@@ -545,7 +545,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
}, },
None => Vec::new(), None => Vec::new(),
}; };
// IDEA-FIX-05: 读真实 related_ids(原硬编码 Vec::new() 丢关联上下文)。解析同 tags 模式。 // 读真实 related_ids(原硬编码 Vec::new() 丢关联上下文)。解析同 tags 模式。
let related_ids: Vec<String> = match record.related_ids.as_deref() { let related_ids: Vec<String> = match record.related_ids.as_deref() {
Some(r) => match serde_json::from_str::<Vec<String>>(r) { Some(r) => match serde_json::from_str::<Vec<String>>(r) {
Ok(v) => v, Ok(v) => v,
@@ -560,7 +560,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
id: record.id.clone(), id: record.id.clone(),
title: record.title.clone(), title: record.title.clone(),
description: record.description.clone(), description: record.description.clone(),
// IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性) // 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性)
status: status_from_str(record.status.as_str()), status: status_from_str(record.status.as_str()),
priority: priority_from_i32(record.priority), priority: priority_from_i32(record.priority),
scores: None, scores: None,
@@ -587,7 +587,7 @@ fn priority_from_i32(p: i32) -> Priority {
} }
} }
/// IDEA-FIX-05: idea status 字符串(DB snake_case)→ IdeaStatus 枚举。 /// idea status 字符串(DB snake_case)→ IdeaStatus 枚举。
/// record_to_idea 读真实 status 用(原硬编码 Draft 丢真实状态)。未知值 fallback Draft(同原行为)。 /// record_to_idea 读真实 status 用(原硬编码 Draft 丢真实状态)。未知值 fallback Draft(同原行为)。
fn status_from_str(s: &str) -> df_types::types::IdeaStatus { fn status_from_str(s: &str) -> df_types::types::IdeaStatus {
use df_types::types::IdeaStatus; use df_types::types::IdeaStatus;
+9 -9
View File
@@ -8,7 +8,7 @@ use tauri::State;
use df_ai::provider::{ChatMessage, CompletionRequest}; use df_ai::provider::{ChatMessage, CompletionRequest};
// F-01 阶段5: 项目扫描描述路由 — TaskRequirements(采样含图时追加 Vision, // F-01 阶段5: 项目扫描描述路由 — TaskRequirements(采样含图时追加 Vision,
// 当前采样(ProjectSample)未含图像,故仅 Text)。池空/无匹配兜底 default_model。 // 当前采样(ProjectSample)未含图像,故仅 Text)。池空/无匹配兜底 default_model。
// 注:路由已解耦(B-260618-03),原 Standard 智力约束已删除,纯 weight 选模型。 // 注:路由已解耦,原 Standard 智力约束已删除,纯 weight 选模型。
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
@@ -78,7 +78,7 @@ pub struct CreateProjectInput {
/// 列出未删除项目(过滤回收站)。 /// 列出未删除项目(过滤回收站)。
/// ///
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active /// 吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。 /// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
/// ///
/// 问题3(项目最近活跃排序):默认无 query 路径改走 list_active_with_activity,返回 /// 问题3(项目最近活跃排序):默认无 query 路径改走 list_active_with_activity,返回
@@ -228,7 +228,7 @@ async fn create_with_binding(
tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e); tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e);
} }
} }
// F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent), // 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
// 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5) // 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
if record.path.is_some() { if record.path.is_some() {
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
@@ -338,7 +338,7 @@ pub async fn get_project(
/// 更新项目单个字段(字段名走 df-storage 白名单校验) /// 更新项目单个字段(字段名走 df-storage 白名单校验)
/// ///
/// B-260801-01(P0-1):0 行(id 不存在 / 已软删)→ 返 Err 而非假成功。原实现无视 /// B-260801-010 行(id 不存在 / 已软删)→ 返 Err 而非假成功。原实现无视
/// update_field 返回值恒返 Ok(true),前端误以为成功但库无变更(UI/DB 不一致)。 /// update_field 返回值恒返 Ok(true),前端误以为成功但库无变更(UI/DB 不一致)。
/// ///
/// 返回值保持 bool(成功恒 true):前端 api 包装 `Promise<boolean>` 契约不变零破坏, /// 返回值保持 bool(成功恒 true):前端 api 包装 `Promise<boolean>` 契约不变零破坏,
@@ -351,7 +351,7 @@ pub async fn update_project(
field: String, field: String,
value: String, value: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
// BE-CMD-3:白名单剔除 id/created_at 已下沉 storage(allowed_columns_for,update_field 直拒, // 白名单剔除 id/created_at 已下沉 storage(allowed_columns_for,update_field 直拒,
// 防改主键/伪造创建时间致子表悬空),此处补剩余字段的语义校验: // 防改主键/伪造创建时间致子表悬空),此处补剩余字段的语义校验:
// - status:值合法性(ProjectStatus::from_db_str,防任意值进库静默归 planning) // - status:值合法性(ProjectStatus::from_db_str,防任意值进库静默归 planning)
// - stack:JSON 合法性(防脏 JSON 落库) // - stack:JSON 合法性(防脏 JSON 落库)
@@ -396,7 +396,7 @@ pub async fn update_project(
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
return Ok(true); return Ok(true);
} }
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站项目不可改字段。 // update_field_active 过滤软删(deleted_at IS NULL),回收站项目不可改字段。
let updated = state let updated = state
.projects .projects
.update_field_active(&id, &field, &value) .update_field_active(&id, &field, &value)
@@ -405,7 +405,7 @@ pub async fn update_project(
if !updated { if !updated {
return Err(format!("项目 ID {id} 不存在或已删除")); return Err(format!("项目 ID {id} 不存在或已删除"));
} }
// F-260620: path 字段变更后立即 reload 白名单(新绑定/重绑目录即时生效,防 AI 误弹窗) // path 字段变更后立即 reload 白名单(新绑定/重绑目录即时生效,防 AI 误弹窗)
if field == "path" { if field == "path" {
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
} }
@@ -448,7 +448,7 @@ pub async fn purge_project(state: State<'_, AppState>, id: String) -> Result<boo
/// 查找已绑定该目录的项目(排除 exclude_id 自身)。无冲突返回 None。 /// 查找已绑定该目录的项目(排除 exclude_id 自身)。无冲突返回 None。
/// ///
/// 委托 `ProjectRepo::find_path_conflict`(DRY R-PD-11:与 tool_registry::bind_dir_to_project /// 委托 `ProjectRepo::find_path_conflict`(DRY 与 tool_registry::bind_dir_to_project
/// 共用同一防重复绑定实现)。normalize_path 内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过。 /// 共用同一防重复绑定实现)。normalize_path 内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过。
async fn find_binding_conflict( async fn find_binding_conflict(
state: &AppState, state: &AppState,
@@ -508,7 +508,7 @@ pub async fn relocate_project_path(
.update_field(&id, "path", &new_path) .update_field(&id, "path", &new_path)
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// F-260620: 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗) // 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗)
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
state state
.projects .projects
+11 -11
View File
@@ -6,7 +6,7 @@ use tauri::State;
use df_types::types::{new_id, TaskStatus}; use df_types::types::{new_id, TaskStatus};
use df_storage::crud::TaskQuery; use df_storage::crud::TaskQuery;
use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord}; use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord};
// LW-9(BE-CMD-8):父任务聚合重算(df-nodes 共享层,与 advance_task 同源,消除双轨)。 // LW-9父任务聚合重算(df-nodes 共享层,与 advance_task 同源,消除双轨)。
use df_nodes::task_advance_node::recompute_parent_status; use df_nodes::task_advance_node::recompute_parent_status;
use crate::state::AppState; use crate::state::AppState;
@@ -247,7 +247,7 @@ pub async fn create_task(
state: State<'_, AppState>, state: State<'_, AppState>,
input: CreateTaskInput, input: CreateTaskInput,
) -> Result<TaskRecord, String> { ) -> Result<TaskRecord, String> {
// ── BE-CMD-1:空 title trim 拒空 + priority 值域校验(对齐前端 PRIORITY_LABELS 与 // ── 空 title trim 拒空 + priority 值域校验(对齐前端 PRIORITY_LABELS 与
// update_task 校验,拦截空白标题 / 99 等脏数据静默落库)。 ── // update_task 校验,拦截空白标题 / 99 等脏数据静默落库)。 ──
let title = input.title.trim().to_string(); let title = input.title.trim().to_string();
if title.is_empty() { if title.is_empty() {
@@ -321,7 +321,7 @@ pub async fn create_task(
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
// F-260619-01:空字符串视为不关联(与 AI 工具层一致) // 空字符串视为不关联(与 AI 工具层一致)
idea_id: input.idea_id.filter(|s| !s.is_empty()), idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 多工程:可选关联到具体工程(module) // 多工程:可选关联到具体工程(module)
module_id: input.module_id.filter(|s| !s.is_empty()), module_id: input.module_id.filter(|s| !s.is_empty()),
@@ -348,7 +348,7 @@ pub async fn create_task(
Some(record.status.as_str()), Some(record.status.as_str()),
) )
.await; .await;
// LW-9(BE-CMD-8):建子任务后触发父聚合重算(容器模型,父 status 由子任务聚合)。 // LW-9建子任务后触发父聚合重算(容器模型,父 status 由子任务聚合)。
// 聚合失败仅 warn 不阻断(对齐 advance_task 宽容语义)。 // 聚合失败仅 warn 不阻断(对齐 advance_task 宽容语义)。
if let Some(pid) = &record.parent_id { if let Some(pid) = &record.parent_id {
if let Err(e) = recompute_parent_status(&state.tasks, pid).await { if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
@@ -365,7 +365,7 @@ pub async fn create_task(
/// 更新任务单个字段(字段名走 df-storage 白名单校验;status 值走枚举校验)。 /// 更新任务单个字段(字段名走 df-storage 白名单校验;status 值走枚举校验)。
/// ///
/// B-260801-01(P0-1):0 行(id 不存在 / 已软删)→ 返 Err 而非 false,杜绝「假成功」 /// B-260801-010 行(id 不存在 / 已软删)→ 返 Err 而非 false,杜绝「假成功」
/// (前端 updateTask 仅 await 不读 bool,但 store.runWithCatch 把 Err 转 toast,用户能感知; /// (前端 updateTask 仅 await 不读 bool,但 store.runWithCatch 把 Err 转 toast,用户能感知;
/// 若返 false 则 store 静默忽略,本地状态前端手动改但库无变更,造成 UI 与 DB 不一致)。 /// 若返 false 则 store 静默忽略,本地状态前端手动改但库无变更,造成 UI 与 DB 不一致)。
/// ///
@@ -439,7 +439,7 @@ pub async fn update_task(
} }
// 空字符串 = None(解除父),合法放行 // 空字符串 = None(解除父),合法放行
} }
// LW-4(BE-CMD-1):queue 字段校验(白名单 + queue/status 一致性,读当前 status)。 // queue 字段校验(白名单 + queue/status 一致性,读当前 status)。
// update_task 只改单字段不改 status,故用严格一致性校验(与 move_task_queue 自动联动不同): // update_task 只改单字段不改 status,故用严格一致性校验(与 move_task_queue 自动联动不同):
// 设置不兼容的 queue 直接拒绝,调用方需走 move_task_queue 自动联动调整 status。 // 设置不兼容的 queue 直接拒绝,调用方需走 move_task_queue 自动联动调整 status。
if field == "queue" { if field == "queue" {
@@ -453,13 +453,13 @@ pub async fn update_task(
.ok_or_else(|| format!("任务 ID {id} 不存在或已删除"))?; .ok_or_else(|| format!("任务 ID {id} 不存在或已删除"))?;
assert_queue_status_consistent(q, current.status.as_str())?; assert_queue_status_consistent(q, current.status.as_str())?;
} }
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。 // update_field 返 affected>0;false = id 不存在或已软删(0 行)。
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。 // 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
// //
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated // 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。 // 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。 // 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。 // update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。
let current = state let current = state
.tasks .tasks
.get_by_id(&id) .get_by_id(&id)
@@ -473,7 +473,7 @@ pub async fn update_task(
if !updated { if !updated {
return Err(format!("任务 ID {id} 不存在或已删除")); return Err(format!("任务 ID {id} 不存在或已删除"));
} }
// LW-9(BE-CMD-8):改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。 // LW-9改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
// 从 current 读旧 parent_id(pre-update),value 是新父值。聚合失败仅 warn 不阻断。 // 从 current 读旧 parent_id(pre-update),value 是新父值。聚合失败仅 warn 不阻断。
if field == "parent_id" { if field == "parent_id" {
if let Some(old_pid) = current.as_ref().and_then(|c| c.parent_id.clone()) { if let Some(old_pid) = current.as_ref().and_then(|c| c.parent_id.clone()) {
@@ -590,7 +590,7 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。 /// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
/// ///
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合) /// thin 入口业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
/// 落 df-nodes::task_advance_node,本命令只做参数转发与结果包装。 /// 落 df-nodes::task_advance_node,本命令只做参数转发与结果包装。
/// ///
/// 返回: `Result<TaskRecord, String>` — 成功返更新后的任务;失败 Err(状态机拒绝/CAS 冲突/ /// 返回: `Result<TaskRecord, String>` — 成功返更新后的任务;失败 Err(状态机拒绝/CAS 冲突/
@@ -794,7 +794,7 @@ pub async fn move_task_queue(
.await; .await;
} }
// LW-9(BE-CMD-8):跨池移动后子任务 status 可能联动变化(一致性联动)→ 触发其父聚合重算。 // LW-9跨池移动后子任务 status 可能联动变化(一致性联动)→ 触发其父聚合重算。
// 仅当被移动任务是子任务;父任务自身 move 不重算自身聚合(容器 status 由子任务推进时重算)。 // 仅当被移动任务是子任务;父任务自身 move 不重算自身聚合(容器 status 由子任务推进时重算)。
// 聚合失败仅 warn 不阻断。 // 聚合失败仅 warn 不阻断。
if let Some(pid) = &updated.parent_id { if let Some(pid) = &updated.parent_id {
+13 -13
View File
@@ -102,9 +102,9 @@ pub async fn run_workflow(
task_id: Option<String>, task_id: Option<String>,
target_status: Option<String>, target_status: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
// 瘦转发到 run_workflow_inner(B-260617-01:抽取共用核心供 ai_approve 经 // 瘦转发到 run_workflow_inner抽取共用核心供 ai_approve 经
// execute_run_workflow_for_tool 直接调,无需构造 tauri::State)。 // execute_run_workflow_for_tool 直接调,无需构造 tauri::State)。
// CR-52: 命令层(前端 invoke run_workflow)是人工手动触发 → triggered_by="manual"。 // 命令层(前端 invoke run_workflow)是人工手动触发 → triggered_by="manual"。
run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual").await run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual").await
} }
@@ -117,7 +117,7 @@ pub async fn run_workflow(
/// `triggered_by` 落入 WorkflowRecord.triggered_by,区分触发来源: /// `triggered_by` 落入 WorkflowRecord.triggered_by,区分触发来源:
/// - 命令层 run_workflow(前端 invoke,人工手动)传 "manual" /// - 命令层 run_workflow(前端 invoke,人工手动)传 "manual"
/// - execute_run_workflow_for_tool(AI 工具经 ai_approve 审批后执行)传 "ai" /// - execute_run_workflow_for_tool(AI 工具经 ai_approve 审批后执行)传 "ai"
/// CR-52:原硬编码 "manual" 致 AI 路径误标,改由调用方透传。 /// 原硬编码 "manual" 致 AI 路径误标,改由调用方透传。
pub async fn run_workflow_inner( pub async fn run_workflow_inner(
app: &AppHandle, app: &AppHandle,
state: &AppState, state: &AppState,
@@ -148,7 +148,7 @@ pub async fn run_workflow_inner(
// 1. 先构建运行时 DAG,校验失败直接返回,不落库 // 1. 先构建运行时 DAG,校验失败直接返回,不落库
let runtime_dag = state.registry.build_dag(&dag).map_err(err_str)?; let runtime_dag = state.registry.build_dag(&dag).map_err(err_str)?;
// 2. P2-I: 反查 project_idtask_id 有值时),不阻塞工作流 // 2. 反查 project_idtask_id 有值时),不阻塞工作流
let project_id = match &task_id { let project_id = match &task_id {
Some(tid) => TaskRepo::new(&state.db) Some(tid) => TaskRepo::new(&state.db)
.get_by_id(tid) .get_by_id(tid)
@@ -167,7 +167,7 @@ pub async fn run_workflow_inner(
name, name,
dag_json, dag_json,
status: "running".to_string(), status: "running".to_string(),
// CR-52: triggered_by 由调用方透传(命令层 run_workflow="manual", // triggered_by 由调用方透传(命令层 run_workflow="manual",
// execute_run_workflow_for_tool="ai"),不再硬编码 "manual" 致 AI 路径误标。 // execute_run_workflow_for_tool="ai"),不再硬编码 "manual" 致 AI 路径误标。
triggered_by: Some(triggered_by.to_string()), triggered_by: Some(triggered_by.to_string()),
project_id, project_id,
@@ -185,7 +185,7 @@ pub async fn run_workflow_inner(
let mut rx = state.event_bus.subscribe(); let mut rx = state.event_bus.subscribe();
let forward_app = app.clone(); let forward_app = app.clone();
let forward_exec_id = execution_id.clone(); let forward_exec_id = execution_id.clone();
// B-260615-35: forward 任务内需查 DB 终态兜底 Lagged 丢终态事件,故 move 进 db 句柄 // forward 任务内需查 DB 终态兜底 Lagged 丢终态事件,故 move 进 db 句柄
let forward_db = state.db.clone(); let forward_db = state.db.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// Lagged 累计次数(用于"累计达阈值"触发兜底查询) // Lagged 累计次数(用于"累计达阈值"触发兜底查询)
@@ -195,7 +195,7 @@ pub async fn run_workflow_inner(
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(event) => { Ok(event) => {
// B-03b-R10 ③(波17 治本): 完成判定加 execution_id 匹配。 // b-R10 ③(波17 治本): 完成判定加 execution_id 匹配。
// AppState.event_bus 是全局单例,并发工作流各自订阅同一总线; // AppState.event_bus 是全局单例,并发工作流各自订阅同一总线;
// 此前 matches! 只看变体不看 exec_id,任一工作流的 WorkflowCompleted/Failed // 此前 matches! 只看变体不看 exec_id,任一工作流的 WorkflowCompleted/Failed
// 都会触发本 forward 循环 break,过早断他人链(并发串扰根因)。 // 都会触发本 forward 循环 break,过早断他人链(并发串扰根因)。
@@ -222,7 +222,7 @@ pub async fn run_workflow_inner(
break; break;
} }
} }
// R-PD-13: Lagged 表示消费过慢,broadcast 滑动窗口丢 n 条最旧事件。 // Lagged 表示消费过慢,broadcast 滑动窗口丢 n 条最旧事件。
// broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件。 // broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件。
// 风险:若关键终态事件(WorkflowCompleted/WorkflowFailed)被丢,finished 判定不触发, // 风险:若关键终态事件(WorkflowCompleted/WorkflowFailed)被丢,finished 判定不触发,
// 循环不会 break,前端永久收不到工作流结束信号。 // 循环不会 break,前端永久收不到工作流结束信号。
@@ -303,7 +303,7 @@ pub async fn run_workflow_inner(
.lock() .lock()
.await .await
.insert(exec_id.clone(), executor.state_machine()); .insert(exec_id.clone(), executor.state_machine());
// P0-A: 把 task_id 注入 config,使 AiSelfReviewNode(ctx.config.get("task_id")) 与 // 把 task_id 注入 config,使 AiSelfReviewNode(ctx.config.get("task_id")) 与
// AiNode parse_params 兜底(ctx.config.get("task_id") 读 task 生成默认 prompt) 可读。 // AiNode parse_params 兜底(ctx.config.get("task_id") 读 task 生成默认 prompt) 可读。
// config 是 serde_json::Value,仅 object 时 insert 安全;task_id None 跳过(向后兼容)。 // config 是 serde_json::Value,仅 object 时 insert 安全;task_id None 跳过(向后兼容)。
// executor.run 之前的 config 是 move 进 run 的,此处 mutate 后整体 move,零额外开销。 // executor.run 之前的 config 是 move 进 run 的,此处 mutate 后整体 move,零额外开销。
@@ -478,7 +478,7 @@ pub async fn get_workflow_execution(
/// 发送人工审批响应 /// 发送人工审批响应
/// ///
/// F-260615-01: 支持 single/multiple。 /// 支持 single/multiple。
/// - single: decision 单值,decisions 为空(向后兼容); /// - single: decision 单值,decisions 为空(向后兼容);
/// - multiple: decisions 数组(≥1 项),decision 留空。 /// - multiple: decisions 数组(≥1 项),decision 留空。
/// IPC 早校验规则与 HumanNode 下游兜底一致,避免「IPC 成功+工作流失败」割裂。 /// IPC 早校验规则与 HumanNode 下游兜底一致,避免「IPC 成功+工作流失败」割裂。
@@ -489,12 +489,12 @@ pub async fn approve_human_approval(
execution_id: ExecutionId, execution_id: ExecutionId,
node_id: String, node_id: String,
decision: String, decision: String,
// F-260615-01: 多选结果数组,缺省空数组(单选调用方不传) // 多选结果数组,缺省空数组(单选调用方不传)
decisions: Option<Vec<String>>, decisions: Option<Vec<String>>,
comment: Option<String>, comment: Option<String>,
// R-PD-5: options 由前端从收到的 HumanApprovalRequest 事件透传(IPC 无法访问节点 config)。 // options 由前端从收到的 HumanApprovalRequest 事件透传(IPC 无法访问节点 config)。
options: Vec<String>, options: Vec<String>,
// F-260615-01: 选择类型,缺省 single // 选择类型,缺省 single
select_type: Option<String>, select_type: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let _ = &app; // 原签名含 app 参数(未使用),保留避免 invoke_handler 注册签名变化 let _ = &app; // 原签名含 app 参数(未使用),保留避免 invoke_handler 注册签名变化
+7 -7
View File
@@ -70,13 +70,13 @@ pub fn run() {
// 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir // 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?; let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?;
// FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断) // 启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
if let Err(e) = tauri::async_runtime::block_on( if let Err(e) = tauri::async_runtime::block_on(
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers), commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
) { ) {
tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e); tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e);
} }
// B-260616-01: L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state // L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
let session_for_handshake = app_state.ai_session.clone(); let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state); app.manage(app_state);
@@ -100,7 +100,7 @@ pub fn run() {
tracing::error!("[tray] 托盘创建失败(非阻断): {}", e); tracing::error!("[tray] 托盘创建失败(非阻断): {}", e);
} }
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态 // L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次), // 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。 // 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
// 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。 // 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。
@@ -422,7 +422,7 @@ pub fn run() {
commands::ai::ai_chat_force_send, commands::ai::ai_chat_force_send,
commands::ai::ai_chat_stop, commands::ai::ai_chat_stop,
commands::ai::ai_approve, commands::ai::ai_approve,
// F-260616-03达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45 // 达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45
commands::ai::ai_continue_loop, commands::ai::ai_continue_loop,
commands::ai::ai_stop_loop, commands::ai::ai_stop_loop,
commands::ai::ai_pending_tool_calls, commands::ai::ai_pending_tool_calls,
@@ -435,12 +435,12 @@ pub fn run() {
commands::ai::ai_save_provider, commands::ai::ai_save_provider,
commands::ai::ai_set_provider, commands::ai::ai_set_provider,
commands::ai::ai_delete_provider, commands::ai::ai_delete_provider,
// F-260614-04c: 负载均衡池可编辑层(enabled/weight 轻量更新 + caps 重建) // c: 负载均衡池可编辑层(enabled/weight 轻量更新 + caps 重建)
commands::ai::ai_update_provider_pool, commands::ai::ai_update_provider_pool,
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测 // F-01 阶段5:测试连接拉取模型列表 + 单模型探测
commands::ai::ai_fetch_models, commands::ai::ai_fetch_models,
commands::ai::ai_probe_model, commands::ai::ai_probe_model,
// P0-1:用表单当前值一次性测试连接(不写库,新建/编辑未保存均可测) // 用表单当前值一次性测试连接(不写库,新建/编辑未保存均可测)
commands::ai::ai_test_connection, commands::ai::ai_test_connection,
// AI 对话管理 // AI 对话管理
commands::ai::ai_conversation_create, commands::ai::ai_conversation_create,
@@ -464,7 +464,7 @@ pub fn run() {
commands::ai::ai_set_allowed_dirs, commands::ai::ai_set_allowed_dirs,
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起) // F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
commands::ai::ai_authorize_dir, commands::ai::ai_authorize_dir,
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断) // 审批历史面板查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::record::list_tool_executions, commands::ai::audit::record::list_tool_executions,
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围) // AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
commands::ai::audit::record::tool_failure_stats, commands::ai::audit::record::tool_failure_stats,
+8 -8
View File
@@ -316,7 +316,7 @@ impl AppState {
// 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建, // 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建,
// 使重启后待审批不丢。前端经 ai_pending_tool_calls + switchConversation 恢复 toolCard 态。 // 使重启后待审批不丢。前端经 ai_pending_tool_calls + switchConversation 恢复 toolCard 态。
crate::commands::ai::restore_pending_approvals(&state).await; crate::commands::ai::restore_pending_approvals(&state).await;
// F-260614-04c: 启动一次性初始化 per-provider caps 表。 // c: 启动一次性初始化 per-provider caps 表。
// 根据 DB enabled providers 建 HashMap<provider_id, global_cap>,让 agentic loop 的 // 根据 DB enabled providers 建 HashMap<provider_id, global_cap>,让 agentic loop 的
// try_acquire_for_provider 从 NotConfigured(无限流)切换到 Acquired/Exhausted(按配置限流)。 // try_acquire_for_provider 从 NotConfigured(无限流)切换到 Acquired/Exhausted(按配置限流)。
// disabled / weight=0 的 provider 不入表(其被 provider_pool::select 过滤出候选,不会被 acquire)。 // disabled / weight=0 的 provider 不入表(其被 provider_pool::select 过滤出候选,不会被 acquire)。
@@ -352,10 +352,10 @@ impl AppState {
Ok(state) Ok(state)
} }
/// F-260614-04c: 从 DB enabled providers 重建 per_provider caps 并 set_provider_caps。 /// c: 从 DB enabled providers 重建 per_provider caps 并 set_provider_caps。
/// ///
/// 启动 + provider 变更(ai_update_provider_pool / 删除 / 新增)后调用。caps 策略见 /// 启动 + provider 变更(ai_update_provider_pool / 删除 / 新增)后调用。caps 策略见
/// AppState::init 注释(本轮每 provider cap = global_cap,F-04d 配差异化上限时仅改此)。 /// AppState::init 注释(本轮每 provider cap = global_cap,d 配差异化上限时仅改此)。
pub async fn reload_provider_caps(&self) { pub async fn reload_provider_caps(&self) {
let providers = match self.ai_providers.list_all().await { let providers = match self.ai_providers.list_all().await {
Ok(ps) => ps, Ok(ps) => ps,
@@ -425,7 +425,7 @@ impl AppState {
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败 /// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。 /// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
pub async fn reload_allowed_dirs(&self) { pub async fn reload_allowed_dirs(&self) {
// F-260619-03: 白名单 = Settings KV allowed_dirs(用户配)+ projects.bind_directory(项目绑定目录自动授权)。 // 白名单 = Settings KV allowed_dirs(用户配)+ projects.bind_directory(项目绑定目录自动授权)。
// 项目绑定目录 = AI 天然可访问(用户已主动绑定),不应重复弹窗。reload 时自动合并入 persistent。 // 项目绑定目录 = AI 天然可访问(用户已主动绑定),不应重复弹窗。reload 时自动合并入 persistent。
let kv_dirs: Vec<String> = match self.settings.get(AllowedDirs::SETTINGS_KEY).await { let kv_dirs: Vec<String> = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
Ok(Some(v)) => serde_json::from_str(&v).unwrap_or_default(), Ok(Some(v)) => serde_json::from_str(&v).unwrap_or_default(),
@@ -592,7 +592,7 @@ impl AppState {
/// 这里注册 df-nodes 提供的真实 HumanNode / AiNode。 /// 这里注册 df-nodes 提供的真实 HumanNode / AiNode。
/// ///
/// "script" 节点不注册:ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串, /// "script" 节点不注册:ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串,
/// 前端可构造任意 DagDef 触达无审批 shellR-PD-2。DevFlow 工作流当前为纯演示 /// 前端可构造任意 DagDef 触达无审批 shell。DevFlow 工作流当前为纯演示
/// 功能(前端唯一构造点 ProjectDetail.vue demoDag 三步 echo),无真实构建/部署脚本 /// 功能(前端唯一构造点 ProjectDetail.vue demoDag 三步 echo),无真实构建/部署脚本
/// 需求。需要脚本执行能力时新建独立 BuildNode(白名单 + 项目目录锚定 + 复用 AI 工具 /// 需求。需要脚本执行能力时新建独立 BuildNode(白名单 + 项目目录锚定 + 复用 AI 工具
/// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。 /// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。
@@ -621,7 +621,7 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
// 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。 // 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。
// 持有 Arc<Database> 在此构造时注入(NodeRegistry::register 工厂闭包 move 捕获 db, // 持有 Arc<Database> 在此构造时注入(NodeRegistry::register 工厂闭包 move 捕获 db,
// Arc clone 廉价),Node::execute 从 NodeContext.config 读 task_id/target_status。 // Arc clone 廉价),Node::execute 从 NodeContext.config 读 task_id/target_status。
// D-260616-03: 推进链/状态机/闸门走 df-nodes Node trait,非复活 df-task。 // 推进链/状态机/闸门走 df-nodes Node trait,非复活 df-task。
registry.register("task_advance", move |_config| { registry.register("task_advance", move |_config| {
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone())) Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
}); });
@@ -662,11 +662,11 @@ mod tests {
use super::*; use super::*;
// ============================================================ // ============================================================
// F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试 // strip_verbatim 比对侧收口 + 黑名单增强测试
// 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。 // 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。
// ============================================================ // ============================================================
/// F-260620: strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除 /// strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除
#[test] #[test]
fn test_strip_verbatim_three_prefixes() { 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"));
+6 -6
View File
@@ -168,7 +168,7 @@ pub fn check_path_authorization(
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
// //
// BUG-2026-07-07: 黑名单检查必须在路径规范化(is_absolute / root.join)之前对**原始路径** // 黑名单检查必须在路径规范化(is_absolute / root.join)之前对**原始路径**
// 也判一次。原因:Windows 上 "/home/user/.ssh/config" 这类 Unix 风格路径 is_absolute() 返 false // 也判一次。原因:Windows 上 "/home/user/.ssh/config" 这类 Unix 风格路径 is_absolute() 返 false
// (Windows 绝对路径需盘符或 UNC),会被当相对路径走 first_persistent_dir 分支;若 persistent 为空, // (Windows 绝对路径需盘符或 UNC),会被当相对路径走 first_persistent_dir 分支;若 persistent 为空,
// 旧逻辑直接 return NeedsAuthorization,**跳过黑名单检查** → .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。 // 旧逻辑直接 return NeedsAuthorization,**跳过黑名单检查** → .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。
@@ -443,7 +443,7 @@ mod tests {
} }
} }
/// BUG-2026-07-07: Windows 上 Unix 风格 .ssh 路径必须被黑名单拒(不能绕过)。 /// Windows 上 Unix 风格 .ssh 路径必须被黑名单拒(不能绕过)。
/// ///
/// 背景:Windows 上 `/home/user/.ssh/config` 的 is_absolute() 返 false(无盘符), /// 背景:Windows 上 `/home/user/.ssh/config` 的 is_absolute() 返 false(无盘符),
/// 旧逻辑在 persistent 为空时直接返 NeedsAuthorization,**跳过黑名单检查**, /// 旧逻辑在 persistent 为空时直接返 NeedsAuthorization,**跳过黑名单检查**,
@@ -476,11 +476,11 @@ mod tests {
// ============================================================ // ============================================================
// F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试 // strip_verbatim 比对侧收口 + 黑名单增强测试
// 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。 // 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。
// ============================================================ // ============================================================
/// F-260620: is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单 /// is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单
/// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。 /// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。
/// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。 /// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。
#[test] #[test]
@@ -497,7 +497,7 @@ mod tests {
assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单"); assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单");
} }
/// F-260620: strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除 /// strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除
#[test] #[test]
fn test_strip_verbatim_three_prefixes() { 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"));
@@ -507,7 +507,7 @@ mod tests {
assert_eq!(strip_verbatim(PathBuf::from("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) /// 黑名单增强 — Windows 根本身 / ProgramData / 用户凭据目录(.ssh/.aws/.gnupg)
#[test] #[test]
fn test_blacklist_windows_root_programdata_creds() { fn test_blacklist_windows_root_programdata_creds() {
// C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等) // C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等)
+3 -3
View File
@@ -32,7 +32,7 @@ use tokio::sync::{Mutex, Semaphore};
/// 新请求 lock 后克隆到最新 Arc、自动走新限制。旧 Semaphore 随最后 permit 释放而 drop。 /// 新请求 lock 后克隆到最新 Arc、自动走新限制。旧 Semaphore 随最后 permit 释放而 drop。
/// per_conv HashMap 内每条 Arc<Semaphore> 不需运行时改 permits(对话内并发上限 2 固定),故无重建需求。 /// per_conv HashMap 内每条 Arc<Semaphore> 不需运行时改 permits(对话内并发上限 2 固定),故无重建需求。
/// ///
/// ## F-260614-04: per-provider 层(可选) /// ## per-provider 层(可选)
/// `per_provider` 为 HashMap<provider_id, Arc<Semaphore>>。调用方(agentic loop)经 /// `per_provider` 为 HashMap<provider_id, Arc<Semaphore>>。调用方(agentic loop)经
/// `try_acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。 /// `try_acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。
/// **单 provider 场景**:若未调 `set_provider_caps`,HashMap 为空, /// **单 provider 场景**:若未调 `set_provider_caps`,HashMap 为空,
@@ -75,7 +75,7 @@ pub struct LlmConcurrency {
/// 热改后**已建对话**的旧 Semaphore 不变(permits 构造时固定),**新建对话**用新值; /// 热改后**已建对话**的旧 Semaphore 不变(permits 构造时固定),**新建对话**用新值;
/// 为使热改立即全量生效,set_per_conv 同时清空 HashMap 强制重建(软收敛:旧 permit 随 Drop 释放)。 /// 为使热改立即全量生效,set_per_conv 同时清空 HashMap 强制重建(软收敛:旧 permit 随 Drop 释放)。
per_conv_permits: Arc<AtomicUsize>, per_conv_permits: Arc<AtomicUsize>,
/// F-260614-04: per-provider 信号量表。空 = 无 per-provider 限流(单 provider 路径零变化)。 /// per-provider 信号量表。空 = 无 per-provider 限流(单 provider 路径零变化)。
/// Arc<Mutex<HashMap>>:运行时增删 provider 配置时替换/插入,acquire 时 clone Arc。 /// Arc<Mutex<HashMap>>:运行时增删 provider 配置时替换/插入,acquire 时 clone Arc。
per_provider: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>, per_provider: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
/// Phase3 预留(批2-B): per-sub_flow 信号量表。key = sub_flow_id(约定 "{conv_id}::{sub_id}")。 /// Phase3 预留(批2-B): per-sub_flow 信号量表。key = sub_flow_id(约定 "{conv_id}::{sub_id}")。
@@ -306,7 +306,7 @@ impl LlmConcurrency {
} }
} }
/// F-260614-04: 批量设置 per-provider 并发上限(替换整表)。 /// 批量设置 per-provider 并发上限(替换整表)。
/// ///
/// 调用方(Settings 配置热改 / 启动初始化)传入 `{ provider_id: permits }` map, /// 调用方(Settings 配置热改 / 启动初始化)传入 `{ provider_id: permits }` map,
/// 替换整张 per_provider 表(软收敛:已持 permit 不回收)。全局容量约束 min(sum, global_cap) /// 替换整张 per_provider 表(软收敛:已持 permit 不回收)。全局容量约束 min(sum, global_cap)
+4 -4
View File
@@ -101,7 +101,7 @@ watch(() => projectStore.error, (err) => {
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500) errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
}) })
// (,P0-C: HumanNode ,) // (, HumanNode ,)
// ProjectDetail TaskDetail ApprovalDialog store.pendingApproval // ProjectDetail TaskDetail ApprovalDialog store.pendingApproval
// TaskDetail HumanNode App.vue : // TaskDetail HumanNode App.vue :
// ( useApprovalPopup ;,) // ( useApprovalPopup ;,)
@@ -174,7 +174,7 @@ onMounted(async () => {
await migrateLegacyLocalStorage() await migrateLegacyLocalStorage()
// 3. ,/locale // 3. ,/locale
initTheme() initTheme()
// F-260618-23: onMounted Agentic /(AtomicUsize ,) // onMounted Agentic /(AtomicUsize ,)
// GeneralPanel.vue onMounted Settings , AI Chat 10 // GeneralPanel.vue onMounted Settings , AI Chat 10
// clamp GeneralPanel (0=,1-50 clamp;,IPC ) // clamp GeneralPanel (0=,1-50 clamp;,IPC )
// try/catch IPC reject onMounted( dataChangedListener ) // try/catch IPC reject onMounted( dataChangedListener )
@@ -201,8 +201,8 @@ onMounted(async () => {
window.addEventListener('keydown', handleKeydown) window.addEventListener('keydown', handleKeydown)
// ( knowledge badge ) // ( knowledge badge )
knowledgeStore.loadCandidates() knowledgeStore.loadCandidates()
// AR-11:(AI create/update/delete emit df-data-changed store listen ,) // (AI create/update/delete emit df-data-changed store listen ,)
// CR-260615-21: try/catch Tauri listen reject onMounted AR-11 // try/catch Tauri listen reject onMounted AR-11
try { try {
await projectStore.startDataChangedListener() await projectStore.startDataChangedListener()
} catch (e) { } catch (e) {
+5 -5
View File
@@ -43,7 +43,7 @@ export const aiApi = {
}, },
/** /**
* AI (UX-02) * AI
* 后端:占用 generating AI ( user ) agentic loop * 后端:占用 generating AI ( user ) agentic loop
* 前端消费: sendMessage push + streaming( useAiSend.regenerate) * 前端消费: sendMessage push + streaming( useAiSend.regenerate)
*/ */
@@ -56,7 +56,7 @@ export const aiApi = {
}, },
/** /**
* user (UX-09) * user
* 后端:占用 generating user content truncated() agentic loop * 后端:占用 generating user content truncated() agentic loop
* 前端消费: editMessage ()+ push + streaming * 前端消费: editMessage ()+ push + streaming
* user () * user ()
@@ -205,7 +205,7 @@ export const aiApi = {
}, },
/** /**
* (P0-1):****, * ****,
* - 新建态:传表单填的 providerType/baseUrl/apiKey,providerId=null * - 新建态:传表单填的 providerType/baseUrl/apiKey,providerId=null
* - (apiKey =): providerId DB 沿; * - (apiKey =): providerId DB 沿;
* apiKey * apiKey
@@ -329,13 +329,13 @@ export const aiApi = {
return invoke('ai_conversation_archive', { conversationId, archived }) return invoke('ai_conversation_archive', { conversationId, archived })
}, },
/** 置顶/取消置顶对话(UX-17) */ /** 置顶/取消置顶对话*/
setPinnedConversation(conversationId: string, pinned: boolean): Promise<void> { setPinnedConversation(conversationId: string, pinned: boolean): Promise<void> {
return invoke('ai_conversation_set_pinned', { conversationId, pinned }) return invoke('ai_conversation_set_pinned', { conversationId, pinned })
}, },
/** /**
/** (UX-18:; batch46 ai_conversation_export IPC) /** ; batch46 ai_conversation_export IPC)
* String(markdown/json/txt), Blob * String(markdown/json/txt), Blob
* @param format 'markdown' | 'json' | 'txt' * @param format 'markdown' | 'json' | 'txt'
*/ */
+1 -1
View File
@@ -5,7 +5,7 @@ export const ideaApi = {
/** /**
* *
* *
* ****(F-260621-02): * ****
* - `list(status?)`:,( `list({ status })`) * - `list(status?)`:,( `list({ status })`)
* - `list(query?)`:,(status/keyword/order_by/limit/offset) WHERE * - `list(query?)`:,(status/keyword/order_by/limit/offset) WHERE
* *
+1 -1
View File
@@ -65,7 +65,7 @@ export interface ProjectQuery {
export const projectApi = { export const projectApi = {
/** /**
* *
* F-260621-02:可选 query(//) = () * query(//) = ()
*/ */
list(query?: ProjectQuery): Promise<ProjectRecord[]> { list(query?: ProjectQuery): Promise<ProjectRecord[]> {
return invoke('list_projects', { query }) return invoke('list_projects', { query })
+1 -1
View File
@@ -5,7 +5,7 @@ export const taskApi = {
/** /**
* *
* *
* ****(F-260621-02): * ****
* - `list(projectId?)`:,( `list({ project_id: projectId })`) * - `list(projectId?)`:,( `list({ project_id: projectId })`)
* - `list(query?)`:,(status/keyword/project_id/priority/assignee/...) WHERE * - `list(query?)`:,(status/keyword/project_id/priority/assignee/...) WHERE
* *
+12 -12
View File
@@ -328,15 +328,15 @@ export interface AiProviderConfig {
base_url: string base_url: string
default_model: string default_model: string
models: string | null models: string | null
/** 模型能力配置数组(F-01:4 维度 + 路由控制;DB TEXT 列 JSON 解析,老库可能缺失) */ /** 模型能力配置数组4 维度 + 路由控制;DB TEXT 列 JSON 解析,老库可能缺失) */
model_configs?: ModelConfig[] model_configs?: ModelConfig[]
is_default: boolean is_default: boolean
config: string | null config: string | null
created_at: string created_at: string
updated_at: string updated_at: string
/** provider 是否进入负载均衡池(F-260614-04c)。false=仅配置存在,不参与主链路由/选池。老库默认 true。 */ /** provider 是否进入负载均衡池(c)。false=仅配置存在,不参与主链路由/选池。老库默认 true。 */
enabled?: boolean enabled?: boolean
/** provider 在负载均衡池中的选择权重(0-100,F-260614-04c)。高权重优先被选为主;同权重近似轮询。老库默认 50。 */ /** provider 在负载均衡池中的选择权重(0-100,c)。高权重优先被选为主;同权重近似轮询。老库默认 50。 */
weight?: number weight?: number
} }
@@ -353,7 +353,7 @@ export type Capability = 'tool_use' | 'embedding' | 'code_gen'
/** 维度 3 — 价格分级(成本)。serde snake_case 字面量。'free' 已删(B-260618-05 死档,预设/启发式从不赋值)。 */ /** 维度 3 — 价格分级(成本)。serde snake_case 字面量。'free' 已删(B-260618-05 死档,预设/启发式从不赋值)。 */
export type CostTier = 'low' | 'medium' | 'high' export type CostTier = 'low' | 'medium' | 'high'
/** 维度 4 — 智力分级。serde snake_case 字面量。CR-260618-11#3:与 CostTier 同,cost/intel 已从路由硬过滤(B-260618-03)及前端标签(UX-260618-04)去除,字段保留仅 DB 兼容,不再参与路由选模型。 */ /** 维度 4 — 智力分级。serde snake_case 字面量。CR-260618-11#3:与 CostTier 同,cost/intel 已从路由硬过滤及前端标签去除,字段保留仅 DB 兼容,不再参与路由选模型。 */
export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra' export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra'
/** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */ /** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */
@@ -384,7 +384,7 @@ export interface ModelConfig {
} }
/** /**
* AI (B-260615-42) * AI
* `commands::ai::ErrorType` serde(rename_all = "snake_case") * `commands::ai::ErrorType` serde(rename_all = "snake_case")
* switch (auth key / network / ) * switch (auth key / network / )
*/ */
@@ -458,7 +458,7 @@ export type AiChatEvent = ({
} | { } | {
type: 'AiHeartbeat' type: 'AiHeartbeat'
} | { } | {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止 // 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
type: 'AiMaxRoundsReached' type: 'AiMaxRoundsReached'
} | { } | {
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。 // F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
@@ -480,7 +480,7 @@ export type AiChatEvent = ({
// enum 写入口(替代旧 bool 轨 add),消费方不再回退 streaming/generating bool 判断。 // enum 写入口(替代旧 bool 轨 add),消费方不再回退 streaming/generating bool 判断。
type: 'AiConvStateChanged'; conv_state: ConvState type: 'AiConvStateChanged'; conv_state: ConvState
} | { } | {
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」) // 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
type: 'AiStreamRetry'; attempt: number; max_attempts: number type: 'AiStreamRetry'; attempt: number; max_attempts: number
} | { } | {
// F-15 上下文管理生命周期事件(原 useAiContext 独立监听器,现合入统一分发器) // F-15 上下文管理生命周期事件(原 useAiContext 独立监听器,现合入统一分发器)
@@ -674,7 +674,7 @@ export interface AiToolCallInfo {
result?: unknown result?: unknown
/** 审批风险说明(AiApprovalRequired 时填充) */ /** 审批风险说明(AiApprovalRequired 时填充) */
reason?: string reason?: string
/** AE-2025-03write_file unified diff vs /** write_file unified diff vs
* write_file undefined退 content */ * write_file undefined退 content */
diff?: string diff?: string
/** /**
@@ -729,7 +729,7 @@ export interface GoalEntry {
status: 'active' | 'completed' status: 'active' | 'completed'
} }
/** 审批选择类型(F-260615-01: single=单选默认,multiple=多选) */ /** 审批选择类型single=单选默认,multiple=多选) */
export type ApprovalSelectType = 'single' | 'multiple' export type ApprovalSelectType = 'single' | 'multiple'
/** 人工审批请求 */ /** 人工审批请求 */
@@ -739,7 +739,7 @@ export interface HumanApprovalRequest {
title: string title: string
description: string description: string
options: string[] options: string[]
/** F-260615-01: 缺省 single(向后兼容),multiple 时允许多选 */ /** 缺省 single(向后兼容),multiple 时允许多选 */
select_type?: ApprovalSelectType select_type?: ApprovalSelectType
} }
@@ -748,7 +748,7 @@ export interface HumanApprovalResponse {
execution_id: string execution_id: string
node_id: string node_id: string
decision: string decision: string
/** F-260615-01: 多选结果(single 时长度=1,multiple 时长度≥1) */ /** 多选结果(single 时长度=1,multiple 时长度≥1) */
decisions?: string[] decisions?: string[]
comment?: string comment?: string
} }
@@ -758,7 +758,7 @@ export interface AiConversationDetail {
id: ConvId id: ConvId
title: string | null title: string | null
messages: string // JSON string of ChatMessage[] messages: string // JSON string of ChatMessage[]
/** 生成中返回 true(后端 ai_conversation_switch 在 generating 时置),前端据此禁编辑(FR-C5) */ /** 生成中返回 true(后端 ai_conversation_switch 在 generating 时置),前端据此禁编辑*/
readonly?: boolean readonly?: boolean
} }
+3 -3
View File
@@ -7,7 +7,7 @@ export const workflowApi = {
* execution_id * execution_id
* *
* F-260616-06 2(-2): taskId/targetStatus ,/ * F-260616-06 2(-2): taskId/targetStatus ,/
* B-260615-34:Tauri 2 IPC camelCase,snake_case workflow.rs * Tauri 2 IPC camelCase,snake_case workflow.rs
*/ */
run( run(
name: string, name: string,
@@ -46,8 +46,8 @@ export const workflowApi = {
* decisions , select_type /: * decisions , select_type /:
* - 单选:decision decisions[0]; * - 单选:decision decisions[0];
* - 多选:decisions ,decision ( decisions ) * - 多选:decisions ,decision ( decisions )
* options (R-PD-5) * options
* B-260615-34:Tauri 2 IPC camelCase,snake_case workflow.rs * Tauri 2 IPC camelCase,snake_case workflow.rs
*/ */
approveHumanApproval(params: { approveHumanApproval(params: {
execution_id: string execution_id: string
+21 -21
View File
@@ -162,7 +162,7 @@ const router = useRouter()
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true) const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
// UX-2025-20: MessageList (examplePrompts ); // MessageList (examplePrompts );
// sendExamplePrompt ( emit 'send-example-prompt' ChatInput.sendExamplePrompt) // sendExamplePrompt ( emit 'send-example-prompt' ChatInput.sendExamplePrompt)
/** 跳 Settings 配置 provider:嵌入模式 router.push;分离窗口无 Settings 路由,降级 toast 提示 */ /** 跳 Settings 配置 provider:嵌入模式 router.push;分离窗口无 Settings 路由,降级 toast 提示 */
@@ -184,9 +184,9 @@ function sendExamplePrompt(i18nKey: string) {
// i18n ,, aiChat.toastSendFail key // i18n ,, aiChat.toastSendFail key
const { toast, showToast } = useToast() const { toast, showToast } = useToast()
// B-260616-12: unlistener(onMounted ,onBeforeUnmount ) // unlistener(onMounted ,onBeforeUnmount )
let _unlistenToolSlow: (() => void) | null = null let _unlistenToolSlow: (() => void) | null = null
// AE-2025-04: toast unlistener(useAiEvents 线, _unlistenToolSlow ) // toast unlistener(useAiEvents 线, _unlistenToolSlow )
let _unlistenAutoApproved: (() => void) | null = null let _unlistenAutoApproved: (() => void) | null = null
// P1-e-d: toast unlistener(useAiApproval IPC 线 emit, _unlistenToolSlow ) // P1-e-d: toast unlistener(useAiApproval IPC 线 emit, _unlistenToolSlow )
let _unlistenAiToast: (() => void) | null = null let _unlistenAiToast: (() => void) | null = null
@@ -206,10 +206,10 @@ function onChatInputError(payload: { msg: string; type: 'error' | 'warning' | 'i
// : MessageList (expose scrollToBottom/scrollToFirstPending) // : MessageList (expose scrollToBottom/scrollToFirstPending)
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null) const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
// AE-2025-02: (>0 ) // (>0 )
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length) const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
// AE-2025-07: Agentic // Agentic
// agentic (streaming agentRound>0); AiAgentRound // agentic (streaming agentRound>0); AiAgentRound
const showAgenticProgress = computed(() => const showAgenticProgress = computed(() =>
store.state.streaming && store.state.agentRound > 0, store.state.streaming && store.state.agentRound > 0,
@@ -236,7 +236,7 @@ async function confirmDeleteConversation(id: string) {
const conv = store.state.conversations.find(c => c.id === id) const conv = store.state.conversations.find(c => c.id === id)
if (!conv) console.warn('confirmDeleteConversation: conv not found, id=', id) if (!conv) console.warn('confirmDeleteConversation: conv not found, id=', id)
const title = conv?.title || t('aiChat.newConversation') const title = conv?.title || t('aiChat.newConversation')
// UX-260617-24:(,) // (,)
const isActive = id === store.state.activeConversationId const isActive = id === store.state.activeConversationId
const msgKey = isActive ? 'aiChat.confirmDeleteActiveConversation' : 'aiChat.confirmDeleteConversation' const msgKey = isActive ? 'aiChat.confirmDeleteActiveConversation' : 'aiChat.confirmDeleteConversation'
if (!await confirmDialog(t(msgKey, { title }))) return if (!await confirmDialog(t(msgKey, { title }))) return
@@ -270,13 +270,13 @@ async function confirmNewConversation() {
// projectsStore (onMounted projects/tasks @ ) // projectsStore (onMounted projects/tasks @ )
const projectsStore = useProjectStore() const projectsStore = useProjectStore()
// UX-09:/( user,) // /( user,)
// 2026-06-18 :/ specify model(: // 2026-06-18 :/ specify model(:
// /)enabledModels weight ,[0]=() null=auto // /)enabledModels weight ,[0]=() null=auto
// run_agentic_loop override , // run_agentic_loop override ,
// : model (modelOverride=enabledModels[0]) enabledModels // : model (modelOverride=enabledModels[0]) enabledModels
// TopBar.vue activeConversationId watch; // TopBar.vue activeConversationId watch;
// 沿/(SW-260618-18) MessageList , // 沿/ MessageList ,
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit() if (editingMsgId.value) cancelEdit()
}) })
@@ -291,13 +291,13 @@ const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null
// cycleProvider/providerBarVisible/showProviderSwitched ai/TopBar.vue // cycleProvider/providerBarVisible/showProviderSwitched ai/TopBar.vue
// ( + provider/model ,,store modelOverride) // ( + provider/model ,,store modelOverride)
/** UX-09:进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */ /** 进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */
function startEdit(msg: AiMessage): void { function startEdit(msg: AiMessage): void {
editingMsgId.value = msg.id editingMsgId.value = msg.id
chatInputRef.value?.startEditFocus(msg) chatInputRef.value?.startEditFocus(msg)
} }
/** UX-09:取消编辑态(切换对话/清空/ESC)。清 editingMsgId + 委托 ChatInput.clearInput 清输入。 */ /** 取消编辑态(切换对话/清空/ESC)。清 editingMsgId + 委托 ChatInput.clearInput 清输入。 */
function cancelEdit(): void { function cancelEdit(): void {
editingMsgId.value = null editingMsgId.value = null
chatInputRef.value?.clearInput() chatInputRef.value?.clearInput()
@@ -366,7 +366,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
} }
/** /**
* UX-260617-16:未发送输入保护检测 inputText/pendingImages/pendingSkill 任一非空, * 未发送输入保护检测 inputText/pendingImages/pendingSkill 任一非空,
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换) * 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)
*/ */
function hasUnsentInput(): boolean { function hasUnsentInput(): boolean {
@@ -452,15 +452,15 @@ function scrollToBottom(smooth = false) {
messageListRef.value?.scrollToBottom(smooth) messageListRef.value?.scrollToBottom(smooth)
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
// (UX-2025-07): window listener,/ // window listener,/
window.removeEventListener('keydown', onGlobalKeydown) window.removeEventListener('keydown', onGlobalKeydown)
// UX-260617-16:(/ beforeunload) // (/ beforeunload)
window.removeEventListener('beforeunload', onBeforeUnloadGuard) window.removeEventListener('beforeunload', onBeforeUnloadGuard)
// :/ listener/ ConversationSidebar // :/ listener/ ConversationSidebar
// onBeforeUnmount (), // onBeforeUnmount (),
// rAF (streaming watch cancelAnimationFrame) MessageList , // rAF (streaming watch cancelAnimationFrame) MessageList ,
// onBeforeUnmount ( watch streaming ,) // onBeforeUnmount ( watch streaming ,)
// CR-260615-24: listener(ai-chat-event/ai-conversation-changed)+ // listener(ai-chat-event/ai-conversation-changed)+
// stopListener listener+watchdog ; AiChat // stopListener listener+watchdog ; AiChat
// ( App.vue v-if detach) listener ,watchdog 130s state // ( App.vue v-if detach) listener ,watchdog 130s state
store.stopListener() store.stopListener()
@@ -473,9 +473,9 @@ onBeforeUnmount(() => {
}) })
onMounted(async () => { onMounted(async () => {
// (UX-2025-07):window listener,( _unlistenToolSlow ) // window listener,( _unlistenToolSlow )
window.addEventListener('keydown', onGlobalKeydown) window.addEventListener('keydown', onGlobalKeydown)
// UX-260617-16:AiChat (App.vue + AiDetached ), // AiChat (App.vue + AiDetached ),
// beforeRouteLeave / inputText/pendingImages/ // beforeRouteLeave / inputText/pendingImages/
// pendingSkill, beforeunload :e.preventDefault() // pendingSkill, beforeunload :e.preventDefault()
// ? returnValue Chrome // ? returnValue Chrome
@@ -496,13 +496,13 @@ onMounted(async () => {
// (), ( await, ) // (), ( await, )
if (!projectsStore.projects.length) void projectsStore.loadProjects() if (!projectsStore.projects.length) void projectsStore.loadProjects()
if (!projectsStore.tasks.length) void projectsStore.loadTasks() if (!projectsStore.tasks.length) void projectsStore.loadTasks()
// B-260616-12: (useAiEvents Tauri 线广;composable , // (useAiEvents Tauri 线广;composable ,
// toast AiChat,,emit , // toast AiChat,,emit ,
// listener msg ,toast ) // listener msg ,toast )
_unlistenToolSlow = await listen<{ name: string }>('ai-tool-slow-toast', (e) => { _unlistenToolSlow = await listen<{ name: string }>('ai-tool-slow-toast', (e) => {
showToast(t('aiTool.executionSlow', { name: e.payload.name }), 'warning') showToast(t('aiTool.executionSlow', { name: e.payload.name }), 'warning')
}) })
// AE-2025-04: toast( info,,) // toast( info,,)
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => { _unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info') showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
}) })
@@ -516,7 +516,7 @@ onMounted(async () => {
// (/): // (/):
// - (detached=true):resumeInDetached // - (detached=true):resumeInDetached
// - (detached=false):restoreGeneratingState // - (detached=false):restoreGeneratingState
// (UX-260617-13: streaming=false + + // streaming=false + +
// ,退 resumeInDetached ) // ,退 resumeInDetached )
// (detached ),;restoreGeneratingState (state.streaming true ) // (detached ),;restoreGeneratingState (state.streaming true )
// startListener(L0 ai-client-ready ), // startListener(L0 ai-client-ready ),
@@ -691,7 +691,7 @@ async function initContextEventListeners(): Promise<void> {
} }
/* 第五批抽离: F-260616-03 max_iterations 操作卡样式已迁移至 ai/MaxRoundsCard.vue */ /* 第五批抽离: F-260616-03 max_iterations 操作卡样式已迁移至 ai/MaxRoundsCard.vue */
/* ── AE-2025-07: Agentic 循环进度条 ── */ /* ── Agentic 循环进度条 ── */
.ai-agentic-progress { .ai-agentic-progress {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -803,7 +803,7 @@ async function initContextEventListeners(): Promise<void> {
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); } .ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); } .ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); } .ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
/* P0-2: ToastType 新增 'success' 兜底样式 */ /* ToastType 新增 'success' 兜底样式 */
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); } .ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; } .ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); } .ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
+9 -9
View File
@@ -213,7 +213,7 @@ const isFailed = computed(() => {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0 if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true if (r) return true
} }
// A1-B5:git {success:false,error}( Err), // git {success:false,error}( Err),
if (r && r.success === false) return true if (r && r.success === false) return true
if (!r) { if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : '' const raw = typeof tc.result === 'string' ? tc.result : ''
@@ -223,7 +223,7 @@ const isFailed = computed(() => {
return false return false
}) })
/** AE-2025-03: write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */ /** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
const diffLines = computed(() => parseDiffLines(props.tc.diff)) const diffLines = computed(() => parseDiffLines(props.tc.diff))
</script> </script>
@@ -236,16 +236,16 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
overflow: hidden; overflow: hidden;
max-width: var(--df-msg-max-width); max-width: var(--df-msg-max-width);
animation: fadeInUp 0.2s var(--df-ease); animation: fadeInUp 0.2s var(--df-ease);
/* UX-260617-14:相对定位锚定本卡 toast(absolute 子元素) */ /* 相对定位锚定本卡 toast(absolute 子元素) */
position: relative; position: relative;
/* UX-260616-03: border/shadow/opacity / /* border/shadow/opacity /
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变, ToolCardList 注释) */ 不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变, ToolCardList 注释) */
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease); transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
} }
.ai-tool-card--pending_approval { border-color: rgba(240,199,94,0.3); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); } .ai-tool-card--pending_approval { border-color: rgba(240,199,94,0.3); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
.ai-tool-card--completed { border-color: rgba(61,219,160,0.2); } .ai-tool-card--completed { border-color: rgba(61,219,160,0.2); }
.ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; } .ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; }
/* UX-260616-01: 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */ /* 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */
.ai-tool-card--failed { border-color: rgba(240,101,101,0.35); box-shadow: 0 0 0 1px rgba(240,101,101,0.06); } .ai-tool-card--failed { border-color: rgba(240,101,101,0.35); box-shadow: 0 0 0 1px rgba(240,101,101,0.06); }
.ai-tool-status-dot--failed { background: var(--df-danger); } .ai-tool-status-dot--failed { background: var(--df-danger); }
.ai-tool-sub--failed { color: var(--df-danger); } .ai-tool-sub--failed { color: var(--df-danger); }
@@ -326,7 +326,7 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm, 4px);
} }
/* BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) */ /* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
.ai-tool-wait-time { .ai-tool-wait-time {
padding: 3px 10px; margin-bottom: 6px; font-size: 11px; padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
@@ -342,7 +342,7 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
animation: pulseGlow 1s ease-in-out infinite; animation: pulseGlow 1s ease-in-out infinite;
} }
/* AE-2025-03: write_file 审批 diff 预览区(红删绿增行级) */ /* 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-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 { .ai-tool-diff-pre {
margin: 0; padding: 6px 0; max-height: 240px; overflow: auto; margin: 0; padding: 6px 0; max-height: 240px; overflow: auto;
@@ -375,7 +375,7 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); } .ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); } .ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); } .ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
/* 审批 loading(B-260616-08):禁用两按钮防重复点击,spinner 转在原位 */ /* 审批 loading禁用两按钮防重复点击,spinner 转在原位 */
.ai-tool-btn:disabled { opacity: 0.6; cursor: not-allowed; } .ai-tool-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.ai-tool-btn--approve:disabled:hover { background: var(--df-success-bg); color: var(--df-success); border-color: var(--df-border); } .ai-tool-btn--approve:disabled:hover { background: var(--df-success-bg); color: var(--df-success); border-color: var(--df-border); }
.ai-tool-btn--reject:disabled:hover { background: var(--df-danger-bg); color: var(--df-danger); border-color: var(--df-border); } .ai-tool-btn--reject:disabled:hover { background: var(--df-danger-bg); color: var(--df-danger); border-color: var(--df-border); }
@@ -385,7 +385,7 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
} }
@keyframes aiToolBtnSpin { to { transform: rotate(360deg); } } @keyframes aiToolBtnSpin { to { transform: rotate(360deg); } }
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */ /* -- 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast { .ai-tool-toast {
position: absolute; top: 6px; left: 50%; transform: translateX(-50%); position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100; padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100;
+14 -14
View File
@@ -81,12 +81,12 @@ const emit = defineEmits<{
const rootEl = ref<HTMLElement>() const rootEl = ref<HTMLElement>()
// body read_file // body read_file
const expandedCards = ref(new Set<string>()) const expandedCards = ref(new Set<string>())
// UX-260616-03:(,) // (,)
// expandedCards :expandedCards ""(), // expandedCards :expandedCards ""(),
// userExpandedCards "",collapseInactive // userExpandedCards "",collapseInactive
// :,"" // :,""
const userExpandedCards = ref(new Set<string>()) const userExpandedCards = ref(new Set<string>())
// UX-260617-23:( ci===0 return true ) // ( ci===0 return true )
// isCardExpanded , + userExpandedCards // isCardExpanded , + userExpandedCards
const userCollapsedCards = ref(new Set<string>()) const userCollapsedCards = ref(new Set<string>())
// read_file "" // read_file ""
@@ -125,7 +125,7 @@ const collapsibleGroupCount = computed(() =>
groupedToolCalls.value.filter(g => g.calls.length >= 2).length groupedToolCalls.value.filter(g => g.calls.length >= 2).length
) )
/** 首个可折叠分组索引(U-260618:全部展开/收起按钮下沉到此分组标题行尾巴) */ /** 首个可折叠分组索引全部展开/收起按钮下沉到此分组标题行尾巴) */
const firstCollapsibleGi = computed(() => { const firstCollapsibleGi = computed(() => {
for (let i = 0; i < groupedToolCalls.value.length; i++) { for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) return i if (groupedToolCalls.value[i].calls.length >= 2) return i
@@ -196,7 +196,7 @@ function toggleAllGroups(): void {
/** /**
* 分组显示名工具名 i18n 友好标签(英文化,原硬编码中文) * 分组显示名工具名 i18n 友好标签(英文化,原硬编码中文)
* UX-260617-17: aiTool.toolGroup.* 子键查表;未命中的工具回退 _ 空格风格 * aiTool.toolGroup.* 子键查表;未命中的工具回退 _ 空格风格
*/ */
// tool.name aiTool.toolGroup.* ( tool_registry ) // tool.name aiTool.toolGroup.* ( tool_registry )
const TOOL_GROUP_I18N_KEY: Record<string, string> = { const TOOL_GROUP_I18N_KEY: Record<string, string> = {
@@ -248,14 +248,14 @@ function groupFileSummary(group: ToolCallGroup): string {
* - 分组未折叠 用户手动 expandedCards 或首卡默认展开 * - 分组未折叠 用户手动 expandedCards 或首卡默认展开
* - 分组已折叠 false整组隐藏 * - 分组已折叠 false整组隐藏
* - 多卡片分组 + 未手动操作过 仅首卡(ci===0)展开 * - 多卡片分组 + 未手动操作过 仅首卡(ci===0)展开
* - UX-260616-03:userExpandedCards(用户主动展开记忆)优先,即使父级 collapseInactive * - userExpandedCards(用户主动展开记忆)优先,即使父级 collapseInactive
* 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠) * 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠)
*/ */
function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean { function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
if (isGroupCollapsed(gi)) return false if (isGroupCollapsed(gi)) return false
// UX-260617-23:( ci===0 + ) // ( ci===0 + )
if (userCollapsedCards.value.has(tc.id)) return false if (userCollapsedCards.value.has(tc.id)) return false
// UX-260616-03:() // ()
if (userExpandedCards.value.has(tc.id)) return true if (userExpandedCards.value.has(tc.id)) return true
// () // ()
if (expandedCards.value.has(tc.id)) return true if (expandedCards.value.has(tc.id)) return true
@@ -290,19 +290,19 @@ function toggleExpand(id: string) {
/** /**
* 收起不在 activeIds 中的已完成/已拒绝卡片供父组件 auto-collapse watch 调用 * 收起不在 activeIds 中的已完成/已拒绝卡片供父组件 auto-collapse watch 调用
* UX-260616-03:userExpandedCards(用户主动展开记忆)不被清除用户读过的卡 * userExpandedCards(用户主动展开记忆)不被清除用户读过的卡
* 即使不再 active(running/pending/write_file)也保持展开,避免"我刚才在看,突然被收起" * 即使不再 active(running/pending/write_file)也保持展开,避免"我刚才在看,突然被收起"
* 仅清 expandedCards(默认态/首卡) expandedTools(内容级展开,跟随卡片折叠) * 仅清 expandedCards(默认态/首卡) expandedTools(内容级展开,跟随卡片折叠)
*/ */
function collapseInactive(activeIds: Set<string>): void { function collapseInactive(activeIds: Set<string>): void {
// CR-260615-31: expandedTools( expandedCards,expandedTools ) // expandedTools( expandedCards,expandedTools )
if (expandedCards.value.size > 0) { if (expandedCards.value.size > 0) {
expandedCards.value = new Set([...expandedCards.value].filter(id => activeIds.has(id))) expandedCards.value = new Set([...expandedCards.value].filter(id => activeIds.has(id)))
} }
if (expandedTools.value.size > 0) { if (expandedTools.value.size > 0) {
expandedTools.value = new Set([...expandedTools.value].filter(id => activeIds.has(id))) expandedTools.value = new Set([...expandedTools.value].filter(id => activeIds.has(id)))
} }
// UX-260616-03: userExpandedCards(),isCardExpanded // userExpandedCards(),isCardExpanded
} }
/** /**
@@ -377,7 +377,7 @@ function groupIcon(name: string): string {
gap: 8px; gap: 8px;
} }
/* -- 顶部操作栏:批量审批 + 全局收起合并同行(UX-260616-02) -- */ /* -- 顶部操作栏:批量审批 + 全局收起合并同行-- */
.ai-tool-topbar { .ai-tool-topbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -419,7 +419,7 @@ function groupIcon(name: string): string {
} }
.ai-btn--danger:hover { filter: brightness(1.1); } .ai-btn--danger:hover { filter: brightness(1.1); }
/* -- /(U-260618: topbar , padding/, /* -- / topbar , padding/,
高度=文字行高,不撑高分组标题行;融入标题视觉, hover 变色) -- */ 高度=文字行高,不撑高分组标题行;融入标题视觉, hover 变色) -- */
.ai-tool-global-toggle { .ai-tool-global-toggle {
display: flex; display: flex;
@@ -447,7 +447,7 @@ function groupIcon(name: string): string {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 10px; padding: 6px 10px;
/* U-260618: max-width 对齐气泡/卡片(90%),不拉满 ai-msg-content 右侧 */ /* max-width 对齐气泡/卡片(90%),不拉满 ai-msg-content 右侧 */
max-width: var(--df-msg-max-width); max-width: var(--df-msg-max-width);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -483,7 +483,7 @@ function groupIcon(name: string): string {
color: var(--df-accent); color: var(--df-accent);
} }
.ai-tool-group-chevron { .ai-tool-group-chevron {
/* U-260618: 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */ /* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease); transition: transform 0.15s var(--df-ease);
+5 -5
View File
@@ -277,7 +277,7 @@ const isFailed = computed(() => {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0 if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true if (r) return true
} }
// A1-B5: JSON success===false(git status/diff/log // JSON success===false(git status/diff/log
// {success:false,error} JSON Err) ,绿 // {success:false,error} JSON Err) ,绿
if (r && r.success === false) return true if (r && r.success === false) return true
if (!r) { if (!r) {
@@ -296,13 +296,13 @@ const cmdOutput = computed(() => {
}) })
/** /**
* UX-260618-06: patch_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色) * patch_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)
* 数据源:parsed.diff(patch_file 工具执行结果字段) diff 截断到前 120 行防撑爆卡片 * 数据源:parsed.diff(patch_file 工具执行结果字段) diff 截断到前 120 行防撑爆卡片
*/ */
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, 120)) const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, 120))
/** /**
* grep 命中计数标签(F-260621):G-1 复用 useToolCard.formatGrep 单一函数, * grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数,
* 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案) * 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案)
* parsed null 返空串,等价原逻辑; null 委托 formatGrep * parsed null 返空串,等价原逻辑; null 委托 formatGrep
*/ */
@@ -333,7 +333,7 @@ function onInstallObscura() {
emit('install-obscura') emit('install-obscura')
} }
// SW-260618-06: status completed /(便 stderr/ body) // status completed /(便 stderr/ body)
// immediate completed, ref false, // immediate completed, ref false,
watch(() => props.tc.status, (s) => { watch(() => props.tc.status, (s) => {
if (s === 'completed') { if (s === 'completed') {
@@ -402,7 +402,7 @@ watch(() => props.tc.status, (s) => {
color: var(--df-warning); color: var(--df-warning);
} }
/* grep 结果(F-260621):文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */ /* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
.ai-tool-grep-result { .ai-tool-grep-result {
border-top: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border);
} }
+6 -6
View File
@@ -128,7 +128,7 @@ import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, Idea
import type { MentionItem } from './MentionItem' import type { MentionItem } from './MentionItem'
const props = defineProps<{ const props = defineProps<{
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */ /** 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
editingMsgId: string | null editingMsgId: string | null
}>() }>()
@@ -317,7 +317,7 @@ watch(inputText, (v) => {
// / ( / ) // / ( / )
skillOpen.value = v.startsWith('/') skillOpen.value = v.startsWith('/')
if (skillOpen.value) skillIndex.value = 0 if (skillOpen.value) skillIndex.value = 0
// UX-10: @ ( @ ,;) // @ ( @ ,;)
if (skillOpen.value) { if (skillOpen.value) {
mentionOpen.value = false mentionOpen.value = false
mentionStart.value = -1 mentionStart.value = -1
@@ -327,7 +327,7 @@ watch(inputText, (v) => {
}) })
/** /**
* UX-10: 检测光标前最近的 @ 触发符 * 检测光标前最近的 @ 触发符
* 规则:从光标位置向前找最近的 @,期间无空白/换行则激活(支持 "abc @dev" 但不支持 "a @b c @d" 中第二个被空格阻断) * 规则:从光标位置向前找最近的 @,期间无空白/换行则激活(支持 "abc @dev" 但不支持 "a @b c @d" 中第二个被空格阻断)
* 找到 mentionStart= @ 下标, mentionOpen=true; 否则关闭 * 找到 mentionStart= @ 下标, mentionOpen=true; 否则关闭
*/ */
@@ -675,7 +675,7 @@ function selectMention(item: MentionItem) {
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
// UX-09: Escape () // Escape ()
if (props.editingMsgId && e.key === 'Escape') { if (props.editingMsgId && e.key === 'Escape') {
e.preventDefault() e.preventDefault()
emit('cancel-edit') emit('cancel-edit')
@@ -716,7 +716,7 @@ function handleKeydown(e: KeyboardEvent) {
return return
} }
} }
// UX-10: @ (/;) // @ (/;)
if (mentionOpen.value && mentionItems.value.length) { if (mentionOpen.value && mentionItems.value.length) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
@@ -907,7 +907,7 @@ function focus(): void {
} }
/** /**
* UX-260617-16:未发送输入保护检测 inputText/pendingImages/pendingSkill 任一非空, * 未发送输入保护检测 inputText/pendingImages/pendingSkill 任一非空,
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换) * 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)
*/ */
function hasUnsentInput(): boolean { function hasUnsentInput(): boolean {
+7 -7
View File
@@ -322,7 +322,7 @@ function onActionsOutsideClick(e: MouseEvent) {
} }
/** /**
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图 * 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见切回分组视图后展开所在分组 * M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见切回分组视图后展开所在分组
* scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮) * scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)
*/ */
@@ -425,7 +425,7 @@ const searchResults = computed<AiConversationSummary[] | null>(
() => store.filteredConversations.value, () => store.filteredConversations.value,
) )
// UX-2025-16: //
const resizeState = { startX: 0, startWidth: 0 } const resizeState = { startX: 0, startWidth: 0 }
function onResizeMove(e: MouseEvent) { function onResizeMove(e: MouseEvent) {
store.setSidebarWidth(resizeState.startWidth + (e.clientX - resizeState.startX)) store.setSidebarWidth(resizeState.startWidth + (e.clientX - resizeState.startX))
@@ -451,11 +451,11 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick) document.removeEventListener('click', onActionsOutsideClick)
// UX-2025-16: document listener( AiChat ) // document listener( AiChat )
document.removeEventListener('mousemove', onResizeMove) document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd) document.removeEventListener('mouseup', onResizeEnd)
document.body.classList.remove('ai-sidebar-resizing') document.body.classList.remove('ai-sidebar-resizing')
// UX-2025-20: //
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null } if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
}) })
@@ -482,7 +482,7 @@ defineExpose({
height: 100%; height: 100%;
background: var(--df-bg); background: var(--df-bg);
} }
/* UX-2025-16:右边缘 2px 拖拽条。hover/active 显高亮;拖拽中 body.ai-sidebar-resizing 全局锁 cursor 防抖 */ /* 右边缘 2px 拖拽条。hover/active 显高亮;拖拽中 body.ai-sidebar-resizing 全局锁 cursor 防抖 */
.ai-sidebar-resize-handle { .ai-sidebar-resize-handle {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -662,7 +662,7 @@ body.ai-sidebar-resizing * {
font-size: 9px; font-size: 9px;
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* F-09: 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */ /* 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */
.ai-conv-gen-dot { .ai-conv-gen-dot {
display: inline-block; display: inline-block;
width: 6px; width: 6px;
@@ -706,7 +706,7 @@ body.ai-sidebar-resizing * {
background: var(--df-sidebar-hover); background: var(--df-sidebar-hover);
color: var(--df-text); color: var(--df-text);
} }
/* CR-22-1: (线,,) /* (线,,)
pinned 类挂在 .ai-conv-item (操作收进更多菜单后,按钮不再常驻, item 级类) */ pinned 类挂在 .ai-conv-item (操作收进更多菜单后,按钮不再常驻, item 级类) */
.ai-conv-item--pinned { .ai-conv-item--pinned {
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text)); box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
+1 -1
View File
@@ -70,7 +70,7 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ( streaming) streaming=false generating=true // ( streaming) streaming=false generating=true
// generatingConvs conv(useAiEvents handleEvent / add) && streaming, // generatingConvs conv(useAiEvents handleEvent / add) && streaming,
// loop () streaming,isGenerating(conv) // loop () streaming,isGenerating(conv)
// F-09: isGenerating(active) // isGenerating(active)
const isViewingGenerating = computed(() => const isViewingGenerating = computed(() =>
store.isGenerating(store.state.activeConversationId), store.isGenerating(store.state.activeConversationId),
) )
+2 -2
View File
@@ -20,7 +20,7 @@ const emit = defineEmits<{
(e: 'go-to-settings'): void (e: 'go-to-settings'): void
}>() }>()
// UX-2025-20: //
// (); i18n key , key // (); i18n key , key
const examplePrompts = [ const examplePrompts = [
'aiChat.examplePrompt1', 'aiChat.examplePrompt1',
@@ -85,7 +85,7 @@ const examplePrompts = [
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
} }
/* UX-2025-20: 无 provider 引导 — 跳 Settings 按钮(克制:边框线 + accent 文字,不喧宾) */ /* 无 provider 引导 — 跳 Settings 按钮(克制:边框线 + accent 文字,不喧宾) */
.ai-empty-action { .ai-empty-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+1 -1
View File
@@ -43,7 +43,7 @@ const { t } = useI18n()
// ,130s streaming streaming=false // ,130s streaming streaming=false
// "/" generating max DirAuthAuth () streaming, // "/" generating max DirAuthAuth () streaming,
// isGenerating(conv) ( max generatingConvs conv,useAiEvents handleEvent / add) // isGenerating(conv) ( max generatingConvs conv,useAiEvents handleEvent / add)
// F-09: isGenerating(active) // isGenerating(active)
const isViewingGenerating = computed(() => const isViewingGenerating = computed(() =>
store.isGenerating(store.state.activeConversationId), store.isGenerating(store.state.activeConversationId),
) )
+30 -30
View File
@@ -28,7 +28,7 @@ import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage } from '../../api/types' import type { AiMessage } from '../../api/types'
defineProps<{ defineProps<{
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */ /** 当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
editingMsgId?: string | null editingMsgId?: string | null
}>() }>()
@@ -42,8 +42,8 @@ const emit = defineEmits<{
const store = useAiStore() const store = useAiStore()
const { t } = useI18n() const { t } = useI18n()
// Markdown (ARC-260615-08: memo,D) // Markdown memo,D)
// B-23:(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/ // (_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
// renderMd) composables/useMarkdown(), TaskDetail/AiChat // renderMd) composables/useMarkdown(), TaskDetail/AiChat
// (splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse) // (splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)
// composables/ai/useStreamRenderer( opts saveSelection/restoreSelection/ // composables/ai/useStreamRenderer( opts saveSelection/restoreSelection/
@@ -54,14 +54,14 @@ const { t } = useI18n()
// scheduleStreamParse/renderStreamingBlocks/clearStreamingState composable // scheduleStreamParse/renderStreamingBlocks/clearStreamingState composable
const { loadMarkdown } = useMarkdown() const { loadMarkdown } = useMarkdown()
// UX-2025-20: (examplePrompts EmptyState.vue ) // (examplePrompts EmptyState.vue )
const messagesContainer = ref<HTMLDivElement>() const messagesContainer = ref<HTMLDivElement>()
// ( ToolCardList , expose collapseInactive ) // ( ToolCardList , expose collapseInactive )
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>() const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
// () // ()
// F-09: isGenerating(active) // isGenerating(active)
const isViewingGenerating = computed(() => const isViewingGenerating = computed(() =>
store.state.streaming && store.isGenerating(store.state.activeConversationId) store.state.streaming && store.isGenerating(store.state.activeConversationId)
) )
@@ -72,10 +72,10 @@ function isLastAi(msg: AiMessage): boolean {
} }
/** /**
* B-260618-19: 是否渲染某条消息( .ai-msg-slot v-if 守卫) * 是否渲染某条消息( .ai-msg-slot v-if 守卫)
* *
* 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见), * 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见),
* 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后, v-else-if(UX-260617-22) * 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后, v-else-if
* 漏判 toolCalls 把它整条过滤 工具卡片消失,且外层 .ai-msg-slot 恒渲染留空 div, * 漏判 toolCalls 把它整条过滤 工具卡片消失,且外层 .ai-msg-slot 恒渲染留空 div,
* flex gap 堆积成大片空白,末条光标气泡随每轮 AiAgentRound push 往下移 * flex gap 堆积成大片空白,末条光标气泡随每轮 AiAgentRound push 往下移
* *
@@ -207,14 +207,14 @@ async function handleMessagesScroll(): Promise<void> {
} }
watch(() => store.state.messages.length, onContentChange) watch(() => store.state.messages.length, onContentChange)
// SW-260618-07: currentText onContentChange scheduleStreamParse watch( currentText, callback ) // currentText onContentChange scheduleStreamParse watch( currentText, callback )
// Markdown (ARC-260615-08 memo): // Markdown (ARC-260615-08 memo):
// - currentText ( delta):rAF scheduleStreamParse, memo parse // - currentText ( delta):rAF scheduleStreamParse, memo parse
// - streaming : rAF + streamingBlocks( renderMd final marked + ) // - streaming : rAF + streamingBlocks( renderMd final marked + )
// - mdReady (CR-260615-06): marked + delta escapeFallback // - mdReady marked + delta escapeFallback
// ;marked ,使 // ;marked ,使
// SW-260618-07: currentText watch( :2040 onContentChange + watch scheduleStreamParse ) // currentText watch( :2040 onContentChange + watch scheduleStreamParse )
// :onContentChange() scheduleStreamParse(rAF ), // :onContentChange() scheduleStreamParse(rAF ),
watch(() => store.state.currentText, (text) => { watch(() => store.state.currentText, (text) => {
// scheduleStreamParse rAF (DOM ), // scheduleStreamParse rAF (DOM ),
@@ -244,7 +244,7 @@ watch(() => store.state.streaming, (s) => {
// streamingBlocks( currentText watch !text ,BUG-260624-01) // streamingBlocks( currentText watch !text ,BUG-260624-01)
cancelPendingRaf() cancelPendingRaf()
if (s) { if (s) {
// UX-260616-03: , // ,
// (isGenerating(activeConversationId)), // (isGenerating(activeConversationId)),
// /(streaming true onMounted ) // /(streaming true onMounted )
// buildActiveToolIds/collapseAllToolLists function , // buildActiveToolIds/collapseAllToolLists function ,
@@ -252,7 +252,7 @@ watch(() => store.state.streaming, (s) => {
collapseAllToolLists(buildActiveToolIds(store.state.messages)) collapseAllToolLists(buildActiveToolIds(store.state.messages))
} }
} else { } else {
// UX-2025-01: , v-for v-html ,DOM // , v-for v-html ,DOM
saveSelection() saveSelection()
clearStreamingState() clearStreamingState()
nextTick(() => restoreSelection()) nextTick(() => restoreSelection())
@@ -270,7 +270,7 @@ watch(() => store.state.streaming, (s) => {
// toolCall (//), // toolCall (//),
// snap/MESSAGE_CAP (deep watch , body ) // snap/MESSAGE_CAP (deep watch , body )
// //
// UX-260616-03: messages deep watch(toolCall /), // messages deep watch(toolCall /),
// agentRound watch(AiAgentRound ,) // agentRound watch(AiAgentRound ,)
// active Set buildActiveToolIds, watch // active Set buildActiveToolIds, watch
function buildActiveToolIds(msgs: typeof store.state.messages): Set<string> { function buildActiveToolIds(msgs: typeof store.state.messages): Set<string> {
@@ -330,7 +330,7 @@ watch(
}, },
) )
// UX-260616-03: AiAgentRound (round ) // AiAgentRound (round )
// ("",) // ("",)
watch( watch(
() => store.state.agentRound, () => store.state.agentRound,
@@ -340,12 +340,12 @@ watch(
}, },
) )
// CR-260615-06: mdReady Markdown // mdReady Markdown
// marked + delta // marked + delta
const _mdRenderKey = ref(0) const _mdRenderKey = ref(0)
function forceUpdate() { _mdRenderKey.value++ } function forceUpdate() { _mdRenderKey.value++ }
// UX-2025-01: (save/restore Selection) // (save/restore Selection)
// v-html innerHTML DOM Node // v-html innerHTML DOM Node
// : + , // : + ,
// Markdown (DOM) // Markdown (DOM)
@@ -484,7 +484,7 @@ watch(mdReady, (ready) => {
} }
}) })
// (F-260615-13) //
// click () clipboard API () // click () clipboard API ()
// navigator.clipboard secure context (Electron file:// / webview) , // navigator.clipboard secure context (Electron file:// / webview) ,
// 退 execCommand('copy') ; // 退 execCommand('copy') ;
@@ -558,7 +558,7 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
} }
} }
// UX-03: //
// errorType useAiEvents push (types.ts AiMessage , // errorType useAiEvents push (types.ts AiMessage ,
// cast AiChat.vue / useAiEvents , types.ts ) // cast AiChat.vue / useAiEvents , types.ts )
interface AiMessageWithErrorType extends AiMessage { interface AiMessageWithErrorType extends AiMessage {
@@ -572,7 +572,7 @@ function canOpenSettings(msg: AiMessage): boolean {
} }
/** /**
* UX-260616-01: 错误是否可重试(显隐重试按钮) * 错误是否可重试(显隐重试按钮)
* Fatal 类错误(鉴权/Provider 配置错4xx/401/403/invalid_api_key)重试必再败, * Fatal 类错误(鉴权/Provider 配置错4xx/401/403/invalid_api_key)重试必再败,
* 隐藏重试按钮,引导用户走去设置network/timeout/unknown 为瞬时错误,可重试 * 隐藏重试按钮,引导用户走去设置network/timeout/unknown 为瞬时错误,可重试
* retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError retryable 字段下批 df-ai 增强) * retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError retryable 字段下批 df-ai 增强)
@@ -587,7 +587,7 @@ function canRetry(msg: AiMessage): boolean {
/** /**
* 错误气泡重试:取错误前的末尾 user 消息重发 * 错误气泡重试:取错误前的末尾 user 消息重发
* 复用 regenerate 链路(后端弹末尾 AI 回复错误消息本身取触发它的 user 消息重跑) * 复用 regenerate 链路(后端弹末尾 AI 回复错误消息本身取触发它的 user 消息重跑)
* 语义等价"重新生成",但语义场景是错误恢复,文案/入口独立(UX-03) * 语义等价"重新生成",但语义场景是错误恢复,文案/入口独立
*/ */
async function handleRetry(): Promise<void> { async function handleRetry(): Promise<void> {
try { try {
@@ -741,9 +741,9 @@ const renderItems = computed<RenderItem[]>(() => {
return items return items
}) })
// UX-09:/;沿/(SW-260618-18) // /;沿/
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
// SW-260618-18: 沿 + () // 沿 + ()
setFollowing(true) setFollowing(true)
// BUG-260624-01(· workflow ):MessageList (AiChat.vue // BUG-260624-01(· workflow ):MessageList (AiChat.vue
// :key/v-if ), streamingBlocks(:69)/ // :key/v-if ), streamingBlocks(:69)/
@@ -1043,7 +1043,7 @@ defineExpose({
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
/* UX-2025-19: .ai-messages flex (B-260618-01: sentinel , /* .ai-messages flex sentinel ,
现仅作消息分组的语义容器,gap:14px .ai-messages 承担) */ 现仅作消息分组的语义容器,gap:14px .ai-messages 承担) */
.ai-msg-slot { display: flex; flex-direction: column; } .ai-msg-slot { display: flex; flex-direction: column; }
@@ -1148,7 +1148,7 @@ defineExpose({
color: var(--df-danger); color: var(--df-danger);
border-color: rgba(240,101,101,0.3); border-color: rgba(240,101,101,0.3);
} }
/* B-260618-06: UX-260617-21 display:block display:block table /* UX-260617-21 display:block display:block table
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱) 布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)
正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡 正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡
容器 .ai-msg-bubble--ai.ai-md overflow-x:auto 承载(CSS spec table-display 容器 .ai-msg-bubble--ai.ai-md overflow-x:auto 承载(CSS spec table-display
@@ -1253,7 +1253,7 @@ defineExpose({
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); } .ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); } .ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── 消息时间戳(UX-2025-13) ── */ /* ── 消息时间戳── */
.ai-msg-time { .ai-msg-time {
font-size: 10px; font-size: 10px;
line-height: 1; line-height: 1;
@@ -1268,7 +1268,7 @@ defineExpose({
align-self: flex-end; /* 用户消息:靠气泡右对齐 */ align-self: flex-end; /* 用户消息:靠气泡右对齐 */
} }
/* ── 消息气泡拷贝按钮(F-260615-13) ── */ /* ── 消息气泡拷贝按钮── */
.ai-copy-btn { .ai-copy-btn {
position: absolute; position: absolute;
right: 4px; right: 4px;
@@ -1299,7 +1299,7 @@ defineExpose({
right: 30px; right: 30px;
} }
/* ── UX-09: 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */ /* ── 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */
.ai-msg-edit-btn { .ai-msg-edit-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1324,7 +1324,7 @@ defineExpose({
color: var(--df-text); color: var(--df-text);
} }
/* ── UX-02/UX-03: 消息操作栏(hover 显示 / 错误气泡常显) ── */ /* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
.ai-msg-actions { .ai-msg-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1338,7 +1338,7 @@ defineExpose({
.ai-msg-actions--error { .ai-msg-actions--error {
opacity: 1; opacity: 1;
} }
/* UX-260616-01: Fatal (), /* Fatal (),
说明为何无重试入口,引导走去设置 */ 说明为何无重试入口,引导走去设置 */
.ai-msg-not-retryable { .ai-msg-not-retryable {
font-size: 10.5px; font-size: 10.5px;
@@ -1492,7 +1492,7 @@ defineExpose({
background: var(--df-bg); background: var(--df-bg);
} }
/* F-15: system (compressed , AI ) /* system (compressed , AI )
用户诉求:压缩只后端省 token,界面不藏消息原文 compressed 照常 user/ai 气泡展开, 用户诉求:压缩只后端省 token,界面不藏消息原文 compressed 照常 user/ai 气泡展开,
摘要 system 块置顶 accent 提示,让用户知情"AI 看到的是摘要" */ 摘要 system 块置顶 accent 提示,让用户知情"AI 看到的是摘要" */
.ai-msg-system { .ai-msg-system {
+8 -8
View File
@@ -334,7 +334,7 @@ watch(() => store.state.activeConversationId, () => {
} }
}) })
// provider override (BUG-2026-07-07:provider header ): // provider override provider header ):
// - modelOverride ref(useAiSend), provider provider model_id, // - modelOverride ref(useAiSend), provider provider model_id,
// id provider (UI X, Y) // id provider (UI X, Y)
// - : provider (/), // - : provider (/),
@@ -400,7 +400,7 @@ const historyExpanded = ref(false)
function scrollToMessage(msgId: string) { function scrollToMessage(msgId: string) {
historyExpanded.value = false historyExpanded.value = false
// data-msg-id , DOM // data-msg-id , DOM
// BUG-260802:msgId , CSS (: . [ ] / ), // msgId , CSS (: . [ ] / ),
// selector / querySelector null // selector / querySelector null
// CSS.escape msgId, // CSS.escape msgId,
// CSS.escape :encodeURIComponent + attr*= (,) // CSS.escape :encodeURIComponent + attr*= (,)
@@ -542,18 +542,18 @@ async function removeGoal(i: number) {
function cycleProvider() { function cycleProvider() {
const ps = store.state.providers const ps = store.state.providers
if (ps.length < 2) return if (ps.length < 2) return
// F-260616-10: activeProvider null DB (is_default), findIndex=-1 providers[0] // activeProvider null DB (is_default), findIndex=-1 providers[0]
const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null
const idx = startId ? ps.findIndex(p => p.id === startId) : -1 const idx = startId ? ps.findIndex(p => p.id === startId) : -1
// idx=-1 provider fallback 0(,) // idx=-1 provider fallback 0(,)
const next = ps[(Math.max(idx, 0) + 1) % ps.length] const next = ps[(Math.max(idx, 0) + 1) % ps.length]
store.setProvider(next.id) store.setProvider(next.id)
// UX-2025-14: toast model + bar model (2s ) // toast model + bar model (2s )
// toast : reactive showToast( App.vue toast), emit // toast : reactive showToast( App.vue toast), emit
showProviderSwitched(next.name || next.id) showProviderSwitched(next.name || next.id)
} }
// UX-2025-14: Provider bar // Provider bar
// model ,0.15s ,2s bar (ref + Transition), // model ,0.15s ,2s bar (ref + Transition),
// toast emit ( toast reactive ) // toast emit ( toast reactive )
const providerBarVisible = ref(false) const providerBarVisible = ref(false)
@@ -567,7 +567,7 @@ function showProviderSwitched(modelName: string) {
_providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000) _providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000)
} }
// UX-2025-14: provider bar ( AiChat onBeforeUnmount , _providerBarTimer ) // provider bar ( AiChat onBeforeUnmount , _providerBarTimer )
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null } if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
}) })
@@ -604,7 +604,7 @@ onBeforeUnmount(() => {
white-space: nowrap; white-space: nowrap;
max-width: 200px; max-width: 200px;
} }
/* AE-2025-02:审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */ /* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */
.ai-approval-badge { .ai-approval-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1014,7 +1014,7 @@ onBeforeUnmount(() => {
font-size: 11px; font-size: 11px;
color: var(--df-danger); color: var(--df-danger);
} }
/* UX-2025-14: Provider 切换临时 bar(淡入淡出 0.15s) */ /* Provider 切换临时 bar(淡入淡出 0.15s) */
.ai-provider-switch-bar { .ai-provider-switch-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -114,7 +114,7 @@ const displayProjects = computed(() =>
.dot-release { background: var(--df-success); } .dot-release { background: var(--df-success); }
.dot-planning { background: var(--df-info); } .dot-planning { background: var(--df-info); }
.dot-cancelled { background: var(--df-text-dim); } .dot-cancelled { background: var(--df-text-dim); }
/* WK-1:paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */ /* paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */
.dot-paused { background: var(--df-text-dim); } .dot-paused { background: var(--df-text-dim); }
.project-name { .project-name {
+8 -8
View File
@@ -322,7 +322,7 @@ const emit = defineEmits<{
(e: 'promote'): void (e: 'promote'): void
(e: 'delete'): void (e: 'delete'): void
(e: 'status-change', value: IdeaStatus): void (e: 'status-change', value: IdeaStatus): void
// WK-16: Promise<boolean> await / // Promise<boolean> await /
// true=();false=(,toast ) // true=();false=(,toast )
(e: 'update-desc', value: string): Promise<boolean> (e: 'update-desc', value: string): Promise<boolean>
(e: 'update-related', ids: string[]): Promise<void> (e: 'update-related', ids: string[]): Promise<void>
@@ -344,7 +344,7 @@ function startEdit() {
} }
// P1- saveEdit :emit Promise<boolean>( store.updateIdea ), // P1- saveEdit :emit Promise<boolean>( store.updateIdea ),
// ; + toast (WK-16) // ; + toast
async function saveEdit() { async function saveEdit() {
if (savingDesc.value) return if (savingDesc.value) return
savingDesc.value = true savingDesc.value = true
@@ -392,7 +392,7 @@ watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1- select badge : badge /, // P1- select badge : badge /,
// emit('status-change') +(沿, statusOptions ) // emit('status-change') +(沿, statusOptions )
const statusMenuOpen = ref(false) const statusMenuOpen = ref(false)
// WK-5: promoted( promoteToProject ,, // promoted( promoteToProject ,,
// Ideas.vue onStatusChange );statusOptions promoted label ( badge ) // Ideas.vue onStatusChange );statusOptions promoted label ( badge )
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted')) const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted'))
function closeStatusMenu() { function closeStatusMenu() {
@@ -502,7 +502,7 @@ async function saveTagsEdit() {
savingTags.value = true savingTags.value = true
try { try {
const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean) const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean)
// WK-16:emit ,(),toast // emit ,(),toast
const ok = await emit('update-tags', JSON.stringify(tags)) const ok = await emit('update-tags', JSON.stringify(tags))
if (ok) tagsEditing.value = false if (ok) tagsEditing.value = false
await nextTick() await nextTick()
@@ -520,9 +520,9 @@ function statusLabelKey(status: IdeaStatus): string {
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
} }
// B-260615-25: Markdown ( AiChat/TaskDetail ,), // Markdown ( AiChat/TaskDetail ,),
// useRendered computed( mdReady + renderMd)+ ensureLoaded() // useRendered computed( mdReady + renderMd)+ ensureLoaded()
// BUG-2026-07-08: ensureLoaded + onMounted ,(marked ) // ensureLoaded + onMounted ,(marked )
// mdReady false,renderMd escapeFallback ,Markdown // mdReady false,renderMd escapeFallback ,Markdown
const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered( const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered(
() => props.idea.description ?? '', () => props.idea.description ?? '',
@@ -548,7 +548,7 @@ interface AdversarialEval {
// ai_analysis JSON // ai_analysis JSON
// //
// WK-15: JSON.parse AdversarialEval,/ // JSON.parse AdversarialEval,/
// undefined*100 .toFixed NaN,assessmentClass undefined // undefined*100 .toFixed NaN,assessmentClass undefined
// .toLowerCase() (),: // .toLowerCase() (),:
// - / 0 // - / 0
@@ -599,7 +599,7 @@ const localAssessmentLabel = (recommendation: string): string =>
assessmentLabel(t, recommendation) assessmentLabel(t, recommendation)
function sentimentClass(sentiment: number) { function sentimentClass(sentiment: number) {
// (FR-C2: >=0 >0 net_sentiment=0 positive) // >=0 >0 net_sentiment=0 positive)
if (sentiment > 0) return 'positive' if (sentiment > 0) return 'positive'
if (sentiment < 0) return 'negative' if (sentiment < 0) return 'negative'
return 'neutral' return 'neutral'
+1 -1
View File
@@ -106,7 +106,7 @@ const resizing = ref(false)
// maximized=(flex:1) | sidebar= // maximized=(flex:1) | sidebar=
const panelStyle = computed(() => { const panelStyle = computed(() => {
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' } if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
// LW-11: width min(panelWidth, 100vw) media query width:100vw // width min(panelWidth, 100vw) media query width:100vw
// ;min() 100vw,media query absolute / // ;min() 100vw,media query absolute /
return { return {
width: `min(${panelWidth.value}px, 100vw)`, width: `min(${panelWidth.value}px, 100vw)`,
+2 -2
View File
@@ -92,7 +92,7 @@ const appSettings = useAppSettingsStore()
// 192px(+) / 56px(,hover tooltip ) // 192px(+) / 56px(,hover tooltip )
// : > (768px) // : > (768px)
const COLLAPSE_THRESHOLD = 768 const COLLAPSE_THRESHOLD = 768
// LW-10:useSetting (loadAll watch ) // useSetting (loadAll watch )
// onMounted appSettings.get loadAll , // onMounted appSettings.get loadAll ,
const userCollapsed = appSettings.useSetting('df-sidebar-collapsed', false) const userCollapsed = appSettings.useSetting('df-sidebar-collapsed', false)
const isNarrowWindow = ref(false) // const isNarrowWindow = ref(false) //
@@ -119,7 +119,7 @@ function handleCollapseKeydown(e: KeyboardEvent) {
} }
onMounted(async () => { onMounted(async () => {
// LW-10: useSetting (loadAll watch ), onMounted // useSetting (loadAll watch ), onMounted
// //
checkWindowWidth() checkWindowWidth()
window.addEventListener('resize', checkWindowWidth) window.addEventListener('resize', checkWindowWidth)
+2 -2
View File
@@ -135,7 +135,7 @@ async function handleApproval(decision: string) {
} }
} }
// F-260615-01: (UX-260618-17 submitting ) // (UX-260618-17 submitting )
async function handleApprovalMulti() { async function handleApprovalMulti() {
if (multiDecisions.value.length === 0) return if (multiDecisions.value.length === 0) return
submitting.value = true submitting.value = true
@@ -153,7 +153,7 @@ async function handleApprovalMulti() {
} }
} }
// F-260615-01: () // ()
watch(() => pendingApproval.value, (newApproval) => { watch(() => pendingApproval.value, (newApproval) => {
if (newApproval) { if (newApproval) {
multiDecisions.value = [] multiDecisions.value = []
+2 -2
View File
@@ -86,7 +86,7 @@ import SettingRow from './SettingRow.vue'
const { t } = useI18n() const { t } = useI18n()
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
// F-260620: toast (Settings.vue showToast), console.error/warn // toast (Settings.vue showToast), console.error/warn
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>() const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
// F-260619-03 Phase A: AI 访 UI // F-260619-03 Phase A: AI 访 UI
@@ -126,7 +126,7 @@ async function pickDir() {
const selected = await open({ directory: true, multiple: false }) const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return if (!selected || Array.isArray(selected)) return
newDir.value = selected as string newDir.value = selected as string
// F-260620: ("") // ("")
await addDir() await addDir()
} catch (e) { } catch (e) {
console.error('选择目录失败:', e) console.error('选择目录失败:', e)
+1 -1
View File
@@ -88,7 +88,7 @@ const { t } = useI18n()
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
const emit = defineEmits<{ const emit = defineEmits<{
// P0-2: ToastType 'success'(,) // ToastType 'success'(,)
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
}>() }>()
@@ -67,9 +67,9 @@ const appSettings = useAppSettingsStore()
const settings = reactive({ const settings = reactive({
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3), llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2), llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
// F-260616-01: Agentic 10 DEFAULT_MAX_AGENT_ITERATIONS // Agentic 10 DEFAULT_MAX_AGENT_ITERATIONS
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10), agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
// F-260616-07: 3 DEFAULT_MAX_AGENT_RETRIES // 3 DEFAULT_MAX_AGENT_RETRIES
agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3), agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3),
}) })
@@ -148,7 +148,7 @@ function onRetryChange() {
} }
// Agentic (debounce 300ms) // Agentic (debounce 300ms)
// F-260616-01: loop load loop // loop load loop
// llm_concurrency Arc // llm_concurrency Arc
// clamp 1-50 + command clamp 1-50 loop / // clamp 1-50 + command clamp 1-50 loop /
let _agentIterTimer: ReturnType<typeof setTimeout> | null = null let _agentIterTimer: ReturnType<typeof setTimeout> | null = null
@@ -184,7 +184,7 @@ const unlimited = computed({
}) })
// (debounce 300ms) // (debounce 300ms)
// F-260616-07: loop load loop // loop load loop
// clamp 0-10 + command clamp 0-10 // clamp 0-10 + command clamp 0-10
let _agentRetryTimer: ReturnType<typeof setTimeout> | null = null let _agentRetryTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxRetries() { function syncAgentMaxRetries() {
@@ -206,12 +206,12 @@ function syncAgentMaxRetries() {
onMounted(() => { onMounted(() => {
// LLM // LLM
syncConcurrencyConfig() syncConcurrencyConfig()
// F-260616-01: Agentic // Agentic
// F-260618-23: App.vue onMounted Settings AI Chat // App.vue onMounted Settings AI Chat
// IPC // IPC
syncAgentMaxIterations() syncAgentMaxIterations()
// F-260616-07: //
// F-260618-23: App.vue onMounted // App.vue onMounted
syncAgentMaxRetries() syncAgentMaxRetries()
}) })
+13 -13
View File
@@ -185,7 +185,7 @@ import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource }
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets' import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
// ============================================================ // ============================================================
// AI CRUD + F-04c (enabled / weight) // AI CRUD + c (enabled / weight)
// ============================================================ // ============================================================
const { t } = useI18n() const { t } = useI18n()
@@ -200,7 +200,7 @@ async function syncAiStoreProviders() {
} }
const emit = defineEmits<{ const emit = defineEmits<{
// P0-2: 'success'(绿 toast) // 'success'(绿 toast)
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
(e: 'providers-changed'): void (e: 'providers-changed'): void
}>() }>()
@@ -229,7 +229,7 @@ const providerForm = reactive({
// F-01 6: + // F-01 6: +
fetching: false, fetching: false,
models: [] as ModelConfig[], models: [] as ModelConfig[],
// P0-2: ( toast) // ( toast)
// toast 3s , + , // toast 3s , + ,
// /null= // /null=
saveError: null as string | null, saveError: null as string | null,
@@ -401,7 +401,7 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.models = [] providerForm.models = []
} }
providerForm.fetching = false providerForm.fetching = false
providerForm.saveError = null // P0-2: providerForm.saveError = null //
providerForm.visible = true providerForm.visible = true
// L19/M32: ( visible=true),isFormDirty // L19/M32: ( visible=true),isFormDirty
providerForm.savedSnapshot = formBusinessSnapshot() providerForm.savedSnapshot = formBusinessSnapshot()
@@ -426,7 +426,7 @@ function onPresetChange() {
} }
/** /**
* 测试连接按钮可用条件(P0-1): * 测试连接按钮可用条件
* - baseUrl 必填(无论新建/编辑,表单值都要有) * - baseUrl 必填(无论新建/编辑,表单值都要有)
* - 新建态(editId ):api_key 必填(无已保存配置可沿用) * - 新建态(editId ):api_key 必填(无已保存配置可沿用)
* - 编辑态(editId ):api_key 可空(留空=沿用已保存密钥) * - 编辑态(editId ):api_key 可空(留空=沿用已保存密钥)
@@ -438,7 +438,7 @@ const canTestConnection = computed(() => {
}) })
/** /**
* 测试连接并拉取模型列表(P0-1) * 测试连接并拉取模型列表
* **表单当前值**测试( DB ):新建态直接传表单值;编辑态传表单值 + editId, * **表单当前值**测试( DB ):新建态直接传表单值;编辑态传表单值 + editId,
* apiKey 留空时后端从已保存配置沿用密钥一次性测试不写库 * apiKey 留空时后端从已保存配置沿用密钥一次性测试不写库
* *
@@ -486,7 +486,7 @@ async function fetchModels() {
} }
/** /**
* P0-2: 把后端返回的错误文本分类成用户可操作的建议 * 把后端返回的错误文本分类成用户可操作的建议
* *
* 后端 commands/provider.rs 已对密钥相关失败返回友好中文 Err(含原因+建议), * 后端 commands/provider.rs 已对密钥相关失败返回友好中文 Err(含原因+建议),
* 这里只做轻量分类挂上"建议下一步",避免裸 Err 文本吓到用户: * 这里只做轻量分类挂上"建议下一步",避免裸 Err 文本吓到用户:
@@ -526,14 +526,14 @@ async function saveProvider() {
defaultModel: providerForm.defaultModel, defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models, modelConfigs: providerForm.models,
}) })
// P0-2: + + 绿toast // + + 绿toast
providerForm.saveError = null providerForm.saveError = null
providerForm.visible = false providerForm.visible = false
providerForm.savedSnapshot = '' // L19: providerForm.savedSnapshot = '' // L19:
await loadProviders() await loadProviders()
emit('toast', t('settings.toastSaved'), 'success') emit('toast', t('settings.toastSaved'), 'success')
} catch (e) { } catch (e) {
// P0-2: ()(,) // ()(,)
// error toast(线), // error toast(线),
const raw = errMsg(e) const raw = errMsg(e)
console.error(t('settings.toastSaveFail', { msg: raw }), e) console.error(t('settings.toastSaveFail', { msg: raw }), e)
@@ -570,10 +570,10 @@ async function setDefaultProvider(id: string) {
} }
// ============================================================ // ============================================================
// F-260614-04c: (enabled / weight) // c: (enabled / weight)
// IPC( syncConcurrencyConfig ); reload_provider_caps // IPC( syncConcurrencyConfig ); reload_provider_caps
// per_provider caps, acquire // per_provider caps, acquire
// saveProvider( INSERT OR REPLACE R-PD-1 , F-04c UPDATE ) // saveProvider( INSERT OR REPLACE R-PD-1 , c UPDATE )
// ============================================================ // ============================================================
let _poolTimer: ReturnType<typeof setTimeout> | null = null let _poolTimer: ReturnType<typeof setTimeout> | null = null
@@ -693,7 +693,7 @@ onMounted(loadProviders)
} }
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; } .detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
/* Provider 卡片 disabled 态(F-260614-04c:enabled=false 视觉弱化) */ /* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
.provider-card--disabled { opacity: 0.6; } .provider-card--disabled { opacity: 0.6; }
.pool-badge { .pool-badge {
font-size: 10px; font-size: 10px;
@@ -718,7 +718,7 @@ onMounted(loadProviders)
.form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; } .form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; } .form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
/* P0-2: 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */ /* 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
.save-error-banner { .save-error-banner {
background: var(--df-danger-bg); background: var(--df-danger-bg);
border: 0.5px solid var(--df-danger); border: 0.5px solid var(--df-danger);
+2 -2
View File
@@ -60,7 +60,7 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
defaultModel: 'deepseek-v4-flash', defaultModel: 'deepseek-v4-flash',
// 重要:deepseek-chat / deepseek-reasoner 旧名已于 2026-07-24 停用, // 重要:deepseek-chat / deepseek-reasoner 旧名已于 2026-07-24 停用,
// 现分别指向 deepseek-v4-flash 的非思考/思考模式(V4-Flash 2026-07-31 发布)。 // 现分别指向 deepseek-v4-flash 的非思考/思考模式(lash 2026-07-31 发布)。
// 思考模式改 deepseek-v4-flash(传 thinking 参数)或 deepseek-v4-pro。 // 思考模式改 deepseek-v4-flash(传 thinking 参数)或 deepseek-v4-pro。
}, },
{ {
@@ -74,7 +74,7 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
baseUrl: 'https://token.sensenova.cn/v1', baseUrl: 'https://token.sensenova.cn/v1',
// 官方文档(platform.sensenova.cn/docs)核实:Token Plan API 仅 3 个模型 —— // 官方文档(platform.sensenova.cn/docs)核实:Token Plan API 仅 3 个模型 ——
// sensenova-6.7-flash-lite(多模态对话主力)/ sensenova-u1-fast(图像生成,不能对话)/ deepseek-v4-flash(纯文本对话)。 // sensenova-6.7-flash-lite(多模态对话主力)/ sensenova-u1-fast(图像生成,不能对话)/ deepseek-v4-flash(纯文本对话)。
// 无 U1 / U1-Pro 对话模型(那是官网产品名,API 不提供);旧 SenseNova-Turbo 已退役。 // 无 U1 / ro 对话模型(那是官网产品名,API 不提供);旧 SenseNova-Turbo 已退役。
// 默认填对话主力 sensenova-6.7-flash-lite(文档所有工具接入示例的 Model ID)。 // 默认填对话主力 sensenova-6.7-flash-lite(文档所有工具接入示例的 Model ID)。
defaultModel: 'sensenova-6.7-flash-lite', defaultModel: 'sensenova-6.7-flash-lite',
}, },
+1 -1
View File
@@ -241,7 +241,7 @@ export function setConvState(convId: string | null | undefined, s: ConvState): v
// ── F-09 B 路线 per-conv 流式态(streaming/currentText) ── // ── F-09 B 路线 per-conv 流式态(streaming/currentText) ──
// //
// 背景(DEC-07a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例, // 背景(a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例,
// F-09 多会话并发下:A 后台生成中切到 B 会话,B 末条 AI 气泡命中 `isLastAi && streaming && // F-09 多会话并发下:A 后台生成中切到 B 会话,B 末条 AI 气泡命中 `isLastAi && streaming &&
// currentText` 会渲染 A 残留 currentText(BUG-260624-01 同源根因)。本批改 per-conv Map,各会话 // currentText` 会渲染 A 残留 currentText(BUG-260624-01 同源根因)。本批改 per-conv Map,各会话
// 独立累积流式文本/流式开关,切会话即切到该会话的 stream state,真并发不串话。 // 独立累积流式文本/流式开关,切会话即切到该会话的 stream state,真并发不串话。
+4 -4
View File
@@ -22,7 +22,7 @@ import { findToolCall } from './aiShared'
import { dispatchApprovalIPC } from './useToolApproval' import { dispatchApprovalIPC } from './useToolApproval'
/** /**
* F-260616-06: 审批按钮防抖守卫 id IPC * id IPC
* IPC <100ms300ms 窗口足够覆盖竞态 * IPC <100ms300ms 窗口足够覆盖竞态
*/ */
const _pendingApprovalIds = new Set<string>() const _pendingApprovalIds = new Set<string>()
@@ -30,7 +30,7 @@ const _pendingApprovalIds = new Set<string>()
/** /**
* 工具审批:保持 pending_approval ( loading ToolCard ref ), * 工具审批:保持 pending_approval ( loading ToolCard ref ),
* IPC completed+ useAiEvents completed/rejected * IPC completed+ useAiEvents completed/rejected
* B-260616-08:不再乐观置 running .ai-tool-approval (), * running .ai-tool-approval (),
* loading ;loading ToolCard ref, * loading ;loading ToolCard ref,
* *
* path_auth 3b():** tc.kind ** IPC * path_auth 3b():** tc.kind ** IPC
@@ -46,7 +46,7 @@ const _pendingApprovalIds = new Set<string>()
* @param decision path ('once'|'session'|'always'|'deny');risk tc.kind * @param decision path ('once'|'session'|'always'|'deny');risk tc.kind
*/ */
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') { async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试) // 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
if (_pendingApprovalIds.has(toolCallId)) return if (_pendingApprovalIds.has(toolCallId)) return
_pendingApprovalIds.add(toolCallId) _pendingApprovalIds.add(toolCallId)
try { try {
@@ -96,7 +96,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态 // IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
// TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。 // TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。
clearStreamWatchdog(approvalConvId) clearStreamWatchdog(approvalConvId)
state.queue = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失 state.queue = [] // IPC 失败即对话结束,清队列防生成中入队消息静默丢失
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支 // 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败 // 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
console.error('[AI] 审批操作未送达后端:', e) console.error('[AI] 审批操作未送达后端:', e)
+6 -6
View File
@@ -45,7 +45,7 @@ async function loadConversations() {
await switchConversation(pending) await switchConversation(pending)
} }
} catch (e) { } catch (e) {
// UX-260617-08:网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。 // 网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
// 此处保留旧列表(赋值语句抛出,旧 state.conversations 不被覆盖)+ 推错误气泡反馈。 // 此处保留旧列表(赋值语句抛出,旧 state.conversations 不被覆盖)+ 推错误气泡反馈。
// 仅首次加载(state.conversations 为空)时推气泡,避免拉取/侧栏刷新重试时反复弹错。 // 仅首次加载(state.conversations 为空)时推气泡,避免拉取/侧栏刷新重试时反复弹错。
console.error('[AI] 加载会话列表失败:', e) console.error('[AI] 加载会话列表失败:', e)
@@ -126,7 +126,7 @@ async function newConversation() {
} }
} }
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1) // 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配
let _latestSwitchId = 0 let _latestSwitchId = 0
// 最近一次切换的切换窗口缓冲(成功提交路径恢复续显用;失败/过期路径丢弃, // 最近一次切换的切换窗口缓冲(成功提交路径恢复续显用;失败/过期路径丢弃,
// 防陈旧文本在后续切换被误恢复造成内容重复)。由 switchConversation 的 finally 统一填充。 // 防陈旧文本在后续切换被误恢复造成内容重复)。由 switchConversation 的 finally 统一填充。
@@ -169,7 +169,7 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
} }
} }
return rawMsgs return rawMsgs
// 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated, // 过滤 tool 消息 + truncated 软删消息编辑某条 user 后其后消息标 truncated,
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤) // 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated') .filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
.map((m: any, i: number) => ({ .map((m: any, i: number) => ({
@@ -177,7 +177,7 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
role: m.role, role: m.role,
content: m.content || '', content: m.content || '',
model: m.model, model: m.model,
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now() // 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now()
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(), timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
// 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens // 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供 // (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
@@ -350,7 +350,7 @@ export async function switchConversation(id: string, force = false) {
state.messages = parseConvMessages(rawMsgs, 'loaded') state.messages = parseConvMessages(rawMsgs, 'loaded')
// parse 成功才置 active(保证 active 与 messages 一致) // parse 成功才置 active(保证 active 与 messages 一致)
state.activeConversationId = id state.activeConversationId = id
// CSW-P1-4: 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位 // 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
// 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中, // 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中,
// flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText, // flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText,
// 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。 // 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。
@@ -410,7 +410,7 @@ export async function switchConversation(id: string, force = false) {
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批, // 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。 // 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。 // 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
// F2-1(AC-EFF-F2-1):纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息 // F2-1纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
// 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。 // 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0) const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
if (!targetGen && !hasToolCalls) { if (!targetGen && !hasToolCalls) {
+8 -8
View File
@@ -19,7 +19,7 @@ import type { AiMessage, AiProviderConfig } from '@/api/types'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
/** 侧栏宽度可拖拽范围(UX-2025-16)。120=窄屏友好,280=宽屏舒适 */ /** 侧栏宽度可拖拽范围。120=窄屏友好,280=宽屏舒适 */
export const SIDEBAR_WIDTH_MIN = 120 export const SIDEBAR_WIDTH_MIN = 120
export const SIDEBAR_WIDTH_MAX = 280 export const SIDEBAR_WIDTH_MAX = 280
export const SIDEBAR_WIDTH_DEFAULT = 200 export const SIDEBAR_WIDTH_DEFAULT = 200
@@ -44,12 +44,12 @@ function applyUiState(s: any) {
if (s.foldedGroups && typeof s.foldedGroups === 'object') { if (s.foldedGroups && typeof s.foldedGroups === 'object') {
state.foldedGroups = { ...state.foldedGroups, ...s.foldedGroups } state.foldedGroups = { ...state.foldedGroups, ...s.foldedGroups }
} }
// 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底) // 侧栏宽度数值字段,clamp 后写入(与上方 boolean 同级兜底)
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth) state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
} }
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局 + activeProvider(模块级单例 state) // 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局 + activeProvider(模块级单例 state)
// F-260616-10: activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验 // activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验
// DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。 // DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
function restoreUiState() { function restoreUiState() {
applyUiState(appSettings.get<any>('df-ai-ui', null)) applyUiState(appSettings.get<any>('df-ai-ui', null))
@@ -79,7 +79,7 @@ export function persistUiState() {
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
archivedCollapsed: state.archivedCollapsed, archivedCollapsed: state.archivedCollapsed,
foldedGroups: state.foldedGroups, foldedGroups: state.foldedGroups,
// 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘 // 侧栏宽度拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
sidebarWidth: state.sidebarWidth, sidebarWidth: state.sidebarWidth,
}) })
} }
@@ -141,8 +141,8 @@ function cyclePanelMode() {
async function loadProviders() { async function loadProviders() {
const list = await aiApi.listProviders() const list = await aiApi.listProviders()
state.providers = list state.providers = list
// F-260616-10: 重启后 activeProvider=null,从 DB is_default 恢复(后端链路正确,仅前端缺同步) // 重启后 activeProvider=null,从 DB is_default 恢复(后端链路正确,仅前端缺同步)
// CR-34-1: 守卫加有效性检查 — savedProvider 指向已删除 provider 时残留无效 id // 守卫加有效性检查 — savedProvider 指向已删除 provider 时残留无效 id
if (!state.activeProvider || !list.some((p: AiProviderConfig) => p.id === state.activeProvider)) { if (!state.activeProvider || !list.some((p: AiProviderConfig) => p.id === state.activeProvider)) {
const def = list.find((p: AiProviderConfig) => p.is_default) const def = list.find((p: AiProviderConfig) => p.is_default)
if (def) state.activeProvider = def.id if (def) state.activeProvider = def.id
@@ -162,7 +162,7 @@ async function loadSkills() {
async function setProvider(providerId: string) { async function setProvider(providerId: string) {
await aiApi.setProvider(providerId) await aiApi.setProvider(providerId)
state.activeProvider = providerId state.activeProvider = providerId
// F-260616-10: 持久化到 appSettings,重启后恢复(对齐 df-ai-active-conv 模式) // 持久化到 appSettings,重启后恢复(对齐 df-ai-active-conv 模式)
void appSettings.set('df-ai-active-provider', providerId) void appSettings.set('df-ai-active-provider', providerId)
} }
@@ -201,7 +201,7 @@ export function useAiPanel() {
loadSkills, loadSkills,
setProvider, setProvider,
clearChat, clearChat,
// UX-2025-16:侧栏宽度拖拽 // 侧栏宽度拖拽
setSidebarWidth, setSidebarWidth,
persistUiState, persistUiState,
} }
+5 -5
View File
@@ -29,7 +29,7 @@ import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTime
/// 待发送队列上限(超过抛错提示用户) /// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10 const QUEUE_LIMIT = 10
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02) /// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等
const QUEUE_TIMEOUT_MS = 30_000 const QUEUE_TIMEOUT_MS = 30_000
/** /**
@@ -134,7 +134,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
} }
/** /**
* AI (UX-02) * AI
* *
* doSend : * doSend :
* - push user ( user ) * - push user ( user )
@@ -190,7 +190,7 @@ async function regenerate() {
} }
/** /**
* user (UX-09) * user
* *
* regenerate : * regenerate :
* - regenerate:末条须是 assistant,pop AI * - regenerate:末条须是 assistant,pop AI
@@ -497,7 +497,7 @@ async function sendQueuedNow(index: number) {
/** streaming /** streaming
* AiCompleted clear(useAiEvents) AiCompleted streaming true * AiCompleted clear(useAiEvents) AiCompleted streaming true
* *
* UX-260617-19: stop IPC streaming=false stop * stop IPC streaming=false stop
* delta/Completed * delta/Completed
* hang (stop_flag watchdog ) * hang (stop_flag watchdog )
* stop clearStreamWatchdog delta * stop clearStreamWatchdog delta
@@ -551,7 +551,7 @@ export function useAiSend() {
} }
} }
// ARC-06: 模块级监听 ai-drain-queue 事件(由 useAiEvents AiCompleted case 发射) // 模块级监听 ai-drain-queue 事件(由 useAiEvents AiCompleted case 发射)
// 打破 useAiEvents ↔ useAiSend 循环依赖:events 不再直接 import 调用 drainQueue // 打破 useAiEvents ↔ useAiSend 循环依赖:events 不再直接 import 调用 drainQueue
let _drainUnlisten: (() => void) | null = null let _drainUnlisten: (() => void) | null = null
export async function initDrainQueueListener(): Promise<void> { export async function initDrainQueueListener(): Promise<void> {
+8 -8
View File
@@ -53,7 +53,7 @@ async function detachPanel(convId?: string) {
return return
} }
// 快照当前生成态,供分离窗口接管(保持正在进行的对话) // 快照当前生成态,供分离窗口接管(保持正在进行的对话)
// F-09:用 per-conv key 写入(多会话各自快照互不覆盖) // 用 per-conv key 写入(多会话各自快照互不覆盖)
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。 // 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。 // 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
if (state.streaming && targetConv) { if (state.streaming && targetConv) {
@@ -67,7 +67,7 @@ async function detachPanel(convId?: string) {
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context, // 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。 // stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。 // 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
// F-09:仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。 // 仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清, // watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。 // 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' }) setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' })
@@ -77,7 +77,7 @@ async function detachPanel(convId?: string) {
const win = new WebviewWindow(label, { const win = new WebviewWindow(label, {
url, url,
title: 'DevFlow AI', title: 'DevFlow AI',
width: 600, // U-260618: 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致 width: 600, // 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致
height: 700, height: 700,
minWidth: 360, minWidth: 360,
minHeight: 400, minHeight: 400,
@@ -109,7 +109,7 @@ async function detachPanel(convId?: string) {
} }
/** () /** ()
* F-09:多会话并发下可能存在多个 detached , per-conv */ * detached , per-conv */
async function reattachPanel() { async function reattachPanel() {
stopFollowMain() stopFollowMain()
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
@@ -128,7 +128,7 @@ async function reattachPanel() {
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步, // 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。 // onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
stopListener() stopListener()
// F-09:清所有 per-conv 快照(分离窗口已全关,快照无消费方) // 清所有 per-conv 快照(分离窗口已全关,快照无消费方)
cleanupAllDetachedSnapshots() cleanupAllDetachedSnapshots()
} }
@@ -230,12 +230,12 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
async function resumeInDetached(convId: string | null) { async function resumeInDetached(convId: string | null) {
// R5: 不自动 switchConversation —— 分离窗口可看历史列表,但不主动覆盖主窗口 activeConversationId // R5: 不自动 switchConversation —— 分离窗口可看历史列表,但不主动覆盖主窗口 activeConversationId
// (避免主窗口期间发消息落错会话)。若需切换会话,由用户在分离窗口显式操作触发。 // (避免主窗口期间发消息落错会话)。若需切换会话,由用户在分离窗口显式操作触发。
// F-09: 透传 convId 给 restoreGeneratingState,定位正确的 per-conv 快照 key。 // 透传 convId 给 restoreGeneratingState,定位正确的 per-conv 快照 key。
await restoreGeneratingState({ convId: convId || undefined }) await restoreGeneratingState({ convId: convId || undefined })
} }
/** () /** ()
* F-09:从当前 webview label convId, per-conv */ * webview label convId, per-conv */
async function closeDetachedWindow() { async function closeDetachedWindow() {
state.docked = false state.docked = false
stopFollowMain() stopFollowMain()
@@ -275,7 +275,7 @@ async function dockDetached() {
} }
} }
/** 吸附态 AI 窗口宽度/间隙常量(U-260618) */ /** 吸附态 AI 窗口宽度/间隙常量*/
const AI_DOCK_WIDTH = 600 const AI_DOCK_WIDTH = 600
const AI_DOCK_GAP = 4 const AI_DOCK_GAP = 4
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调 // 防 syncToMain 改主窗口 size 触发 onResized 递归回调
+9 -9
View File
@@ -26,9 +26,9 @@ export interface StreamBlock {
} }
export interface UseStreamRendererOptions { export interface UseStreamRendererOptions {
/** UX-2025-01:保存当前文字选区(v-html 替换前调) */ /** 保存当前文字选区(v-html 替换前调) */
saveSelection: () => void saveSelection: () => void
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */ /** 恢复之前保存的选区(DOM 重建后调) */
restoreSelection: () => void restoreSelection: () => void
/** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */ /** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
isFollowingBottom: Ref<boolean> | ComputedRef<boolean> isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
@@ -59,7 +59,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
const _blockCache = new Map<string, string>() const _blockCache = new Map<string, string>()
const BLOCK_CACHE_LIMIT = 300 const BLOCK_CACHE_LIMIT = 300
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧) // 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01) // 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持
const streamingBlocks = ref<StreamBlock[]>([]) const streamingBlocks = ref<StreamBlock[]>([])
let rafId: number | null = null let rafId: number | null = null
let lastStreamText = '' let lastStreamText = ''
@@ -82,7 +82,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// (跨双换行不切),非代码段按双换行切段。 /// (跨双换行不切),非代码段按双换行切段。
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。 /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
/// ///
/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则 /// 旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则
/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错, /// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错,
/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏 /// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏
/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏), /// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏),
@@ -113,7 +113,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token /// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
function parseBlockNoCache(block: string): string { function parseBlockNoCache(block: string): string {
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。 // _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
// UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/ // 防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
// getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的 // getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
// renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse() // renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
// 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback, // 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
@@ -187,7 +187,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted // 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。 // 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
const degrade = isTail && isUnclosedCodeFence(b) const degrade = isTail && isUnclosedCodeFence(b)
// AC-EFF-S1-1:单块回复病态(整回复无 \n\n/围栏边界,末块=全文)超阈值 → 降级纯文本, // 单块回复病态(整回复无 \n\n/围栏边界,末块=全文)超阈值 → 降级纯文本,
// 防每帧 marked.lexer+parse O(n) 卡顿(流式期间临时纯文本,AiCompleted 后完整 markdown)。 // 防每帧 marked.lexer+parse O(n) 卡顿(流式期间临时纯文本,AiCompleted 后完整 markdown)。
const longSingleBlock = isTail && n === 1 && b.length > SINGLE_BLOCK_TEXT_LIMIT const longSingleBlock = isTail && n === 1 && b.length > SINGLE_BLOCK_TEXT_LIMIT
const html = (degrade || longSingleBlock) const html = (degrade || longSingleBlock)
@@ -226,7 +226,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
if (rafId !== null) return if (rafId !== null) return
rafId = requestAnimationFrame(() => { rafId = requestAnimationFrame(() => {
rafId = null rafId = null
// UX-2025-01: 末块 innerHTML 每帧替换,保存选区 // 末块 innerHTML 每帧替换,保存选区
opts.saveSelection() opts.saveSelection()
streamingBlocks.value = renderStreamingBlocks(lastStreamText) streamingBlocks.value = renderStreamingBlocks(lastStreamText)
nextTick(() => { nextTick(() => {
@@ -245,7 +245,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
streamingBlocks.value = [] streamingBlocks.value = []
lastStreamText = '' lastStreamText = ''
// AC-EFF-S1-1:清增量基线(防新轮/新会话误命中旧帧已完成块)。 // 清增量基线(防新轮/新会话误命中旧帧已完成块)。
_prevStreamText = '' _prevStreamText = ''
_prevCompleted = [] _prevCompleted = []
_prevTail = '' _prevTail = ''
@@ -259,7 +259,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用, /// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。 /// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
/// AC-EFF-S1-1:同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。 /// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
function resetStreamCache(): void { function resetStreamCache(): void {
_blockCache.clear() _blockCache.clear()
_prevStreamText = '' _prevStreamText = ''

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