优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口

- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰)
- 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch
- TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重
- 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible
- UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认)
- 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化
- 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs
- 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传
- crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
This commit is contained in:
lxy
2026-10-10 08:09:45 +08:00
parent 8df4e33656
commit 39e0eac39d
67 changed files with 1344 additions and 364 deletions
+384 -54
View File
@@ -33,9 +33,12 @@
//! (父侧 ai_chat_stop / ai_chat_force_send / ai_conversation_delete 经 SubAgentRegistry 级联)。
//!
//! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常
//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此):
//! 运行期展示类事件(轮次/流式文本/工具生命周期/命令输出)经 `route_scope` 把子会话
//! conv 改挂父会话再推送,父会话视图能看到子代理实时进展(治「切回父会话 spawn 卡停在旧态」
//! 的幽灵态);复用既有 emit 双写出口,不另开旁路。仅结束时挂父会话 emit 一条 AiAgentFinished
//! (前端展示二期消费);不 emit AiCompleted / AiMaxRounds 终态事件、不 spawn 标题/知识提炼。
//! 非展示类事件(Fatal 的 AiError、MidStream 的 AiConvStateChanged 等)不改写,维持子会话
//! 路由——消费方按「未知 conversation_id 丢弃」自我过滤,完整残留清单记录于此:
//! - Fatal 分支会向子 conv emit AiError;
//! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用);
//! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true,
@@ -45,11 +48,11 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::AppHandle;
use tauri::{AppHandle, State};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::{ContextManager, TokenEstimator};
use df_ai::context::{ContextConfig, ContextManager, TokenEstimator};
use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED;
use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition};
use df_ai::router::{Modality, TaskRequirements};
@@ -63,13 +66,14 @@ use crate::commands::ai::agentic::{
};
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{
save_conversation_meta, truncate_for_persist, SaveMetaOverride, TokenAccumulator,
save_conversation_meta, SaveMetaOverride, TokenAccumulator, TRUNCATE_THRESHOLD,
};
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::event_bus::{emit_ai_chat_event, route_scope};
use crate::commands::ai::{AiChatEvent, ToolCallDraft};
use crate::state::LlmConcurrency;
use crate::state::{AppState, LlmConcurrency};
use super::guard::GeneratingGuard;
use super::context_lifecycle::maybe_auto_compress;
use crate::commands::ai::provider_pool::ProviderPool;
use super::router_setup::resolve_model_with_override;
use super::{
@@ -109,10 +113,34 @@ pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
/// 自达统一出口。
pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65;
/// 子代理上下文窗口预算上限(token)。自动压缩触发阈值与 build_for_request 裁剪预算
/// 的数据源,口径同 [`ContextConfig::max_tokens`]。默认 128_000 对齐 ContextConfig::default
/// ——接入压缩层不改动既有预算(零行为变更);值单点集中于此,后续按需分档只改本常量。
const SUB_AGENT_CONTEXT_MAX_TOKENS: u32 = 128_000;
/// 子代理会话的上下文配置(压缩/裁剪预算来源)。仅覆写 max_tokens 为子代理常量,
/// 输出预留与安全系数沿用 ContextConfig::default,与主会话同一压缩链路口径一致。
fn sub_agent_context_config() -> ContextConfig {
ContextConfig {
max_tokens: SUB_AGENT_CONTEXT_MAX_TOKENS,
..ContextConfig::default()
}
}
/// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长
/// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。
const DIAG_TEXT_MAX_CHARS: usize = 400;
/// 任务书钉扎副本的长度上限(字符)。钉扎进 system prompt 的副本是循环不变量(不受
/// build_for_request 预算裁剪),超长任务书会在每轮 system prompt 里永续常驻占 token;
/// 首条 user 消息已带完整任务书,钉扎副本只需够覆盖目标/完成标准/约束,超限截尾留标注。
const SUB_AGENT_TASK_PIN_MAX_CHARS: usize = 4_000;
/// 子代理报告交付父会话前的超长截断头/尾保留字符数(对齐对话侧落库截断 HEAD/TAIL 量级,
/// 保证头尾语义一致:结论在头、末尾未尽事项在尾)。阈值本身复用 TRUNCATE_THRESHOLD。
const SUB_AGENT_REPORT_HEAD: usize = 3_072;
const SUB_AGENT_REPORT_TAIL: usize = 3_072;
// ============================================================
// 结果类型
// ============================================================
@@ -185,6 +213,10 @@ pub(crate) struct SubAgentOutcome {
pub(crate) rejected_tools: Vec<(String, u32)>,
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
pub(crate) stop_kind: Option<SubAgentStopKind>,
/// 实际使用的模型 id(candidate 切换/池空回退后的最终值,非请求值)。
/// 请求 override 落空回退到别的模型时,父会话据此知道子代理「实际用什么跑的」;
/// 启动期失败(未解析出模型)为空串。
pub(crate) model: String,
}
impl SubAgentOutcome {
@@ -197,6 +229,7 @@ impl SubAgentOutcome {
tokens: 0,
rejected_tools: Vec::new(),
stop_kind: Some(SubAgentStopKind::Error),
model: String::new(),
}
}
}
@@ -424,6 +457,50 @@ pub(crate) fn cap_diag_text(s: &str, max_chars: usize) -> String {
out
}
/// 任务书钉扎副本的长度收敛(治「spawn task 全文永续无瘦身」):超上限时保留头部
/// (目标/完成标准通常在任务书前部)并附省略标注,完整任务书仍在首条 user 消息里
/// (钉扎只为把目标锚定在不受裁剪的 system 段,无需常驻全文)。纯函数可单测。
fn cap_pinned_task(task: &str) -> String {
if task.chars().count() <= SUB_AGENT_TASK_PIN_MAX_CHARS {
return task.to_string();
}
let head: String = task.chars().take(SUB_AGENT_TASK_PIN_MAX_CHARS).collect();
format!("{}\n[…任务书过长,钉扎副本已截断;完整任务书见首条消息]…", head)
}
/// 子代理报告交付父会话前的超长收敛(阈值/头尾语义与对话落库截断一致,省略文案按子代理
/// 语境改写)。通用 truncate_for_persist 的「完整内容仅在内存态可读」是面向本地 DB 持久化的
/// 措辞,而子代理报告是交付给父 LLM 的返回值——须明示完整报告随子代理轨迹落库,
/// 父侧不误以为拿不到全文。纯函数可单测。
fn cap_report_for_parent(content: &str) -> String {
let chars: Vec<char> = content.chars().collect();
if chars.len() <= TRUNCATE_THRESHOLD {
return content.to_string();
}
let head: String = chars.iter().take(SUB_AGENT_REPORT_HEAD).collect();
let tail: String = chars[chars.len() - SUB_AGENT_REPORT_TAIL..].iter().collect();
let omitted = chars.len() - SUB_AGENT_REPORT_HEAD - SUB_AGENT_REPORT_TAIL;
format!(
"{}\n\n[...报告过长,已省略中间 {} 字符(头尾保留);完整报告随子代理轨迹落库...]\n\n{}",
head, omitted, tail
)
}
/// 把子代理采样温度注入 provider 的 config JSON(供应商级温度覆盖的数据源:stream_relay
/// 读 candidate.config 的 "temperature" 字段决定请求 temperature)。保留 config 内既有字段,
/// 仅覆写 temperature;config 缺失/非法时新建仅含 temperature 的对象。钳到 [0,2],防越界值打挂 provider。
fn inject_temperature(provider: &mut df_storage::models::AiProviderRecord, temperature: f32) {
let t = temperature.clamp(0.0, 2.0) as f64;
let mut obj = provider
.config
.as_deref()
.and_then(|cfg| serde_json::from_str::<serde_json::Value>(cfg).ok())
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
obj.insert("temperature".to_string(), serde_json::json!(t));
provider.config = Some(serde_json::Value::Object(obj).to_string());
}
/// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
@@ -462,30 +539,60 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
/// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏,
/// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。
/// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。
/// `lang` 为父会话语言(agent_language,缺省中文):决定本段与工具纪律段语言;
/// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入(子代理同样无法自行获取日期)。
/// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。
pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
let mut p = format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
);
pub(crate) fn sub_agent_system_prompt(max_rounds: u32, lang: &str) -> String {
// lang 只决定本段与工具纪律段语言;派工纪律段(DISPATCH_DISCIPLINE)是跨端共享母本、
// 文本单份(见 agentic/mod.rs),不随 lang 分叉。
let body = if lang == "en" {
format!(
"You are a sub-agent spawned by the DevFlow main session, running in an isolated context to carry out the parent's task brief.\n\
## Role\n\
- Execute for the parent task only; the user cannot see your process and cannot interact with or approve you\n\
- Use tools for exploration/retrieval/verification work; put conclusions in your final reply\n\
- You have no approval channel: path-authorization requests and medium/high-risk tools are denied outright with a failure result;\n\
for a denied operation, switch to an authorized way or state the gap in your report\n\
- This task's budget (actual values): up to {max_rounds} rounds / cumulative token cap {max_tokens} / wall-clock cap {wall_secs}s (15 min). Stop when converged, do no exploration beyond the brief;\n\
when the budget is nearly exhausted (last round) output the conclusion report directly, leave no exploration unfinished\n\
## Output requirements\n\
- Your final reply IS the conclusion report delivered to the parent; no pleasantries, do not ask the user questions\n\
- Report structure: conclusion first, then key findings/evidence/data, finally open items and recommendations\n\
- Preserve file paths, identifiers and error messages verbatim in the report (the parent locates by them; do not rewrite or summarize)\n\
- If any tool call was denied: state in open items which subtasks were left incomplete due to tool-permission limits, so the parent decides how to take over",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
)
} else {
format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 报告中的文件路径、标识符、错误信息照原样保留(父会话据此定位,勿改写或概括)\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
)
};
// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入:日期 LLM 无法自行获取,
// 不注入则子代理写文件/报告里的日期全靠猜。
let mut p = crate::commands::ai::prompt::env_profile_line();
p.push_str(&body);
// 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh"));
// 内裁剪,不适用于子代理。lang 透传父会话语言,英文会话得英文纪律段。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section(lang));
// 派工纪律同源注入(N1):引用父模块母本常量,与主会话 build_runtime_prompt 共享同一文本
// (报告限长/结论先行/证据随附直接约束子代理输出形态,若再派工亦同规约束);
// 内置于二进制而非知识库,防新实例知识库为空时纪律失传。
@@ -517,6 +624,9 @@ pub(crate) async fn run_sub_agent_loop(
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
// 采样温度独立覆盖(P3):None=沿用 provider 配置/默认 0.7(默认不变,与主链路一致),
// Some(t)=本次子代理克隆候选注入 t,不影响主会话。
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency,
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
// provider/created_at。
@@ -526,7 +636,7 @@ pub(crate) async fn run_sub_agent_loop(
session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs, allowed,
max_rounds, model_override, llm_concurrency.clone(),
max_rounds, model_override, temperature_override, llm_concurrency.clone(),
).await;
// 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付)
@@ -600,6 +710,7 @@ async fn run_sub_agent_loop_inner(
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency,
) -> SubAgentOutcome {
// 挂钟 deadline(三重熔断之三)
@@ -611,9 +722,10 @@ async fn run_sub_agent_loop_inner(
// (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是
// 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留,
// 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。
// 钉扎副本经 cap_pinned_task 收敛长度:超长任务书只钉目标锚,防全文字符串每轮常驻占 token。
let system_prompt = format!(
"{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n<task>\n{}\n</task>\n",
system_prompt, task_prompt
system_prompt, cap_pinned_task(&task_prompt)
);
// 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed
@@ -644,6 +756,9 @@ async fn run_sub_agent_loop_inner(
session.deleted_convs.insert(child_conv_id.clone());
session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string());
let conv = session.conv(&child_conv_id);
// 上下文预算单点接入:子会话 ContextManager 用子代理配置(默认值与父会话一致),
// 供压缩触发阈值与 build_for_request 裁剪读取;新条目消息为空,直接替换无损。
conv.messages = ContextManager::new(sub_agent_context_config());
conv.messages.push(ChatMessage::user(&task_prompt));
conv.stop_flag.store(false, Ordering::SeqCst);
conv.iteration_used = 0;
@@ -655,7 +770,7 @@ async fn run_sub_agent_loop_inner(
GeneratingGuard::new(child_conv_id.clone(), child_epoch.clone(), my_epoch, app_handle.clone());
// ── provider 解析(30s 超时,对齐主 loop 入口;失败走 failed 返回值,不 emit AiError)──
let (primary_provider, candidates) = {
let (primary_provider, mut candidates) = {
let resolve = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let provider_repo = AiProviderRepo::new(&db);
let pool = match provider_repo.list_all().await {
@@ -695,6 +810,16 @@ async fn run_sub_agent_loop_inner(
let mut resolved_model = resolve_model_with_override(&req_probe, &primary_provider, &model_override);
let mut provider_config = primary_provider;
// 子代理采样温度独立覆盖(P3):默认 None 不注入(沿用 provider 配置或 0.7,行为不变)。
// Some(t) 时把 temperature 写进本次子代理的候选 config JSON——stream_relay 的供应商级
// 温度覆盖正从 candidate.config 读取,故只改克隆候选即可生效,不触碰主会话/DB 记录。
if let Some(temp) = temperature_override {
inject_temperature(&mut provider_config, temp);
for c in candidates.iter_mut() {
inject_temperature(c, temp);
}
}
// system_prompt 是整个子循环的不变量,token 估算一次缓存复用(对齐主 loop)
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
@@ -733,6 +858,35 @@ async fn run_sub_agent_loop_inner(
break;
}
// 子代理运行期进度可见(父会话视图):每轮起算前挂父会话发 AiAgentRound 建独立气泡,
// 本轮后续流式文本/工具事件经 conv 路由改挂父会话续写该气泡(见 route_scope)。
emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentRound {
round: round + 1,
conversation_id: Some(parent_conv_id.clone()),
});
// ── 自动压缩(智能裁剪):复用父会话同一压缩链路 ——
// 子代理长跑探索/多文件阅读同样累积上下文,缺此层只剩 build_for_request 硬裁剪、
// 无 LLM 摘要重锚定,主题随裁剪蒸发。provider 沿用本会话当前 candidate(与父 loop
// 同款);压缩后台化(fire-and-forget)不阻塞本轮,结果下一轮生效。返回 true =
// 子会话条目已消失,退出循环(对齐 prepare 段的 conv 缺失处理)。压缩过程产生的
// 展示类事件(AiCompressing/AiAutoCompressed)挂子会话,消费方按未知 conv 丢弃,
// 与模块头「非展示类事件不改写」残留清单一并说明。
if maybe_auto_compress(
&session_arc,
&child_conv_id,
&app_handle,
&provider_config,
&llm_concurrency,
round as usize,
sys_tokens,
).await {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
let (messages, estimated_prompt) = {
@@ -797,18 +951,23 @@ async fn run_sub_agent_loop_inner(
// 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨
// await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。
let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(stream_budget, stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config, &mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
// 子代理展示类事件改挂父会话:本轮流式(AiTextDelta/AiHeartbeat/AiStreamRetry 等)
// 经 route_scope 从子会话改挂父会话推送,父会话视图实时可见(终态/状态机事件不改写)。
match tokio::time::timeout(stream_budget, route_scope(
child_conv_id.clone(), parent_conv_id.clone(),
stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config, &mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
),
)).await {
Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
rounds_done += 1;
@@ -868,11 +1027,16 @@ async fn run_sub_agent_loop_inner(
.values()
.map(|d| (d.id.clone(), d.name.clone()))
.collect();
let pending = process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
// 工具事件(AiToolCallStarted/Completed/AiCommandOutput 等)同样改挂父会话,
// 让父会话视图看到子代理的工具执行实时状态;子会话自身消息写入不受影响。
let pending = route_scope(
child_conv_id.clone(), parent_conv_id.clone(),
process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
),
).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await;
@@ -951,8 +1115,8 @@ async fn run_sub_agent_loop_inner(
// ── 报告提取:收敛轮 full_text 优先;截断/失败取末条 assistant 文本附终止说明;再无则用原因 ──
let total_tokens = tokens_snapshot(&tokens).total_tokens as u64;
let summary = if !report.trim().is_empty() {
// 父侧注入前超长截断复用现有 truncate_for_persist(头尾保留语义一致)
truncate_for_persist(report.trim())
// 父侧注入前超长截断:用子代理语境文案(非通用落库文案),阈值/头尾语义一致
cap_report_for_parent(report.trim())
} else {
match last_assistant_text(&session_arc, &child_conv_id).await {
Some(text) => {
@@ -961,7 +1125,7 @@ async fn run_sub_agent_loop_inner(
} else {
format!("{}\n\n(子代理提前终止:{})", text, stop_reason)
};
truncate_for_persist(combined.trim())
cap_report_for_parent(combined.trim())
}
None if !stop_reason.is_empty() => format!("子代理未产出报告:{}", stop_reason),
None => "子代理未产出报告".to_string(),
@@ -979,6 +1143,7 @@ async fn run_sub_agent_loop_inner(
status = status.as_str(),
rounds = rounds_done,
tokens = total_tokens,
model = %resolved_model,
summary_len = summary.len(),
"[sub-agent] 子代理结束"
);
@@ -991,6 +1156,8 @@ async fn run_sub_agent_loop_inner(
tokens: total_tokens,
rejected_tools: rejected,
stop_kind,
// 实际生效模型(candidate 切换/回退后的最终值),父会话据此知晓子代理真用了哪个模型
model: resolved_model.clone(),
}
}
@@ -1088,6 +1255,99 @@ async fn collect_rejected_tools(
}
}
// ============================================================
// 完成卡「已消化」标记(KV 持久,app_settings)
// ============================================================
/// 完成卡「已消化」标记的 KV key 前缀:key = `agentCardDigested:{parentConvId}`,
/// 值 = 已标记 agentId 的 JSON 字符串数组。完成卡本身是派生态(由子代理轨迹重建),
/// 「是否已消化」是用户偏好,故复用 app_settings KV 而不落 SQLite 新表/新列;轨迹删除后
/// 孤儿 key 自然失效(重建路径只对轨迹存在的卡应用标记),无需清理任务。
pub(crate) const AGENT_CARD_DIGESTED_KEY_PREFIX: &str = "agentCardDigested:";
/// 组装某父会话的已消化标记 KV key。
pub(crate) fn digested_kv_key(parent_conv_id: &str) -> String {
format!("{}{}", AGENT_CARD_DIGESTED_KEY_PREFIX, parent_conv_id)
}
/// 解析 KV 原始串为已消化 agentId 列表。容错:缺省/非 JSON/非数组一律降级为空,
/// 数组内空串与非字符串项跳过;结果去重升序,保证确定性与幂等收口。纯函数可单测。
pub(crate) fn parse_digested_ids(raw: Option<&str>) -> Vec<String> {
let Some(s) = raw else {
return Vec::new();
};
// 结构不符(坏值/旧格式残留)一律空列表兜底,不向调用方抛错——标记是纯偏好,读不动即视为未标记
let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(s) else {
return Vec::new();
};
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for it in items {
if let Some(id) = it.as_str().filter(|id| !id.is_empty()) {
set.insert(id.to_string());
}
}
set.into_iter().collect()
}
/// 批量置位/清位已消化标记,返回新列表(去重升序)。空 id 忽略;幂等(重复置位/清位收敛)。
pub(crate) fn apply_digested(list: Vec<String>, agent_ids: &[String], digested: bool) -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = list.into_iter().collect();
for id in agent_ids.iter().filter(|id| !id.is_empty()) {
if digested {
set.insert(id.clone());
} else {
set.remove(id);
}
}
set.into_iter().collect()
}
/// 序列化已消化列表为稳定 KV 值(JSON 字符串数组;空列表序列化为 `[]`,便于清位后覆盖旧值)。
pub(crate) fn serialize_digested_ids(list: &[String]) -> String {
serde_json::to_string(list).unwrap_or_else(|_| "[]".to_string())
}
/// 读某父会话的已消化标记列表(完成卡重建路径读 KV 合并的数据源,只读无副作用)。
#[tauri::command]
pub async fn ai_sub_agent_digested_list(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<String>, String> {
let raw = state
.settings
.get(&digested_kv_key(&conversation_id))
.await
.map_err(crate::commands::err_str)?;
Ok(parse_digested_ids(raw.as_deref()))
}
/// 批量置位/清位完成卡「已消化」标记(digested=true 标记,false 取消),返回更新后的完整列表。
///
/// 为什么由后端收口读改写而非前端各自 settings KV 自读自写:同会话多卡批量标记并发时,
/// 前端 read-modify-write 会互相覆盖丢标记;键格式(agentCardDigested:{conv} → string[])
/// 亦单点约定,前端不散落拼 key。
#[tauri::command]
pub async fn ai_sub_agent_digest(
state: State<'_, AppState>,
conversation_id: String,
agent_ids: Vec<String>,
digested: bool,
) -> Result<Vec<String>, String> {
let key = digested_kv_key(&conversation_id);
let raw = state
.settings
.get(&key)
.await
.map_err(crate::commands::err_str)?;
let list = apply_digested(parse_digested_ids(raw.as_deref()), &agent_ids, digested);
state
.settings
.set(&key, &serialize_digested_ids(&list))
.await
.map_err(crate::commands::err_str)?;
Ok(list)
}
// ============================================================
// 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除
// ============================================================
@@ -1329,7 +1589,7 @@ mod tests {
/// 同源工具纪律段(P1-1)。
#[test]
fn sub_agent_prompt_mentions_report_contract() {
let p = sub_agent_system_prompt(6);
let p = sub_agent_system_prompt(6, "zh-CN");
assert!(p.contains("子代理"));
assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
@@ -1339,8 +1599,12 @@ mod tests {
assert!(p.contains(&SUB_AGENT_MAX_TOTAL_TOKENS.to_string()), "token 上限注入");
assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入");
assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p);
// 环境画像(日期)与父会话同源注入:子代理无法自行获取日期
assert!(p.contains("当前日期"), "环境画像日期须注入: {}", p);
// 报告约定:文件路径原样保留
assert!(p.contains("文件路径"), "报告约定须含文件路径原样保留: {}", p);
// 父设小预算时按真实值注入(防按 6 轮规划被截断)
assert!(sub_agent_system_prompt(3).contains("最多 3 轮"));
assert!(sub_agent_system_prompt(3, "zh-CN").contains("最多 3 轮"));
// P1-1 工具纪律段同源拼接
assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p);
assert!(p.contains("重复调用检测"), "重复调用检测须注入");
@@ -1348,6 +1612,37 @@ mod tests {
assert!(p.contains("get_app_config"), "查配置正解须注入");
assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入");
assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理");
// lang=en:主体与工具纪律段均转英文(派工纪律段母本单份仍中文,见函数文档)
let en = sub_agent_system_prompt(6, "en");
assert!(en.contains("sub-agent"), "英文主体: {}", en);
assert!(en.contains("no approval channel"));
assert!(en.contains(DUPLICATE_EN_MARKER), "英文纪律段须随 lang 透传");
assert!(!en.contains("你没有审批通道"));
}
/// 英文工具纪律段特征串(间接断言 lang="en" 已透传到 discipline 段)。
const DUPLICATE_EN_MARKER: &str = "Duplicate call detection";
/// 任务书钉扎长度收敛:上限内原样,超限截断并留标注(治全文字符串每轮常驻)。
#[test]
fn cap_pinned_task_bounds_length() {
let short = "统计 crates 下 TODO 数量";
assert_eq!(cap_pinned_task(short), short);
let long = "x".repeat(SUB_AGENT_TASK_PIN_MAX_CHARS + 500);
let capped = cap_pinned_task(&long);
assert!(capped.chars().count() < long.chars().count(), "超限须收敛");
assert!(capped.contains("完整任务书见首条消息"), "须留截断标注");
}
/// 子代理报告截断:阈值内原样,超阈截断并用子代理语境文案(不含落库语境的「内存态」)。
#[test]
fn cap_report_for_parent_uses_subagent_wording() {
let short = "结论:已完成";
assert_eq!(cap_report_for_parent(short), short);
let long = "y".repeat(TRUNCATE_THRESHOLD + 100);
let capped = cap_report_for_parent(&long);
assert!(capped.contains("随子代理轨迹落库"), "须用子代理语境文案");
assert!(!capped.contains("内存态"), "不得沿用父侧落库文案");
}
/// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。
@@ -1436,4 +1731,39 @@ mod tests {
vec!["write_file×3".to_string(), "run_command×1".to_string()]
);
}
/// 完成卡「已消化」标记:KV key 形态 + 容错解析(坏值/空串/非串项) + 批量置位清位幂等 + 序列化往返。
#[test]
fn digested_marker_kv_roundtrip_and_tolerance() {
assert_eq!(digested_kv_key("conv-1"), "agentCardDigested:conv-1");
// 容错解析:缺省/非 JSON/非数组 一律空;去重升序;跳过空串与非串项
assert!(parse_digested_ids(None).is_empty());
assert!(parse_digested_ids(Some("not json")).is_empty());
assert!(parse_digested_ids(Some("{}")).is_empty());
assert_eq!(
parse_digested_ids(Some(r#"["b","a","a","",1,null]"#)),
vec!["a".to_string(), "b".to_string()]
);
// 批量置位(空 id 忽略) + 幂等
let list = apply_digested(
vec![],
&["p::agent-2".to_string(), "p::agent-1".to_string(), String::new()],
true,
);
assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()]);
let list = apply_digested(list, &["p::agent-1".to_string()], true);
assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()], "重复置位幂等");
// 批量清位(含不存在 id,不 panic) + 幂等
let list = apply_digested(list, &["p::agent-1".to_string(), "ghost".to_string()], false);
assert_eq!(list, vec!["p::agent-2".to_string()]);
assert!(apply_digested(list, &["p::agent-2".to_string()], false).is_empty());
// 序列化 → 解析 往返;空列表序列化为 `[]`
let list = vec!["x".to_string(), "y".to_string()];
assert_eq!(parse_digested_ids(Some(&serialize_digested_ids(&list))), list);
assert_eq!(serialize_digested_ids(&[]), "[]");
}
}
+22 -5
View File
@@ -27,7 +27,7 @@ use crate::state::{AppState, log_slow_lock};
use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
use super::super::agentic::{resolve_model_with_override, run_agentic_loop, try_continue_agent_loop};
use super::super::event_bus::emit_ai_chat_event;
#[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS;
@@ -866,6 +866,17 @@ pub(crate) async fn spawn_agentic_session(
return Err("AI 正在生成中,请等待完成".to_string());
}
let conv = session.conv(&target);
// 939 ★③:按目标模型实际上下文窗口重设消息预算(仅在新建空会话生效,收口见 model_context)。
// 路由口径同主 loop pre-loop(needs_tool_use=true;estimated_context/tier 置零——预算只关心窗口,
// 与 loop 内按真实估计的重路由无关);override 穿透/兜底由 resolve_model_with_override 统一处理。
let budget_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai::router::Modality::Text],
needs_tool_use: true,
estimated_context: 0,
tier: None,
};
let budget_model = resolve_model_with_override(&budget_req, &provider_config, &model_override);
conv.apply_model_window(&budget_model, &provider_config.model_configs);
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
@@ -1256,7 +1267,7 @@ pub async fn ai_approve(
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
// 审计:拒绝(决策者=human)
audit_finalize(&state, &tool_call_id, "rejected", None).await;
audit_finalize(&state, &tool_call_id, "rejected", None, None).await;
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
// F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。
@@ -1315,6 +1326,8 @@ pub async fn ai_approve(
// 仅分支 run_workflow,其余工具审批路径完全不变(含 AE-04 trust_key_for 写 trust / audit 终态
// 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
// 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。
// V50 纯执行耗时:只测真执行窗口(run_workflow / execute_with_heartbeat),不含等待人工批准。
let exec_started = std::time::Instant::now();
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else {
@@ -1329,6 +1342,7 @@ pub async fn ai_approve(
&app, conv_id.as_deref().unwrap_or_default(), &id,
).await
};
let exec_ms = exec_started.elapsed().as_millis() as i64;
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
let (audit_status, result_val) = match &exec_result {
@@ -1336,7 +1350,7 @@ pub async fn ai_approve(
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
};
// 审计:人工审批后无论成败回填(决策者=human)
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
// AR-11(方案A):人工审批通过且工具执行成功(非 failed)后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
@@ -1607,7 +1621,7 @@ pub async fn ai_authorize_dir(
conversation_id: conv_id.clone(),
};
emit_ai_chat_event(&app, ev_approval);
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg), None).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
@@ -1657,6 +1671,8 @@ pub async fn ai_authorize_dir(
tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
// 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档,
// execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
// V50 纯执行耗时:只测真执行窗口,不含等待人工批准。
let exec_started = std::time::Instant::now();
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else {
@@ -1668,6 +1684,7 @@ pub async fn ai_authorize_dir(
&app, conv_id.as_deref().unwrap_or_default(), &tool_call_id,
).await
};
let exec_ms = exec_started.elapsed().as_millis() as i64;
tracing::debug!(
ok = exec_result.is_ok(),
status = if exec_result.is_ok() { "completed" } else { "failed" },
@@ -1685,7 +1702,7 @@ pub async fn ai_authorize_dir(
if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
result_val = append_cross_drive_note(result_val, &note);
}
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name);
// MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
@@ -348,6 +348,11 @@ pub(crate) async fn recover_parked_inner(
return Err("该会话正在生成中,无需恢复".to_string());
}
let message = PARKED_RECOVERY_MESSAGE.to_string();
// V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环,
// 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。
if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await {
tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败");
}
spawn_agentic_session(
app,
state,
@@ -1573,6 +1578,7 @@ mod tests {
origin: origin.map(|s| s.to_string()),
boundary_json: None,
loop_state: None,
status: "active".to_string(),
})
.await
.expect("测试会话插入应成功");
+17 -5
View File
@@ -546,6 +546,8 @@ async fn save_conversation_inner(
.filter(|s| !s.is_empty()),
// #10:懒创建继承 loop running(时序根治——spawn 先置标后落库的 UPDATE 0 行丢失)
loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(),
};
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -575,17 +577,27 @@ async fn save_conversation_inner(
.advance_persisted_count(total_len, needs_full_rewrite);
}
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
// 自动 checkpoint(每 20 条消息为一个快照点)
{
let total_tokens: i64 = persist_msgs.len() as i64;
if total_tokens > 0 && total_tokens % 20 == 0 {
let msg_count: i64 = persist_msgs.len() as i64;
if msg_count > 0 && msg_count % 20 == 0 {
let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default();
if !snapshot.is_empty() && snapshot.len() < 1_000_000 {
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), total_tokens);
// token_total 语义=真实 token 用量(逐条 prompt+completion 累加),非消息条数。
// 原误用 persist_msgs.len() 当 token,致统计差 3 个量级(p50=120 vs 数万级)。
let token_total: i64 = persist_msgs
.iter()
.map(|m| {
m.prompt_tokens.unwrap_or(0) as i64
+ m.completion_tokens.unwrap_or(0) as i64
})
.sum();
// ck_id 仍以消息条数锚定(同轮/同长度重存幂等跳过),与 token_total 语义解耦。
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), msg_count);
let ck_now = now_millis().to_string();
// 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL,
// 防 snapshot 含引号/特殊字符致注入或损坏。
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, total_tokens, &ck_now).await {
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, token_total, &ck_now).await {
tracing::warn!("checkpoint 写入失败 {conv_id}: {e}");
}
}
+4 -1
View File
@@ -260,7 +260,10 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
let started = Instant::now();
// ── 检测 obscura ──
let obscura_path = match find_obscura() {
// find_obscura 内部 std::thread + recv_timeout 阻塞探活(单候选最多 15s),须在阻塞池跑,
// 否则卡住单线程 runtime;join 失败(理论不发生)按"未装"降级。
let detected = tokio::task::spawn_blocking(find_obscura).await.ok().flatten();
let obscura_path = match detected {
Some(p) => p,
None => {
// 未装:回退静态 + hint。
+8 -10
View File
@@ -442,16 +442,14 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> {
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
let tmp = format!("{}.tmp-img", output_path);
tokio::fs::write(&tmp, data).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("写入图片失败: {}", e)
})?;
tokio::fs::rename(&tmp, output_path).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("原子替换失败: {}", e)
})?;
if let Err(e) = tokio::fs::write(&tmp, data).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入图片失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, output_path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e));
}
Ok(())
}
+8 -10
View File
@@ -451,16 +451,14 @@ pub(crate) async fn execute_http_request(args: Value, allowed: &AllowedDirs) ->
}
// 全量写(原子:tmp→rename,避免写一半崩溃留半成品,与 write_file 同思路)
let tmp = format!("{}.tmp-http", path);
tokio::fs::write(&tmp, &full_body).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("写入 output_file 失败: {}", e)
})?;
tokio::fs::rename(&tmp, &path).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("output_file 原子重命名失败: {}", e)
})?;
if let Err(e) = tokio::fs::write(&tmp, &full_body).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入 output_file 失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("output_file 原子重命名失败: {}", e));
}
return Ok(json!({
"method": method_str,
"url": final_url,