重构: commands.rs拆分chat域(strategy单批1-2文件)

- commands.rs → commands/mod.rs(1829→725行) + 新建 commands/chat.rs(1147行, 13 chat IPC + finalize_pending_placeholders + PendingToolCallInfo)
- re-export三级透传: chat::* → commands::* → ai::*(lib.rs invoke_handler 9处零改动 + 前端零改动)
- 签名/行为原样搬迁(F-09 batch4 conv_id 决策e保留);零调用方预留保留
strategy: 单批1-2文件防上下文溢出 + 原子(新文件→旧改→编译验证)
主代兜底: cargo check --workspace 0 + test 98 + grep 13IPC/re-export三级印证
杂项销账: B-260617-11 tauri.conf.json ["nsis"]已入库关闭(WATCH跨平台) + CR-07 batch4待审登记
This commit is contained in:
2026-06-19 03:15:03 +08:00
parent d899c58682
commit e6dbb14c5d
5 changed files with 931 additions and 717 deletions

View File

@@ -1,4 +1,14 @@
//! 所有 `#[tauri::command]` IPC 函数 — 由 mod.rs 重导出供 invoke_handler 引用
//! chat 域 IPC — 发送 / 重新生成 / 编辑 / 强制发送 / 停止 / 审批 / 上下文分段与压缩 / 循环控制
//!
//! 由原 `commands.rs`(单文件 1829 行 God 文件)按域拆分,本文件聚焦 chat 域 13 个 IPC +
//! chat 专用 helper(`finalize_pending_placeholders`)与 `PendingToolCallInfo`。
//! 其他域(provider/conversation/skills/config)仍留在 `commands/mod.rs`,后续批拆分。
//!
//! re-export:由 `commands/mod.rs` 经 `pub use self::chat::*;` 拉到 `commands::*`,
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//!
//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。
use std::sync::atomic::Ordering;
@@ -6,22 +16,20 @@ use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use df_ai::provider::{ChatMessage, ContentPart};
// df-ai 重导出 df_ai_core(供下游直接引用 trait/类型);src-tauri 不直接依赖 df-ai-core crate。
use df_ai::df_ai_core::model::ModelConfig;
use df_types::types::new_id;
use df_storage::models::AiProviderRecord;
use crate::state::AppState;
use crate::commands::{err_str, now_millis};
use super::agentic::{run_agentic_loop, try_continue_agent_loop};
use super::audit::{audit_finalize, emit_data_changed};
use super::conversation::save_conversation;
use super::knowledge_inject::build_knowledge_context;
use super::prompt::build_system_prompt;
use super::skills::{read_skill_content, SkillInfo, skills_cached};
// 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::audit::{audit_finalize, emit_data_changed};
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::build_knowledge_context;
use super::super::prompt::build_system_prompt;
use super::super::skills::read_skill_content;
use super::AiChatEvent;
use super::super::AiChatEvent;
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
/// "需要用户审批,等待确认")就地替换为终态文本。
@@ -40,7 +48,7 @@ use super::AiChatEvent;
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
fn finalize_pending_placeholders(session: &mut super::AiSession, conv_id: &str, final_text: &str) {
fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。
@@ -78,7 +86,7 @@ pub async fn ai_regenerate(
language: Option<String>,
model_override: Option<String>,
) -> Result<String, String> {
let provider_config = super::prompt::get_active_provider(&state).await?;
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
@@ -200,7 +208,7 @@ pub async fn ai_chat_send(
parts: Option<Vec<ContentPart>>,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::prompt::get_active_provider(&state).await?;
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
// F-260616-09 B 批4(决策 e 真并发上线):
@@ -274,7 +282,7 @@ pub async fn ai_chat_send(
let mut system_prompt = build_system_prompt(&state, &lang).await;
// 技能注入:读 SKILL.md 全文拼到 system prompt 前作为指令
// 隔离标注(FR-S4):用明确头尾标注包裹,标明"仅供 AI 参考、非用户消息、非系统指令"
// 防 SKILL.md 内 prompt injection 与用户指令/行为准则混淆。
// 防 SKILL.md 内 prompt injection 与用户指令/行为准则混淆。
if let Some(ref skill_name) = skill {
if let Some(content) = read_skill_content(skill_name) {
system_prompt = format!(
@@ -396,7 +404,7 @@ pub async fn ai_approve(
// 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。
// F-260616-09 B 批4:per_conv.session_trust 唯一真相源(conv_id 来源 approval.conversation_id)。
// 无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 executed)。
if let Some(key) = super::trust_key_for(&approval.tool_name, &approval.arguments) {
if let Some(key) = super::super::trust_key_for(&approval.tool_name, &approval.arguments) {
let inserted = if let Some(ref cid) = conv_id {
session.conv(cid).session_trust.insert(key.clone())
} else {
@@ -425,7 +433,7 @@ pub async fn ai_approve(
// 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
// 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::execute_run_workflow_for_tool(&app, &state, &args).await
super::super::execute_run_workflow_for_tool(&app, &state, &args).await
} else {
state.ai_tools.execute(&approval.tool_name, args.clone()).await
};
@@ -603,7 +611,7 @@ pub async fn ai_chat_compress_context(
conversation_id: String,
language: Option<String>,
) -> Result<(), String> {
let provider_config = super::prompt::get_active_provider(&state).await?;
let provider_config = super::super::prompt::get_active_provider(&state).await?;
const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -660,7 +668,7 @@ pub async fn ai_chat_compress_context(
// 拿 provider(照 ai_chat_send 的 build_provider_for 模式,密钥经 resolve_provider_secret 闭环,
// FR-S1 api_key 绝不进 payload/日志/错误信息)
let provider = match super::secret::build_provider_for(&provider_config) {
let provider = match super::super::secret::build_provider_for(&provider_config) {
Ok(p) => p,
Err(e) => {
let mut session = state.ai_session.lock().await;
@@ -668,14 +676,14 @@ pub async fn ai_chat_compress_context(
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::ErrorType::Auth),
error_type: Some(super::super::ErrorType::Auth),
conversation_id: Some(conv_id),
});
return Err(format!("压缩失败: {}", e));
}
};
let llm_concurrency = state.llm_concurrency.clone();
let summary = match super::compress::compress_via_llm(
let summary = match super::super::compress::compress_via_llm(
provider.as_ref(),
&provider_config,
active_msgs,
@@ -691,7 +699,7 @@ pub async fn ai_chat_compress_context(
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::ErrorType::Unknown),
error_type: Some(super::super::ErrorType::Unknown),
conversation_id: Some(conv_id),
});
return Err(format!("压缩失败: {}", e));
@@ -731,7 +739,7 @@ pub async fn ai_chat_edit(
language: Option<String>,
model_override: Option<String>,
) -> Result<String, String> {
let provider_config = super::prompt::get_active_provider(&state).await?;
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 替换末条 user content + truncate 其后
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
@@ -850,10 +858,10 @@ pub async fn ai_chat_force_send(
conversation_id: Option<String>,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不占用 generating)
let provider_config = super::prompt::get_active_provider(&state).await?;
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
//
// F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
@@ -1137,693 +1145,3 @@ pub async fn ai_stop_loop(
});
Ok("ok".to_string())
}
// ============================================================
// 提供商管理
// ============================================================
/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识
fn mask_api_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
if chars.len() <= 8 {
return "".repeat(chars.len());
}
let prefix: String = chars[..4].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{}••••{}", prefix, suffix)
}
/// 列出所有已配置的 AI 提供商is_default 真相源为 DB重启不丢
#[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(err_str)?;
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
for p in &mut providers {
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)
}
/// 保存/更新 AI 提供商配置
#[tauri::command]
pub async fn ai_save_provider(
state: State<'_, AppState>,
id: Option<String>,
name: String,
base_url: String,
api_key: String,
default_model: String,
provider_type: String,
model_configs: Vec<ModelConfig>,
) -> Result<String, String> {
// F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
// 编辑已有提供商时保留原 created_at避免被覆盖
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
// 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。
let existing = match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?,
None => None,
};
let created_at = existing.as_ref().map(|p| p.created_at.clone()).unwrap_or_else(now_millis);
// is_default编辑保留原值新建时若全表尚无默认则设为默认首个自动默认避免无默认可用
let is_default = match &existing {
Some(p) => p.is_default,
None => !state.ai_providers.list_all().await
.map_err(err_str)?
.iter().any(|p| p.is_default),
};
// F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库);
// 新建默认进池(enabled=true,weight=50)。
let (enabled, weight) = match &existing {
Some(p) => (p.enabled, p.weight),
None => (true, 50),
};
// 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 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(err_str)?;
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: provider_id,
name,
provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type },
api_key,
base_url,
default_model,
models: None,
model_configs,
is_default,
config: None,
created_at,
updated_at: now_millis(),
// F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。
enabled,
weight,
};
let id = record.id.clone();
state
.ai_providers
.insert(record)
.await
.map_err(err_str)?;
Ok(id)
}
/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。
///
/// 与 `ai_save_provider` 的区别:
/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥
/// 迁移 + INSERT OR REPLACE 全字段;前端 Settings「负载均衡池」开关/权重滑块仅需改这俩字段,
/// 不应重发整张表(尤其避免空 api_key 触发密钥迁移分支)。
/// - 本 IPC 仅 UPDATE enabled/weight(经 update_field 或 update_full),重建 caps 表。
///
/// 落库后立即重建 per_provider caps(set_provider_caps),保证开关/权重变更对 agentic loop
/// 即时生效(下条消息即按新配置 acquire)。caps 重建非强一致(软收敛:已持 permit 不回收)。
#[tauri::command]
pub async fn ai_update_provider_pool(
state: State<'_, AppState>,
provider_id: String,
enabled: bool,
weight: u32,
) -> Result<(), String> {
// 验证 provider 存在(防前端传错 id 静默无操作)
let mut record = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// weight 落库前 clamp 到 [0,100](对齐 crud update 的 weight.min(100),防越界)。
let weight = weight.min(100);
if record.enabled == enabled && record.weight == weight {
// 无变化:跳过 DB 写 + caps 重建(幂等,防前端重复点击触发不必要的 IO)。
return Ok(());
}
record.enabled = enabled;
record.weight = weight;
record.updated_at = now_millis();
// update_full 走 UPDATE 全字段(含 enabled/weight,波12已加);api_key 不变(DB 恒空)。
state
.ai_providers
.update_full(&record)
.await
.map_err(err_str)?;
// 重建 per_provider caps:本 provider 被禁用/weight=0 → 不入新表 → acquire_for_provider
// 对其返 None(无限流,但 provider_pool::select 已把它移出候选,实际不会被 acquire)。
// caps 重建逻辑收敛到 AppState::reload_provider_caps(单点真理,启动 + 变更共用)。
state.reload_provider_caps().await;
Ok(())
}
#[tauri::command]
pub async fn ai_set_provider(
state: State<'_, AppState>,
provider_id: String,
) -> Result<(), String> {
// 验证提供商存在
let provider = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// 互斥写 DB目标 is_default=true其余=false。仅写变化的记录。
let providers = state.ai_providers.list_all().await.map_err(err_str)?;
for p in &providers {
let should = p.id == provider_id;
if p.is_default != should {
let mut updated = p.clone();
updated.is_default = should;
updated.updated_at = now_millis();
state.ai_providers.update_full(&updated).await.map_err(err_str)?;
}
}
let mut session = state.ai_session.lock().await;
session.active_provider_id = Some(provider.id);
Ok(())
}
/// 删除 AI 提供商
#[tauri::command]
pub async fn ai_delete_provider(
state: State<'_, AppState>,
provider_id: String,
) -> Result<(), String> {
state.ai_providers.delete(&provider_id).await.map_err(err_str)?;
// 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) {
session.active_provider_id = None;
}
Ok(())
}
// ============================================================
// 模型列表拉取 + 单模型探测(F-01 阶段5 IPC)
// ============================================================
/// 将前端 provider_type 规范化为 fetch_and_probe 接受的类型。
///
/// Settings.vue 存的 provider_type 是 "openai_compat" / "anthropic"(对齐 build_provider 工厂),
/// 而 model_fetch::fetch_and_probe 分派用 "openai_compat" / "anthropic_compat"。
/// 两个工厂入口类型语义一致(anthropic 协议),仅命名不同,这里收敛归一。
fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
match provider_type {
"anthropic" => "anthropic_compat".to_string(),
other => other.to_string(),
}
}
/// 测试连接并拉取厂商模型列表(F-01 阶段5)
///
/// 流程:
/// 1. DB 取 AiProviderRecord(get_by_id)
/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB,
/// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>
///
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
#[tauri::command]
pub async fn ai_fetch_models(
state: State<'_, AppState>,
provider_id: String,
) -> Result<Vec<ModelConfig>, String> {
let provider = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息)
let api_key = df_storage::secret::resolve_provider_secret(&provider);
let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type);
let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key)
.await
.map_err(err_str)?;
// 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/
// context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。
// 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router
// 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。
// 新模型(旧池无同 model_id)用探测默认值。
let merged: Vec<ModelConfig> = probed
.iter()
.map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) {
Some(old) => ModelConfig {
weight: old.weight,
enabled: old.enabled,
label: old.label.clone(),
..c.clone()
},
None => c.clone(),
})
.collect();
// 写回 model_configs(更新 updated_at)
let mut updated = provider.clone();
updated.model_configs = merged.clone();
updated.updated_at = now_millis();
state
.ai_providers
.update_full(&updated)
.await
.map_err(err_str)?;
Ok(merged)
}
/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
///
/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。
/// 直接调 df_ai::model_probe::probe(&model_id),返回填充了 probe_source 的 ModelConfig。
#[tauri::command]
pub async fn ai_probe_model(
_state: State<'_, AppState>,
model_id: String,
) -> Result<ModelConfig, String> {
Ok(df_ai::model_probe::probe(&model_id))
}
// ============================================================
// 对话管理
// ============================================================
/// 创建新对话
#[tauri::command]
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();
// F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。
// 旧实现(B-260615-10)生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀
// 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv),
// 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。
//
// B-260618-06 保留:切换 active 前先持久化旧 active conv(防其内存 messages 因后续操作丢失)。
// 注意:旧 conv 若有后台 loop 在跑,loop 自身的 save_conversation 也会持久化,此处 save 幂等。
let old_conv = {
let session = state.ai_session.lock().await;
session.active_conversation_id.clone()
};
let old_has_msgs = {
let session = state.ai_session.lock().await;
old_conv.as_deref()
.and_then(|oc| session.conv_read(oc))
.map(|c| !c.messages.is_empty())
.unwrap_or(false)
};
if let Some(ref oc) = old_conv {
if old_has_msgs {
save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None).await;
}
}
let mut session = state.ai_session.lock().await;
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now);
// 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
// (switch 路径会 retain 清目标 conv 的,create 不动他人)。
let _ = session.conv(&id); // 惰性建立空 PerConvState(字段全默认值)
Ok(serde_json::json!({ "id": id }))
}
/// 列出对话(仅摘要,不含 messages 全文)
///
/// limit 默认 50 防数据膨胀include_archived 默认 false归档对话默认隐藏
#[tauri::command]
pub async fn ai_conversation_list(
state: State<'_, AppState>,
limit: Option<usize>,
include_archived: Option<bool>,
) -> Result<Vec<serde_json::Value>, String> {
let limit = limit.unwrap_or(50);
let include_archived = include_archived.unwrap_or(false);
let records = state.ai_conversations.list_all().await.map_err(err_str)?;
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit
let summaries: Vec<serde_json::Value> = records.iter()
.filter(|r| include_archived || !r.archived)
.take(limit)
.map(|r| {
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
let models: Vec<String> = r.models.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
serde_json::json!({
"id": r.id,
"title": r.title,
"provider_id": r.provider_id,
"model": r.model,
"models": models,
"archived": r.archived,
"pinned": r.pinned,
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"created_at": r.created_at,
"updated_at": r.updated_at,
})
}).collect();
Ok(summaries)
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command]
pub async fn ai_conversation_switch(
state: State<'_, AppState>,
app_handle: AppHandle,
conversation_id: String,
) -> Result<serde_json::Value, String> {
let record = state.ai_conversations.get_by_id(&conversation_id).await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
let messages: Vec<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
let messages_json = record.messages.clone();
let title = record.title.clone();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)
let need_title_regen = record.title.is_none();
let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。
// 旧实现:active conv 生成中 → 只读切换(不改 session 状态),因单例 messages 会被新 conv 覆盖。
// 决策 e:per_conv 已隔离,切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload。
// **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
// (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
session.active_conversation_id = Some(conversation_id.clone());
let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
if !already_live {
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
let conv = session.conv(&conversation_id);
conv.messages.restore_from_messages(messages);
conv.model_override = None;
conv.session_trust.clear();
conv.agent_language = None;
conv.iteration_used = 0;
conv.stop_flag.store(false, Ordering::SeqCst);
}
// 仅清空目标对话自身的 pending_approvals,保留其他对话的(防 init 重建的内存 HashMap 被清空,
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
// → ai_pending_tool_calls 查询 → ai_approve 落库)
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
drop(session);
// 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。
// ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。
if need_title_regen {
match super::prompt::get_active_provider(&state).await {
Ok(provider_config) => {
super::title::spawn_ensure_title(
&provider_config,
&state.db,
&conversation_id,
&app_handle,
&state.ai_session,
&state.llm_concurrency,
);
}
Err(e) => tracing::warn!(
"切入历史会话触发标题重生成跳过(无可用 provider, conv_id={}): {}",
conversation_id, e
),
}
}
Ok(serde_json::json!({
"id": record.id,
"title": title,
"messages": messages_json,
}))
}
/// 删除对话
#[tauri::command]
pub async fn ai_conversation_delete(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<(), String> {
state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?;
let mut session = state.ai_session.lock().await;
// 删除任意对话(含非活跃)都应清理其积压审批:pending_approvals 是单例 HashMap,
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
session.per_conv.remove(&conversation_id);
if session.active_conversation_id.as_deref() == Some(&conversation_id) {
session.active_conversation_id = None;
}
// F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。
// conv 已删=LlmConcurrency 该条目不再被 acquire(无 conv 则无 loop/标题/提炼/压缩针对它)。
// 已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
// 时机:conv 删除即清理(比"loop 结束 + 无 pending"更确定——conv 删了必无 pending,
// 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
state.llm_concurrency.release_conv(&conversation_id).await;
Ok(())
}
/// 重命名对话标题
#[tauri::command]
pub async fn ai_conversation_rename(
state: State<'_, AppState>,
conversation_id: String,
title: String,
) -> Result<(), String> {
let title = title.trim().to_string();
if title.is_empty() {
return Err("标题不能为空".to_string());
}
state.ai_conversations.update_field(&conversation_id, "title", &title)
.await.map_err(err_str)?;
Ok(())
}
/// 归档/取消归档对话(归档后在侧栏折叠分组展示)
#[tauri::command]
pub async fn ai_conversation_archive(
state: State<'_, AppState>,
conversation_id: String,
archived: bool,
) -> Result<(), String> {
state.ai_conversations
.set_archived(&conversation_id, archived)
.await
.map_err(err_str)?;
Ok(())
}
/// 置顶/取消置顶对话(UX-17:对话置顶)
///
/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。
/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。
#[tauri::command]
pub async fn ai_conversation_set_pinned(
state: State<'_, AppState>,
conversation_id: String,
pinned: bool,
) -> Result<(), String> {
state.ai_conversations
.set_pinned(&conversation_id, pinned)
.await
.map_err(err_str)?;
Ok(())
}
/// 导出对话为指定格式(UX-18:对话导出)
///
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出
/// (content 内已有的三反引号代码块围栏原样保留,不做二次转义)
/// - json: 完整 messages 数组(serde 序列化 ChatMessage 列表)
/// - txt: `user: ...` / `assistant: ...` 纯文本拼接,system/tool 附注
///
/// 最小化:仅渲染 user/assistant 文本;tool_calls/tool_results 略过(导出给人看的对话)。
/// 空对话(无 messages)→ 空字符串(对应格式空体)。
#[tauri::command]
pub async fn ai_conversation_export(
state: State<'_, AppState>,
conversation_id: String,
format: String,
) -> Result<String, String> {
// format 校验:非法值 Err(不 panic),防止 format! 注入或未处理分支
let fmt = format.as_str();
if !matches!(fmt, "markdown" | "json" | "txt") {
return Err(format!("不支持的导出格式: {}", format));
}
// 取落库对话(完整历史)
let record = state.ai_conversations.get_by_id(&conversation_id).await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
let messages: Vec<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
let body = match fmt {
"markdown" => {
// user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流)
let mut parts: Vec<String> = Vec::new();
for m in &messages {
let title = match m.role {
df_ai::provider::MessageRole::User => Some("## 用户"),
df_ai::provider::MessageRole::Assistant => Some("## 助手"),
df_ai::provider::MessageRole::System => Some("## 系统"),
df_ai::provider::MessageRole::Tool => Some("## 工具结果"),
};
if let Some(t) = title {
// content 原样输出,内部三反引号围栏保留(Markdown 嵌套代码块,渲染器原生支持)
parts.push(format!("{}\n\n{}", t, m.content));
}
}
parts.join("\n\n")
}
"json" => {
serde_json::to_string_pretty(&messages)
.map_err(|e| format!("序列化失败: {}", e))?
}
"txt" => {
let mut parts: Vec<String> = Vec::new();
for m in &messages {
let role_name = match m.role {
df_ai::provider::MessageRole::System => "system",
df_ai::provider::MessageRole::User => "user",
df_ai::provider::MessageRole::Assistant => "assistant",
df_ai::provider::MessageRole::Tool => "tool",
};
parts.push(format!("{}: {}", role_name, m.content));
}
parts.join("\n")
}
// 上方 matches! 已校验,理论不可达
_ => return Err(format!("不支持的导出格式: {}", format)),
};
Ok(body)
}
/// 列出本机 Claude 技能skills + commands + plugins 三类),供前端 `/` 联想
#[tauri::command]
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
// 命中进程内缓存,命中后仅 clone不重复扫盘
Ok(skills_cached().clone())
}
/// 设置 LLM 调用并发上限(运行时调整,立即生效)
///
/// 软收敛:缩并发时已持有旧 permit 的任务继续执行不受影响,待其释放后新限制完全生效。
/// None 表示该层不变(前端可单独调一层)。值下限为 1。
#[tauri::command]
pub async fn ai_set_concurrency_config(
state: State<'_, AppState>,
global_limit: Option<u32>,
per_conv_limit: Option<u32>,
) -> Result<(), String> {
// 下限 1,无上限;同时给 global 时约束 per-conv 不超过 global
if let Some(g) = global_limit {
state.llm_concurrency.set_global(g.max(1) as usize).await;
}
if let Some(p) = per_conv_limit {
let mut p = p.max(1);
if let Some(g) = global_limit {
p = p.min(g.max(1));
}
state.llm_concurrency.set_per_conv(p as usize).await;
}
Ok(())
}
/// 设置 Agentic 循环最大轮次(运行时调整,立即生效)
///
/// 与并发配置不同max_iterations 是 loop 入口 load 快照的值,热改后当前 loop 不受影响
/// (已锁定边界),下次发消息生效。范围双 clampcommand 端 1-50 + 前端 input min/max
/// 防越界输入致 loop 过早结束(值过小)或失控(值过大)。
#[tauri::command]
pub async fn ai_set_agent_max_iterations(
state: State<'_, AppState>,
value: u32,
) -> Result<(), String> {
// clamp 1-50下限防 agent 失能(一轮即截断无法调任何工具),
// 上限防失控烧 token50 轮足够覆盖复杂多步任务)
let clamped = value.clamp(1, 50) as usize;
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
Ok(())
}
/// 设置流式对话失败自动重试次数F-260616-07 / 决策 a1运行时调整下次发消息生效
///
/// 只重试流前失败Init Err未输出任何 token流中途失败MidStream Partial保文不重试。
/// 退避复用 retry::backoff_delay(1s→2s→4s+jitter) + is_status_retryable Fatal 分类 +
/// 30s 总预算(详见 agentic.rs 流前重试循环)。
/// 范围 clamp 0-100 表示不重试(直接报错),上限 10 防过度重试烧 token/拖慢体验。
/// 默认 3复用 retry.rs backoff_delay + 错误分类,详见 agentic.rs 重试循环)。
#[tauri::command]
pub async fn ai_set_agent_max_retries(
state: State<'_, AppState>,
value: u32,
) -> Result<(), String> {
let clamped = value.clamp(0, 10) as usize;
state.agent_max_retries.store(clamped, Ordering::SeqCst);
Ok(())
}

