From 3f2cf5fa3aa8d19736bb9587d684f6b5ebb24730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 2 Aug 2026 10:44:09 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20agentic=20=E6=9E=B6?= =?UTF-8?q?=E6=9E=84(run=5Fagentic=5Floop=20=E6=8A=BD=20finish=5Fround=5Fe?= =?UTF-8?q?xit=20+=20router=20=E8=B0=83=E7=94=A8=E7=82=B9=20+=20provider?= =?UTF-8?q?=5Fpool=20=E4=BA=B2=E5=92=8C=20enabled)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_agentic_loop: 抽 finish_round_exit + emit_ai_completed_once(6 退出点收敛) - compress/title/knowledge_inject/project: router estimated_context+tier 调用点对齐 - provider_pool: 亲和加 model.enabled 过滤 --- src-tauri/src/commands/ai/agentic/mod.rs | 308 +++++++++++++----- src-tauri/src/commands/ai/compress.rs | 1 + src-tauri/src/commands/ai/knowledge_inject.rs | 2 + src-tauri/src/commands/ai/provider_pool.rs | 39 ++- src-tauri/src/commands/ai/title.rs | 1 + src-tauri/src/commands/project.rs | 2 + 6 files changed, 261 insertions(+), 92 deletions(-) diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 9a00882..1897a91 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -21,7 +21,7 @@ use df_ai::context_helpers::{ // B 路线 Phase 1:plan_hint 接入主 loop——filter_tool_defs_planned 在 filter_tool_defs // 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到 // 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。 -use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer}; +use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, suggested_model_tier, IntentRecognizer}; use df_ai::coordinator::{Coordinator, ExecutionResult}; use df_ai::persona::PersonaRegistry; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; @@ -736,10 +736,16 @@ pub(crate) async fn run_agentic_loop( // 主对话路由 — TaskRequirements(needs_tool_use=true)。 // 模态当前仅 Text(图像消息类型未实现,后续多模态接入时检测 Part/Image 追加 Vision)。 // select_model_id None(池空/无匹配)→ 兜底 default_model,行为与接入前一致。 + // + // **子项 1+2 根因修复**:此处是 pre-loop 初始路由(算 resolved_model 兜底用),estimated_context=0 + // (loop 内 messages 此处尚未构建,无法估值)+ tier=None(intent 在 739 行之后才识别)。 + // 真实 estimated_context + tier 在 loop 内(line ~1395,estimated_prompt 算出后)重建 agentic_req + // shadow 此绑定,candidate chain 用 loop 内的真实估值版本(主路由路径)。 let agentic_req = TaskRequirements { modalities: vec![Modality::Text], needs_tool_use: true, estimated_context: 0, + tier: None, }; let resolved_model = select_model_id(&agentic_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); @@ -1122,29 +1128,25 @@ pub(crate) async fn run_agentic_loop( } } - // 落库 - save_conversation(&session_arc, &db, &conv_id, None, None, true).await; - - // generating 复位并 emit AiCompleted(对齐现有退出路径模式) - guard.reset().await; - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { - total_tokens: tokens.total(), + // 落库 + generating 复位 + emit AiCompleted(统一走 finish_round_exit,行为零变更) + // Coordinator 路径:save(None,None) + 不 spawn_title + emit(None,None,publish=true) + // emit_usage=tokens 快照(本路径无 round, tokens 为空, total=0 对齐原 tokens.total()) + let coord_usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), - incomplete: None, - conversation_id: Some(conv_id.clone()), - pinned_goals: pinned_goals_snapshot.clone(), - }); - let _ = app_handle.state::().ai_event_bus.publish_event( - AiChatEvent::AiCompleted { - total_tokens: tokens.total(), - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - incomplete: None, - conversation_id: Some(conv_id.clone()), - pinned_goals: pinned_goals_snapshot.clone(), - } - ); + total_tokens: tokens.total(), + }; + finish_round_exit( + &session_arc, &db, &conv_id, + None, None, + false, + &provider_config, &llm_concurrency, + &mut guard, + &coord_usage, + None, None, true, + &pinned_goals_snapshot, + &app_handle, + ).await; return; } } @@ -1172,21 +1174,18 @@ pub(crate) async fn run_agentic_loop( total_tokens: tokens.total(), }; // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 - save_conversation(&session_arc, &db, &conv_id, Some(&usage), None, true).await; - // 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底) - spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); - guard.reset().await; - // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() }); - // L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { - total_tokens: usage.total_tokens, - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - incomplete: None, - conversation_id: Some(conv_id.clone()), - pinned_goals: pinned_goals_snapshot.clone(), - }); + // 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true) + finish_round_exit( + &session_arc, &db, &conv_id, + Some(&usage), None, + true, + &provider_config, &llm_concurrency, + &mut guard, + &usage, + None, None, true, + &pinned_goals_snapshot, + &app_handle, + ).await; return; } @@ -1392,6 +1391,28 @@ pub(crate) async fn run_agentic_loop( messages.iter().map(|m| est.estimate_message(m)).sum() }; + // 子项 1+2 根因修复:loop 内重建 agentic_req,真实 estimated_context + 意图 tier。 + // + // **子项 1(estimated_context 死代码)**:原 pre-loop agentic_req 传 0, + // 上下文窗口过滤维度(context_window >= estimated_context)恒过,失效。此处用本轮 + // estimated_prompt(system+history 全量 token 估值)作 estimated_context → 窗口过滤生效 + // (小窗口模型如 8K 被大上下文任务正确滤掉,不再误选)。 + // + // **子项 2(weight tier tiebreak)**:tier 接 intent→ModelTier(Code/Debug→Heavy, + // Chat→Fast 等),同 weight 候选间按 tier tiebreak(满足档位下限的候选胜,见 router.rs)。 + // intent 在 loop 外(786 行)已识别,loop 内每轮复用同一 intent(用户末条 active 消息 + // 在单轮 LLM 调用内不变;多轮对话 intent 演化由用户后续消息触发,下轮 recognize 更新)。 + // + // **变量 shadow**:此绑定覆盖 pre-loop 的 agentic_req(line ~744 算初始 resolved_model + // 用过),candidate chain(下方 stream_one_provider)取此 loop 内版本。Rust shadow 安全: + // pre-loop 版本在 line 744 用完即弃,resolved_model 已落到 mut 变量。 + let agentic_req = TaskRequirements { + modalities: vec![Modality::Text], + needs_tool_use: true, + estimated_context: estimated_prompt as usize, + tier: suggested_model_tier(&intent), + }; + // LLM 并发限流: // per_conv 由 loop 入口(_conv_per_conv_permit)整 loop 持有(含工具执行/审批等待/重试), // 防单对话内并发 LLM 调用失控(单对话内 permits=2,非会话数限制)。 @@ -1552,28 +1573,27 @@ pub(crate) async fn run_agentic_loop( } } - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; - // 标题生成后台化(失败有 extract_title 兜底) - spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); - guard.reset().await; - // generating 复位后再 emit Completed(incomplete=true):前端据此标记消息为不完整 - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { - total_tokens: usage.total_tokens, + // 统一走 finish_round_exit 收尾(save + spawn_title + reset + emit)。 + // 注意:partial 文本+系统提示已先 push(上方 block),此 save 落库含本轮 partial,幂等覆盖。 + // emit_usage 用 tokens 快照(tokens.add 已累加本轮):total_tokens/prompt/completion 对齐原 emit 三元组。 + // MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方), + // do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。 + let emit_usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), - conversation_id: Some(conv_id.clone()), - incomplete: Some(true), - pinned_goals: pinned_goals_snapshot.clone(), - }); - // L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - incomplete: None, - conversation_id: Some(conv_id.clone()), - pinned_goals: pinned_goals_snapshot.clone(), - }); + }; + finish_round_exit( + &session_arc, &db, &conv_id, + Some(&usage), Some(&resolved_model), + true, + &provider_config, &llm_concurrency, + &mut guard, + &emit_usage, + Some(true), None, true, + &pinned_goals_snapshot, + &app_handle, + ).await; return; } // global/per_conv permit 已上移 loop 入口整 loop 持有, @@ -1624,21 +1644,18 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; - // 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底) - spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); - guard.reset().await; - // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() }); - // L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { - total_tokens: usage.total_tokens, - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - incomplete: None, - conversation_id: Some(conv_id.clone()), - pinned_goals: pinned_goals_snapshot.clone(), - }); + // 统一走 finish_round_exit:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true) + finish_round_exit( + &session_arc, &db, &conv_id, + Some(&usage), Some(&resolved_model), + true, + &provider_config, &llm_concurrency, + &mut guard, + &usage, + None, None, true, + &pinned_goals_snapshot, + &app_handle, + ).await; return; } @@ -1725,16 +1742,20 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; - guard.reset().await; - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { - total_tokens: usage.total_tokens, - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - incomplete: Some(true), - conversation_id: Some(conv_id.clone()), - pinned_goals: pinned_goals_snapshot.clone(), - }); + // 统一走 finish_round_exit:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) + + // emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为** + // (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。 + finish_round_exit( + &session_arc, &db, &conv_id, + Some(&usage), Some(&resolved_model), + false, + &provider_config, &llm_concurrency, + &mut guard, + &usage, + Some(true), None, false, + &pinned_goals_snapshot, + &app_handle, + ).await; return; } @@ -1770,17 +1791,18 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() }); - // L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - // AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { - total_tokens: usage_total, + // (正常完成路径 save+extract+title 已在上方 spawn 异步,此处仅 emit,故直接调 emit_ai_completed_once) + // emit_usage=tokens 快照(prompt/completion/total 全从 tokens 取,对齐原 usage_total=tokens.total()) + let normal_usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), - incomplete: None, - conversation_id: Some(conv_id.clone()), - pinned_goals: pinned_goals_snapshot.clone(), - }); + total_tokens: usage_total, + }; + emit_ai_completed_once( + &app_handle, &conv_id, &normal_usage, + None, None, true, + &pinned_goals_snapshot, + ).await; } // ── heartbeat_loop: 后台心跳任务(emit AiHeartbeat 防前端 watchdog 误杀) ── @@ -2192,6 +2214,112 @@ async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { }); } +// ── emit_ai_completed_once: AiCompleted 事件 emit+publish 双写 helper(对齐 emit_fatal_error 模式) ── +// +// 抽自 6 处退出路径(stop_flag 入口 / Coordinator merge / MidStream 保文 / push 后 stop / +// max_iterations / 正常完成)中「emit AiCompleted + publish_event AiCompleted」近重复代码。 +// +// **incomplete 语义分叉**(memory: 语义不变): +// MidStream 保文路径 emit 端 incomplete=Some(true)(前端据 标记不完整),publish 端 incomplete=None +// (事件总线消费方无需此标记);max_iterations 路径仅 emit 不 publish(原实现即无 publish,保留)。 +// 其余 4 路径 emit/publish 的 incomplete 一致(均 None 或均 Some)。 +// 故本 helper 拆 emit_incomplete / publish_incomplete / do_publish 三参,精确镜像原各路径差异。 +// +// usage 字段(对齐 run_agentic_loop 各退出点用法):emit 端的 token 三元组从 `usage` 取; +// prompt/completion 与 usage 一致(各退出点原样从 tokens 或 round-derived usage 传入)。 +// +// pinned_goals:前端直接读取刷新(G1 目标钉扎),原样透传快照(不 clone,借用调用方)。 +async fn emit_ai_completed_once( + app_handle: &AppHandle, + conv_id: &str, + usage: &df_ai::provider::TokenUsage, + emit_incomplete: Option, + publish_incomplete: Option, + do_publish: bool, + pinned_goals: &[super::GoalEntry], +) { + // emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。 + let _ = app_handle.emit( + "ai-chat-event", + AiChatEvent::AiCompleted { + total_tokens: usage.total_tokens, + prompt_tokens: usage.prompt_tokens, + completion_tokens: usage.completion_tokens, + incomplete: emit_incomplete, + conversation_id: Some(conv_id.to_string()), + pinned_goals: pinned_goals.to_vec(), + }, + ); + // publish 端(事件总线):EVENT_BUS_ENABLED 门控在 publish 内;incomplete 用 publish_incomplete + // (MidStream 传 None);do_publish=false(max_iterations)跳过整段 publish(保留原无 publish 行为)。 + if do_publish { + let _ = app_handle.state::().ai_event_bus.publish_event( + AiChatEvent::AiCompleted { + total_tokens: usage.total_tokens, + prompt_tokens: usage.prompt_tokens, + completion_tokens: usage.completion_tokens, + incomplete: publish_incomplete, + conversation_id: Some(conv_id.to_string()), + pinned_goals: pinned_goals.to_vec(), + }, + ); + } +} + +// ── finish_round_exit: run_agentic_loop 收尾统一入口(抽自 5 处退出路径重复代码) ── +// +// 收敛各退出点的「save_conversation + spawn_ensure_title + guard.reset + emit AiCompleted」序列。 +// 退出点(行为零变更): +// - stop_flag 入口:save(Some usage, None model) + spawn_title + reset + emit(None,None,publish=true) +// - Coordinator merge:save(None, None) + no title + reset + emit(None,None,publish=true) +// - MidStream 保文:save(Some usage, Some model) + spawn_title + reset + emit(Some(true),None,publish=true) +// - push 后 stop:save(Some usage, Some model) + spawn_title + reset + emit(None,None,publish=true) +// - max_iterations:save(Some usage, Some model) + no title + reset + emit(Some(true),-,publish=false) +// +// **不在范围**(模式异构,保留原状): +// - 正常完成(spawn 异步 save+extract+ensure_title 阻塞式,与 spawn_ensure_title 后台式不同): +// 仅复用 emit_ai_completed_once,不走本 helper。 +// - Fatal / Exhausted:emit AiError(已由 emit_fatal_error 统一),不走本 helper。 +// - stale_conv return:无 emit/guard 显式 reset(依赖 guard Drop),不走本 helper。 +// +// 参数口径(对齐各退出点原代码): +// - save_usage/save_model:透传 save_conversation;Coordinator 传 (None, None) 仍会调 save(幂等) +// - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入) +// - spawn_title:true → spawn_ensure_title(provider_config 后台生成,失败有 extract_title 兜底) +// - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档) +async fn finish_round_exit( + session_arc: &Arc>, + db: &Arc, + conv_id: &str, + save_usage: Option<&df_ai::provider::TokenUsage>, + save_model: Option<&str>, + spawn_title: bool, + provider_config: &AiProviderRecord, + llm_concurrency: &LlmConcurrency, + guard: &mut GeneratingGuard, + emit_usage: &df_ai::provider::TokenUsage, + emit_incomplete: Option, + publish_incomplete: Option, + do_publish: bool, + pinned_goals: &[super::GoalEntry], + app_handle: &AppHandle, +) { + // 落库:save_conversation 幂等(每轮重复覆盖落库),Coordinator 传 (None,None) 也调用(对齐原 :1126) + save_conversation(session_arc, db, conv_id, save_usage, save_model, true).await; + // 标题生成后台化(不阻塞 Completed emit):Coordinator / max_iterations 不 spawn(对齐原行为) + if spawn_title { + spawn_ensure_title(provider_config, db, conv_id, app_handle, session_arc, llm_concurrency); + } + // generating 复位(guard.reset 幂等):保证前端收 AiCompleted 时后端已 Idle(发送队列续发不被「正在生成中」拒绝) + guard.reset().await; + // generating 复位后再 emit Completed(对齐原各路径顺序) + emit_ai_completed_once( + app_handle, conv_id, emit_usage, + emit_incomplete, publish_incomplete, do_publish, + pinned_goals, + ).await; +} + // ── push_assistant_message: 轮结束后向 session.messages 推入 assistant 消息(扁平抽自原嵌套块) ── // has_tool_calls=true: assistant_with_tools(文本+工具调用占位), false 且文本非空: assistant(纯文本)。 // 两分支都不为空且都不需 push 时(no tool + empty text),返回(no-op)。 diff --git a/src-tauri/src/commands/ai/compress.rs b/src-tauri/src/commands/ai/compress.rs index 6f14056..d44397b 100644 --- a/src-tauri/src/commands/ai/compress.rs +++ b/src-tauri/src/commands/ai/compress.rs @@ -77,6 +77,7 @@ pub(crate) async fn compress_via_llm( modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, }; let model = select_model_id(&compress_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 6b5e8aa..ea68484 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -59,6 +59,7 @@ async fn resolve_embed_provider( modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, }; let model = select_model_id(&embed_req, &rec.model_configs).unwrap_or(fallback_model); Some((provider, model)) @@ -666,6 +667,7 @@ async fn extract_knowledge_from_conversation( modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, }; let extract_model = select_model_id(&extract_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); diff --git a/src-tauri/src/commands/ai/provider_pool.rs b/src-tauri/src/commands/ai/provider_pool.rs index f02db40..6a81024 100644 --- a/src-tauri/src/commands/ai/provider_pool.rs +++ b/src-tauri/src/commands/ai/provider_pool.rs @@ -70,10 +70,12 @@ impl ProviderPool { // 反转比较结果:大者排前(亲和 true > false;weight 大 > 小;is_default true > false)。 candidates.sort_by(|a, b| { // 主键:模型亲和。model_id None → 视两方都亲和(退化为全过此键)。 + // 必须过滤 m.enabled:用户禁用所有匹配实例时,该 provider 不应被标为亲和, + // 否则会选不含可用目标模型的 provider 做主,污染 fallback 链顺序。 let a_affinity = model_id - .map_or(true, |mid| a.model_configs.iter().any(|m| m.model_id == mid)); + .map_or(true, |mid| a.model_configs.iter().any(|m| m.model_id == mid && m.enabled)); let b_affinity = model_id - .map_or(true, |mid| b.model_configs.iter().any(|m| m.model_id == mid)); + .map_or(true, |mid| b.model_configs.iter().any(|m| m.model_id == mid && m.enabled)); let by_affinity = b_affinity.cmp(&a_affinity); // true 排前 if by_affinity != std::cmp::Ordering::Equal { return by_affinity; @@ -267,4 +269,37 @@ mod tests { "应按 亲和 > weight > is_default 排序,排除 enabled=false/weight=0" ); } + + /// ── 模型亲和:enabled 过滤 ── + + #[test] + fn model_affinity_excludes_disabled_model() { + // 回归点:匹配 model 全 disabled 时,provider 不应被标为亲和。 + // 设计:p1 含目标模型但 enabled=false 且 weight=90(高 weight 诱惑), + // p2 含目标模型且 enabled=true 但 weight=30(低 weight)。 + // 修复后(过滤 m.enabled):p1 非亲和 / p2 亲和 → p2 因亲和主键排前(即使 weight 低)。 + // bug 复现(不过滤 enabled):p1 p2 同亲和 → 按 weight → p1(90)排前。 + // 位置差异精确捕获回归:p2 排前=修复正确,p1 排前=bug 复现。 + let pool = vec![ + AiProviderRecord { + weight: 90, + model_configs: vec![ModelConfig { + enabled: false, + ..ModelConfig::with_defaults("glm-4-flash") + }], + ..provider("p1_disabled_model") + }, + AiProviderRecord { + weight: 30, + model_configs: vec![ModelConfig::with_defaults("glm-4-flash")], + ..provider("p2_enabled_model") + }, + ]; + let selected = ProviderPool::select(&pool, Some("glm-4-flash")); + assert_eq!( + selected.iter().map(|p| p.id.as_str()).collect::>(), + vec!["p2_enabled_model", "p1_disabled_model"], + "匹配 model 全 disabled 的 p1 非亲和,含 enabled model 的 p2 因亲和排前(即使 weight 低)" + ); + } } diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 49886af..844c470 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -102,6 +102,7 @@ pub(crate) async fn ensure_conversation_title( modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, }; let title_model = select_model_id(&title_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 7063db5..c5437dc 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -652,6 +652,7 @@ async fn extract_description_via_llm( modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, }; let scan_model = select_model_id(&scan_req, &pc.model_configs) .unwrap_or_else(|| pc.default_model.clone()); @@ -746,6 +747,7 @@ pub async fn scan_project_with_ai( modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, + tier: None, }; let scan_model = select_model_id(&scan_req, &pc.model_configs) .unwrap_or_else(|| pc.default_model.clone());