重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复

- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
2026-06-15 05:14:42 +08:00
parent 04032a2a8d
commit 2de0c6ecb7
37 changed files with 2457 additions and 484 deletions

View File

@@ -259,6 +259,33 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
}
// 流式生成中:置位让 loop 自行收尾
session.stop_flag.store(true, Ordering::SeqCst);
let conv_id = session.active_conversation_id.clone();
drop(session);
// B-260615-13 兜底任务loop 若 panic/异常退出漏发收尾stop_flag 无人读,
// 用户点 stop 无反应、generating 卡 true。这里 sleep 短超时后重检 generating
// 仍 true(loop 没复位)则强制复位 + emit AiCompleted 通知前端收尾。
// 正常路径(loop 活自行复位)此时 generating 已 false无操作退出。
let session_arc = state.ai_session.clone();
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let mut session = session_arc.lock().await;
if session.generating {
session.generating = false;
drop(session); // 释放锁后再 emit避免持锁调 runtime emit
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
conversation_id: conv_id,
},
);
}
});
Ok(())
}
@@ -281,11 +308,15 @@ fn mask_api_key(key: &str) -> String {
#[tauri::command]
pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> {
let mut providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?;
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
for p in &mut providers {
if !p.api_key.is_empty() {
p.api_key = mask_api_key(&p.api_key);
}
let real = if !p.api_key.is_empty() {
p.api_key.clone() // 未迁移(老明文)
} else {
super::secret::get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring
};
p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) };
}
Ok(providers)
}
@@ -319,20 +350,45 @@ pub async fn ai_save_provider(
.map_err(|e| e.to_string())?
.iter().any(|p| p.is_default),
};
// api_key 空(编辑不改 key,前端 list 拿到 mask 故留空)→保留原 DB 值(FR-S1)
let api_key = if api_key.is_empty() {
match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await
.map_err(|e| e.to_string())?
.map(|p| p.api_key)
.unwrap_or_default(),
None => String::new(),
// FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。
// api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。
let provider_id = id.clone().unwrap_or_else(new_id);
if !api_key.is_empty() {
// 显式改/填密钥 → 写 keyring(现状不变)
if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
return Err(format!("密钥保存到系统钥匙串失败: {}", e));
}
} else {
api_key
};
} else if let Some(pid) = &id {
// 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)。
// 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key,
// 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。
// 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态);
// 迁移失败则 Err 阻断保存且 INSERT OR REPLACE 不执行 → DB 明文保留,绝不劣化现状。
let old = state.ai_providers.get_by_id(pid).await
.map_err(|e| e.to_string())?;
if let Some(old) = old {
if !old.api_key.is_empty()
&& super::secret::get_provider_secret(pid).is_none()
{
// DB 有明文 且 keyring 无 → 即时迁移补密钥
if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) {
return Err(format!(
"检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\
已保留原密钥未改动——请检查系统钥匙串权限后再次保存。",
e
));
}
tracing::info!(
"[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)",
pid
);
}
// else: keyring 已有 / DB 已空 → 下方 INSERT OR REPLACE 清 DB 明文安全
}
}
let api_key = String::new(); // DB 恒空(真实密钥在 keyring)
let record = AiProviderRecord {
id: id.unwrap_or_else(new_id),
id: provider_id,
name,
provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type },
api_key,
@@ -391,6 +447,11 @@ pub async fn ai_delete_provider(
provider_id: String,
) -> Result<(), String> {
state.ai_providers.delete(&provider_id).await.map_err(|e| e.to_string())?;
// CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方,
// 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写)
if let Err(e) = super::secret::delete_provider_secret(&provider_id) {
tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e);
}
// 删除的若是当前默认,清空 active 指向,避免悬空
let mut session = state.ai_session.lock().await;
if session.active_provider_id.as_deref() == Some(&provider_id) {
@@ -405,17 +466,34 @@ pub async fn ai_delete_provider(
/// 创建新对话
#[tauri::command]
pub async fn ai_conversation_create(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
pub async fn ai_conversation_create(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
// 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。
// 首条消息发送后由 save_conversation upsert 写入。
let id = new_id();
let now = now_millis();
let mut session = state.ai_session.lock().await;
// 生成中(含审批等待态)禁止新建对话:否则 clear 会清空 pending_approvals,
// 用户审批时 ai_approve 找不到 pending → generating 永不复位 → 面板卡死需重启
// B-260615-10: 生成中软复位取代硬拦——强制结束当前生成,让用户能立即新建对话。
// 旧 loop 经 B-260615-11 一致性校验(active_conversation_id 变更)自动退出,不污染新对话;
// stop_flag 置位作双保险,让 streaming 中的旧 loop 也尽快收尾。
if session.generating {
return Err("生成中无法新建对话,请先停止或等待完成".to_string());
let old_conv = session.active_conversation_id.clone();
session.generating = false;
session.pending_approvals.clear();
session.stop_flag.store(true, Ordering::SeqCst);
drop(session);
if let Some(old_conv) = old_conv {
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
conversation_id: Some(old_conv),
});
}
session = state.ai_session.lock().await;
}
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now);