View File

@@ -0,0 +1,725 @@
//! 所有 `#[tauri::command]` IPC 函数 — 由 ai/mod.rs 重导出供 invoke_handler 引用
//!
//! 原单文件 commands.rs(1829 行 God 文件)按域拆分为 `commands/` 子目录:
//! - [`chat`] — chat 域 13 个 IPC(发送/重新生成/编辑/强制发送/停止/审批/上下文分段与压缩/循环控制)
//! + chat 专用 helper(`finalize_pending_placeholders`)+ `PendingToolCallInfo`
//! - 本文件(`mod.rs`)保留其余域 IPC:provider/conversation/skills/config + `mask_api_key` helper
//! (后续批按域继续拆分)。
//!
//! re-export 链:`commands/mod.rs` → `pub use self::chat::*` 把 chat IPC 拉到 `commands::*`
//! → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*`
//! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
// chat 域子模块 + glob 重导出(拉到 commands::* 经 ai/mod.rs 透传到 commands::ai::*)
pub mod chat;
#[allow(unused_imports)]
pub use self::chat::*;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, State};
use df_ai::provider::ChatMessage;
// df-ai 重导出 df_ai_core(供下游直接引用 trait/类型);src-tauri 不直接依赖 df-ai-core crate。
use df_ai::df_ai_core::model::ModelConfig;
use df_types::types::new_id;
use df_storage::models::AiProviderRecord;
use crate::state::AppState;
use crate::commands::{err_str, now_millis};
// chat 域专用符号(run_agentic_loop/audit_finalize/build_system_prompt/AiChatEvent/ContentPart/
// read_skill_content/skills_cached 等)已随 chat.rs 搬走,本文件剩余 provider/conversation/
// skills/config 域不再引用,故不 import。save_conversation 在 conversation 域仍用,保留。
use super::conversation::save_conversation;
use super::skills::{skills_cached, SkillInfo};
// ============================================================
// 提供商管理
// ============================================================
/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识
fn mask_api_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
if chars.len() <= 8 {
return "".repeat(chars.len());
}
let prefix: String = chars[..4].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{}••••{}", prefix, suffix)
}
/// 列出所有已配置的 AI 提供商is_default 真相源为 DB重启不丢
#[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(err_str)?;
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
for p in &mut providers {
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)
}
/// 保存/更新 AI 提供商配置
#[tauri::command]
pub async fn ai_save_provider(
state: State<'_, AppState>,
id: Option<String>,
name: String,
base_url: String,
api_key: String,
default_model: String,
provider_type: String,
model_configs: Vec<ModelConfig>,
) -> Result<String, String> {
// F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
// 编辑已有提供商时保留原 created_at避免被覆盖
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
// 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。
let existing = match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?,
None => None,
};
let created_at = existing.as_ref().map(|p| p.created_at.clone()).unwrap_or_else(now_millis);
// is_default编辑保留原值新建时若全表尚无默认则设为默认首个自动默认避免无默认可用
let is_default = match &existing {
Some(p) => p.is_default,
None => !state.ai_providers.list_all().await
.map_err(err_str)?
.iter().any(|p| p.is_default),
};
// F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库);
// 新建默认进池(enabled=true,weight=50)。
let (enabled, weight) = match &existing {
Some(p) => (p.enabled, p.weight),
None => (true, 50),
};
// 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 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(err_str)?;
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: provider_id,
name,
provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type },
api_key,
base_url,
default_model,
models: None,
model_configs,
is_default,
config: None,
created_at,
updated_at: now_millis(),
// F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。
enabled,
weight,
};
let id = record.id.clone();
state
.ai_providers
.insert(record)
.await
.map_err(err_str)?;
Ok(id)
}
/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。
///
/// 与 `ai_save_provider` 的区别:
/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥
/// 迁移 + INSERT OR REPLACE 全字段;前端 Settings「负载均衡池」开关/权重滑块仅需改这俩字段,
/// 不应重发整张表(尤其避免空 api_key 触发密钥迁移分支)。
/// - 本 IPC 仅 UPDATE enabled/weight(经 update_field 或 update_full),重建 caps 表。
///
/// 落库后立即重建 per_provider caps(set_provider_caps),保证开关/权重变更对 agentic loop
/// 即时生效(下条消息即按新配置 acquire)。caps 重建非强一致(软收敛:已持 permit 不回收)。
#[tauri::command]
pub async fn ai_update_provider_pool(
state: State<'_, AppState>,
provider_id: String,
enabled: bool,
weight: u32,
) -> Result<(), String> {
// 验证 provider 存在(防前端传错 id 静默无操作)
let mut record = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// weight 落库前 clamp 到 [0,100](对齐 crud update 的 weight.min(100),防越界)。
let weight = weight.min(100);
if record.enabled == enabled && record.weight == weight {
// 无变化:跳过 DB 写 + caps 重建(幂等,防前端重复点击触发不必要的 IO)。
return Ok(());
}
record.enabled = enabled;
record.weight = weight;
record.updated_at = now_millis();
// update_full 走 UPDATE 全字段(含 enabled/weight,波12已加);api_key 不变(DB 恒空)。
state
.ai_providers
.update_full(&record)
.await
.map_err(err_str)?;
// 重建 per_provider caps:本 provider 被禁用/weight=0 → 不入新表 → acquire_for_provider
// 对其返 None(无限流,但 provider_pool::select 已把它移出候选,实际不会被 acquire)。
// caps 重建逻辑收敛到 AppState::reload_provider_caps(单点真理,启动 + 变更共用)。
state.reload_provider_caps().await;
Ok(())
}
#[tauri::command]
pub async fn ai_set_provider(
state: State<'_, AppState>,
provider_id: String,
) -> Result<(), String> {
// 验证提供商存在
let provider = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// 互斥写 DB目标 is_default=true其余=false。仅写变化的记录。
let providers = state.ai_providers.list_all().await.map_err(err_str)?;
for p in &providers {
let should = p.id == provider_id;
if p.is_default != should {
let mut updated = p.clone();
updated.is_default = should;
updated.updated_at = now_millis();
state.ai_providers.update_full(&updated).await.map_err(err_str)?;
}
}
let mut session = state.ai_session.lock().await;
session.active_provider_id = Some(provider.id);
Ok(())
}
/// 删除 AI 提供商
#[tauri::command]
pub async fn ai_delete_provider(
state: State<'_, AppState>,
provider_id: String,
) -> Result<(), String> {
state.ai_providers.delete(&provider_id).await.map_err(err_str)?;
// 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) {
session.active_provider_id = None;
}
Ok(())
}
// ============================================================
// 模型列表拉取 + 单模型探测(F-01 阶段5 IPC)
// ============================================================
/// 将前端 provider_type 规范化为 fetch_and_probe 接受的类型。
///
/// Settings.vue 存的 provider_type 是 "openai_compat" / "anthropic"(对齐 build_provider 工厂),
/// 而 model_fetch::fetch_and_probe 分派用 "openai_compat" / "anthropic_compat"。
/// 两个工厂入口类型语义一致(anthropic 协议),仅命名不同,这里收敛归一。
fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
match provider_type {
"anthropic" => "anthropic_compat".to_string(),
other => other.to_string(),
}
}
/// 测试连接并拉取厂商模型列表(F-01 阶段5)
///
/// 流程:
/// 1. DB 取 AiProviderRecord(get_by_id)
/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB,
/// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>
///
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
#[tauri::command]
pub async fn ai_fetch_models(
state: State<'_, AppState>,
provider_id: String,
) -> Result<Vec<ModelConfig>, String> {
let provider = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息)
let api_key = df_storage::secret::resolve_provider_secret(&provider);
let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type);
let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key)
.await
.map_err(err_str)?;
// 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/
// context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。
// 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router
// 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。
// 新模型(旧池无同 model_id)用探测默认值。
let merged: Vec<ModelConfig> = probed
.iter()
.map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) {
Some(old) => ModelConfig {
weight: old.weight,
enabled: old.enabled,
label: old.label.clone(),
..c.clone()
},
None => c.clone(),
})
.collect();
// 写回 model_configs(更新 updated_at)
let mut updated = provider.clone();
updated.model_configs = merged.clone();
updated.updated_at = now_millis();
state
.ai_providers
.update_full(&updated)
.await
.map_err(err_str)?;
Ok(merged)
}
/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
///
/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。
/// 直接调 df_ai::model_probe::probe(&model_id),返回填充了 probe_source 的 ModelConfig。
#[tauri::command]
pub async fn ai_probe_model(
_state: State<'_, AppState>,
model_id: String,
) -> Result<ModelConfig, String> {
Ok(df_ai::model_probe::probe(&model_id))
}
// ============================================================
// 对话管理
// ============================================================
/// 创建新对话
#[tauri::command]
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();
// F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。
// 旧实现(B-260615-10)生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀
// 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv),
// 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。
//
// B-260618-06 保留:切换 active 前先持久化旧 active conv(防其内存 messages 因后续操作丢失)。
// 注意:旧 conv 若有后台 loop 在跑,loop 自身的 save_conversation 也会持久化,此处 save 幂等。
let old_conv = {
let session = state.ai_session.lock().await;
session.active_conversation_id.clone()
};
let old_has_msgs = {
let session = state.ai_session.lock().await;
old_conv.as_deref()
.and_then(|oc| session.conv_read(oc))
.map(|c| !c.messages.is_empty())
.unwrap_or(false)
};
if let Some(ref oc) = old_conv {
if old_has_msgs {
save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None).await;
}
}
let mut session = state.ai_session.lock().await;
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now);
// 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
// (switch 路径会 retain 清目标 conv 的,create 不动他人)。
let _ = session.conv(&id); // 惰性建立空 PerConvState(字段全默认值)
Ok(serde_json::json!({ "id": id }))
}
/// 列出对话(仅摘要,不含 messages 全文)
///
/// limit 默认 50 防数据膨胀include_archived 默认 false归档对话默认隐藏
#[tauri::command]
pub async fn ai_conversation_list(
state: State<'_, AppState>,
limit: Option<usize>,
include_archived: Option<bool>,
) -> Result<Vec<serde_json::Value>, String> {
let limit = limit.unwrap_or(50);
let include_archived = include_archived.unwrap_or(false);
let records = state.ai_conversations.list_all().await.map_err(err_str)?;
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit
let summaries: Vec<serde_json::Value> = records.iter()
.filter(|r| include_archived || !r.archived)
.take(limit)
.map(|r| {
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
let models: Vec<String> = r.models.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
serde_json::json!({
"id": r.id,
"title": r.title,
"provider_id": r.provider_id,
"model": r.model,
"models": models,
"archived": r.archived,
"pinned": r.pinned,
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"created_at": r.created_at,
"updated_at": r.updated_at,
})
}).collect();
Ok(summaries)
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command]
pub async fn ai_conversation_switch(
state: State<'_, AppState>,
app_handle: AppHandle,
conversation_id: String,
) -> Result<serde_json::Value, String> {
let record = state.ai_conversations.get_by_id(&conversation_id).await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
let messages: Vec<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
let messages_json = record.messages.clone();
let title = record.title.clone();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)
let need_title_regen = record.title.is_none();
let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。
// 旧实现:active conv 生成中 → 只读切换(不改 session 状态),因单例 messages 会被新 conv 覆盖。
// 决策 e:per_conv 已隔离,切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload。
// **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
// (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
session.active_conversation_id = Some(conversation_id.clone());
let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
if !already_live {
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
let conv = session.conv(&conversation_id);
conv.messages.restore_from_messages(messages);
conv.model_override = None;
conv.session_trust.clear();
conv.agent_language = None;
conv.iteration_used = 0;
conv.stop_flag.store(false, Ordering::SeqCst);
}
// 仅清空目标对话自身的 pending_approvals,保留其他对话的(防 init 重建的内存 HashMap 被清空,
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
// → ai_pending_tool_calls 查询 → ai_approve 落库)
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
drop(session);
// 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。
// ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。
if need_title_regen {
match super::prompt::get_active_provider(&state).await {
Ok(provider_config) => {
super::title::spawn_ensure_title(
&provider_config,
&state.db,
&conversation_id,
&app_handle,
&state.ai_session,
&state.llm_concurrency,
);
}
Err(e) => tracing::warn!(
"切入历史会话触发标题重生成跳过(无可用 provider, conv_id={}): {}",
conversation_id, e
),
}
}
Ok(serde_json::json!({
"id": record.id,
"title": title,
"messages": messages_json,
}))
}
/// 删除对话
#[tauri::command]
pub async fn ai_conversation_delete(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<(), String> {
state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?;
let mut session = state.ai_session.lock().await;
// 删除任意对话(含非活跃)都应清理其积压审批:pending_approvals 是单例 HashMap,
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
session.per_conv.remove(&conversation_id);
if session.active_conversation_id.as_deref() == Some(&conversation_id) {
session.active_conversation_id = None;
}
// F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。
// conv 已删=LlmConcurrency 该条目不再被 acquire(无 conv 则无 loop/标题/提炼/压缩针对它)。
// 已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
// 时机:conv 删除即清理(比"loop 结束 + 无 pending"更确定——conv 删了必无 pending,
// 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
state.llm_concurrency.release_conv(&conversation_id).await;
Ok(())
}
/// 重命名对话标题
#[tauri::command]
pub async fn ai_conversation_rename(
state: State<'_, AppState>,
conversation_id: String,
title: String,
) -> Result<(), String> {
let title = title.trim().to_string();
if title.is_empty() {
return Err("标题不能为空".to_string());
}
state.ai_conversations.update_field(&conversation_id, "title", &title)
.await.map_err(err_str)?;
Ok(())
}
/// 归档/取消归档对话(归档后在侧栏折叠分组展示)
#[tauri::command]
pub async fn ai_conversation_archive(
state: State<'_, AppState>,
conversation_id: String,
archived: bool,
) -> Result<(), String> {
state.ai_conversations
.set_archived(&conversation_id, archived)
.await
.map_err(err_str)?;
Ok(())
}
/// 置顶/取消置顶对话(UX-17:对话置顶)
///
/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。
/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。
#[tauri::command]
pub async fn ai_conversation_set_pinned(
state: State<'_, AppState>,
conversation_id: String,
pinned: bool,
) -> Result<(), String> {
state.ai_conversations
.set_pinned(&conversation_id, pinned)
.await
.map_err(err_str)?;
Ok(())
}
/// 导出对话为指定格式(UX-18:对话导出)
///
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出
/// (content 内已有的三反引号代码块围栏原样保留,不做二次转义)
/// - json: 完整 messages 数组(serde 序列化 ChatMessage 列表)
/// - txt: `user: ...` / `assistant: ...` 纯文本拼接,system/tool 附注
///
/// 最小化:仅渲染 user/assistant 文本;tool_calls/tool_results 略过(导出给人看的对话)。
/// 空对话(无 messages)→ 空字符串(对应格式空体)。
#[tauri::command]
pub async fn ai_conversation_export(
state: State<'_, AppState>,
conversation_id: String,
format: String,
) -> Result<String, String> {
// format 校验:非法值 Err(不 panic),防止 format! 注入或未处理分支
let fmt = format.as_str();
if !matches!(fmt, "markdown" | "json" | "txt") {
return Err(format!("不支持的导出格式: {}", format));
}
// 取落库对话(完整历史)
let record = state.ai_conversations.get_by_id(&conversation_id).await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
let messages: Vec<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
let body = match fmt {
"markdown" => {
// user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流)
let mut parts: Vec<String> = Vec::new();
for m in &messages {
let title = match m.role {
df_ai::provider::MessageRole::User => Some("## 用户"),
df_ai::provider::MessageRole::Assistant => Some("## 助手"),
df_ai::provider::MessageRole::System => Some("## 系统"),
df_ai::provider::MessageRole::Tool => Some("## 工具结果"),
};
if let Some(t) = title {
// content 原样输出,内部三反引号围栏保留(Markdown 嵌套代码块,渲染器原生支持)
parts.push(format!("{}\n\n{}", t, m.content));
}
}
parts.join("\n\n")
}
"json" => {
serde_json::to_string_pretty(&messages)
.map_err(|e| format!("序列化失败: {}", e))?
}
"txt" => {
let mut parts: Vec<String> = Vec::new();
for m in &messages {
let role_name = match m.role {
df_ai::provider::MessageRole::System => "system",
df_ai::provider::MessageRole::User => "user",
df_ai::provider::MessageRole::Assistant => "assistant",
df_ai::provider::MessageRole::Tool => "tool",
};
parts.push(format!("{}: {}", role_name, m.content));
}
parts.join("\n")
}
// 上方 matches! 已校验,理论不可达
_ => return Err(format!("不支持的导出格式: {}", format)),
};
Ok(body)
}
/// 列出本机 Claude 技能skills + commands + plugins 三类),供前端 `/` 联想
#[tauri::command]
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
// 命中进程内缓存,命中后仅 clone不重复扫盘
Ok(skills_cached().clone())
}
/// 设置 LLM 调用并发上限(运行时调整,立即生效)
///
/// 软收敛:缩并发时已持有旧 permit 的任务继续执行不受影响,待其释放后新限制完全生效。
/// None 表示该层不变(前端可单独调一层)。值下限为 1。
#[tauri::command]
pub async fn ai_set_concurrency_config(
state: State<'_, AppState>,
global_limit: Option<u32>,
per_conv_limit: Option<u32>,
) -> Result<(), String> {
// 下限 1,无上限;同时给 global 时约束 per-conv 不超过 global
if let Some(g) = global_limit {
state.llm_concurrency.set_global(g.max(1) as usize).await;
}
if let Some(p) = per_conv_limit {
let mut p = p.max(1);
if let Some(g) = global_limit {
p = p.min(g.max(1));
}
state.llm_concurrency.set_per_conv(p as usize).await;
}
Ok(())
}
/// 设置 Agentic 循环最大轮次(运行时调整,立即生效)
///
/// 与并发配置不同max_iterations 是 loop 入口 load 快照的值,热改后当前 loop 不受影响
/// (已锁定边界),下次发消息生效。范围双 clampcommand 端 1-50 + 前端 input min/max
/// 防越界输入致 loop 过早结束(值过小)或失控(值过大)。
#[tauri::command]
pub async fn ai_set_agent_max_iterations(
state: State<'_, AppState>,
value: u32,
) -> Result<(), String> {
// clamp 1-50下限防 agent 失能(一轮即截断无法调任何工具),
// 上限防失控烧 token50 轮足够覆盖复杂多步任务)
let clamped = value.clamp(1, 50) as usize;
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
Ok(())
}
/// 设置流式对话失败自动重试次数F-260616-07 / 决策 a1运行时调整下次发消息生效
///
/// 只重试流前失败Init Err未输出任何 token流中途失败MidStream Partial保文不重试。
/// 退避复用 retry::backoff_delay(1s→2s→4s+jitter) + is_status_retryable Fatal 分类 +
/// 30s 总预算(详见 agentic.rs 流前重试循环)。
/// 范围 clamp 0-100 表示不重试(直接报错),上限 10 防过度重试烧 token/拖慢体验。
/// 默认 3复用 retry.rs backoff_delay + 错误分类,详见 agentic.rs 重试循环)。
#[tauri::command]
pub async fn ai_set_agent_max_retries(
state: State<'_, AppState>,
value: u32,
) -> Result<(), String> {
let clamped = value.clamp(0, 10) as usize;
state.agent_max_retries.store(clamped, Ordering::SeqCst);
Ok(())
}