新增: F-01阶段6 AiChat模型下拉+override穿透

This commit is contained in:
2026-06-17 00:50:47 +08:00
parent 63bff8bf96
commit 4c84fbc1b6
7 changed files with 256 additions and 16 deletions

View File

@@ -119,6 +119,7 @@ pub(crate) async fn run_agentic_loop(
max_iterations: usize, max_iterations: usize,
max_retries: usize, max_retries: usize,
start_iteration: usize, start_iteration: usize,
model_override: Option<String>,
) { ) {
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) // B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
let mut guard = GeneratingGuard::new(session_arc.clone()); let mut guard = GeneratingGuard::new(session_arc.clone());
@@ -174,6 +175,17 @@ pub(crate) async fn run_agentic_loop(
}; };
let resolved_model = select_model_id(&agentic_req, &provider_config.model_configs) let resolved_model = select_model_id(&agentic_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone()); .unwrap_or_else(|| provider_config.default_model.clone());
// F-01 阶段6: 用户指定模型 override 穿透(仅主对话生效,标题/扫描/灵感仍走路由)。
// 兜底原则:override 非空且在该 provider model_configs 池中 → 用 override;否则用 resolved_model。
// 绝不让 override 导致无模型(空/不在池 → 落回路由结果,行为不变)。
let resolved_model = match model_override.as_deref() {
Some(id) if !id.is_empty()
&& provider_config.model_configs.iter().any(|m| m.model_id == id) =>
{
id.to_string()
}
_ => resolved_model,
};
let tool_defs = tools_arc.tool_definitions(); let tool_defs = tools_arc.tool_definitions();
// 停止信号副本stream_llm 与每轮迭代共享读取,避免重复加锁 // 停止信号副本stream_llm 与每轮迭代共享读取,避免重复加锁
// notify 同取一份 Arc 引用B-260615-14stream_llm select! 监听 notified() 即时唤醒 // notify 同取一份 Arc 引用B-260615-14stream_llm select! 监听 notified() 即时唤醒
@@ -684,6 +696,11 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, s
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照 // F-260616-07: 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// F-01 阶段6: 续跑沿用同一主对话的 model_override(审批续跑/达 max 续跑保持一致)。
let model_override = {
let session = state.ai_session.lock().await;
session.model_override.clone()
};
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本 // 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开 // 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
@@ -693,6 +710,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, s
}); });
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
}); });
} }

View File

@@ -41,6 +41,7 @@ pub async fn ai_regenerate(
state: State<'_, AppState>, state: State<'_, AppState>,
conversation_id: String, conversation_id: String,
language: Option<String>, language: Option<String>,
model_override: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let provider_config = super::prompt::get_active_provider(&state).await?; let provider_config = super::prompt::get_active_provider(&state).await?;
@@ -55,6 +56,8 @@ pub async fn ai_regenerate(
session.agent_language = language.clone(); session.agent_language = language.clone();
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。 // F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
session.iteration_used = 0; session.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
session.model_override = model_override.clone();
let popped = session.messages.pop_last_assistant_round(); let popped = session.messages.pop_last_assistant_round();
if !popped { if !popped {
// 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错 // 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错
@@ -109,7 +112,7 @@ pub async fn ai_regenerate(
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -137,6 +140,7 @@ pub async fn ai_chat_send(
message: String, message: String,
language: Option<String>, language: Option<String>,
skill: Option<String>, skill: Option<String>,
model_override: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志) // 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::prompt::get_active_provider(&state).await?; let provider_config = super::prompt::get_active_provider(&state).await?;
@@ -152,6 +156,9 @@ pub async fn ai_chat_send(
session.agent_language = language.clone(); session.agent_language = language.clone();
// F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。 // F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。
session.iteration_used = 0; session.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。run_agentic_loop 兜底校验
// (override 非空且在 provider model_configs 池中才用,否则落回路由结果)。
session.model_override = model_override.clone();
// F-260614-02 §5.2:纯技能调用(用户未填文本)时,落库 user content 改 /{skillname} // F-260614-02 §5.2:纯技能调用(用户未填文本)时,落库 user content 改 /{skillname}
// 作为技能调用标记(非伪造用户文本),让 title.rs summary_msgs 取到非空素材生成标题。 // 作为技能调用标记(非伪造用户文本),让 title.rs summary_msgs 取到非空素材生成标题。
// 非空 message 原样落库。 // 非空 message 原样落库。
@@ -219,7 +226,7 @@ pub async fn ai_chat_send(
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -392,6 +399,7 @@ pub async fn ai_chat_edit(
conversation_id: String, conversation_id: String,
new_message: String, new_message: String,
language: Option<String>, language: Option<String>,
model_override: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let provider_config = super::prompt::get_active_provider(&state).await?; let provider_config = super::prompt::get_active_provider(&state).await?;
@@ -424,6 +432,8 @@ pub async fn ai_chat_edit(
session.agent_language = language.clone(); session.agent_language = language.clone();
// F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。 // F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。
session.iteration_used = 0; session.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
session.model_override = model_override.clone();
} }
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
@@ -480,6 +490,7 @@ pub async fn ai_chat_edit(
max_iterations, max_iterations,
max_retries, max_retries,
0, 0,
model_override,
) )
.await; .await;
}); });
@@ -499,6 +510,7 @@ pub async fn ai_chat_force_send(
message: String, message: String,
language: Option<String>, language: Option<String>,
skill: Option<String>, skill: Option<String>,
model_override: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
// 原子复位:清 generating + 清积压审批 + 置 stop_flag,与 ai_chat_stop 审批分支一致 // 原子复位:清 generating + 清积压审批 + 置 stop_flag,与 ai_chat_stop 审批分支一致
let old_conv_id = { let old_conv_id = {
@@ -521,7 +533,7 @@ pub async fn ai_chat_force_send(
} }
// 复位完成后走 ai_chat_send 同款流程(内部会重新设 generating=true 并 spawn loop // 复位完成后走 ai_chat_send 同款流程(内部会重新设 generating=true 并 spawn loop
// 直接内联而非递归调 ai_chat_send避免 IPC 嵌套 // 直接内联而非递归调 ai_chat_send避免 IPC 嵌套
ai_chat_send(app, state, message, language, skill).await ai_chat_send(app, state, message, language, skill, model_override).await
} }
/// 停止当前 AI 生成 /// 停止当前 AI 生成
@@ -946,6 +958,8 @@ pub async fn ai_conversation_create(
// 启动即见 stop_flag=true 异常退出;agent_language 清空防新会话沿用旧会话语言设置。 // 启动即见 stop_flag=true 异常退出;agent_language 清空防新会话沿用旧会话语言设置。
session.stop_flag.store(false, Ordering::SeqCst); session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = None; session.agent_language = None;
// F-01 阶段6: 清旧对话的 model_override,防新对话沿用上一次的「指定」模型。
session.model_override = None;
Ok(serde_json::json!({ "id": id })) Ok(serde_json::json!({ "id": id }))
} }
@@ -1021,6 +1035,9 @@ pub async fn ai_conversation_switch(
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的) // 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
// → ai_pending_tool_calls 查询 → ai_approve 落库) // → ai_pending_tool_calls 查询 → ai_approve 落库)
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// F-01 阶段6: 切换对话清旧 override,防新对话沿用上一次的「指定」模型
// (override 是单对话级 UI 选择,不跨对话持久化)。
session.model_override = None;
Ok(serde_json::json!({ Ok(serde_json::json!({
"id": record.id, "id": record.id,

View File

@@ -6,13 +6,23 @@ import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProvid
export const aiApi = { export const aiApi = {
/** 发送消息(非阻塞,通过 ai-chat-event 流式返回skill 传技能名则注入其 SKILL.md */ /** 发送消息(非阻塞,通过 ai-chat-event 流式返回skill 传技能名则注入其 SKILL.md */
sendMessage(message: string, language?: string, skill?: string): Promise<string> { sendMessage(message: string, language?: string, skill?: string, modelOverride?: string | null): Promise<string> {
return invoke('ai_chat_send', { message, language: language || 'zh-CN', skill: skill || null }) return invoke('ai_chat_send', {
message,
language: language || 'zh-CN',
skill: skill || null,
modelOverride: modelOverride || null,
})
}, },
/** 强制发送B-260616-02: 复位 generating 残留后走 send 同款流程) */ /** 强制发送B-260616-02: 复位 generating 残留后走 send 同款流程) */
forceSend(message: string, language?: string, skill?: string): Promise<string> { forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null): Promise<string> {
return invoke('ai_chat_force_send', { message, language: language || 'zh-CN', skill: skill || null }) return invoke('ai_chat_force_send', {
message,
language: language || 'zh-CN',
skill: skill || null,
modelOverride: modelOverride || null,
})
}, },
/** /**
@@ -20,8 +30,12 @@ export const aiApi = {
* 后端:占用 generating → 弹出末尾 AI 回复(保留触发它的 user 消息)→ 重跑 agentic loop。 * 后端:占用 generating → 弹出末尾 AI 回复(保留触发它的 user 消息)→ 重跑 agentic loop。
* 前端消费:与 sendMessage 一样需本地 push 空气泡占位 + 置 streaming(见 useAiSend.regenerate)。 * 前端消费:与 sendMessage 一样需本地 push 空气泡占位 + 置 streaming(见 useAiSend.regenerate)。
*/ */
regenerate(conversationId: string, language?: string): Promise<string> { regenerate(conversationId: string, language?: string, modelOverride?: string | null): Promise<string> {
return invoke('ai_regenerate', { conversationId, language: language || 'zh-CN' }) return invoke('ai_regenerate', {
conversationId,
language: language || 'zh-CN',
modelOverride: modelOverride || null,
})
}, },
/** /**
@@ -30,8 +44,13 @@ export const aiApi = {
* 前端消费:调 editMessage 前需移除被编辑消息之后的所有消息(视图截断)+ push 空气泡占位 + 置 streaming。 * 前端消费:调 editMessage 前需移除被编辑消息之后的所有消息(视图截断)+ push 空气泡占位 + 置 streaming。
* 中间 user 消息编辑被后端拒绝(只允许末条)。 * 中间 user 消息编辑被后端拒绝(只允许末条)。
*/ */
editMessage(conversationId: string, newMessage: string, language?: string): Promise<string> { editMessage(conversationId: string, newMessage: string, language?: string, modelOverride?: string | null): Promise<string> {
return invoke('ai_chat_edit', { conversationId, newMessage, language: language || 'zh-CN' }) return invoke('ai_chat_edit', {
conversationId,
newMessage,
language: language || 'zh-CN',
modelOverride: modelOverride || null,
})
}, },
/** 批准/拒绝工具调用 */ /** 批准/拒绝工具调用 */

View File

@@ -211,6 +211,42 @@
{{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }} {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div> </div>
</div> </div>
<!-- F-01 阶段6: 模型选择器(自动/指定)
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话
provider model_configs(空池):下拉空 + 强制自动(toggle 禁用)
仅展示 active provider enabled model_configs -->
<div class="ai-model-picker" :title="modelPickerHint">
<div class="ai-model-mode">
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }"
:disabled="!enabledModels.length"
@click="setAutoMode"
>{{ $t('aiChat.autoMode') }}</button>
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
:disabled="!enabledModels.length"
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
@click="setSpecifyMode"
>{{ $t('aiChat.specifyMode') }}</button>
</div>
<select
v-if="enabledModels.length"
class="ai-model-select"
:value="selectedModelId"
:disabled="!isSpecifyMode"
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
@change="onModelSelect($event)"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
{{ modelOptionLabel(m) }}
</option>
</select>
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
</div>
<div class="ai-header-actions"> <div class="ai-header-actions">
<button class="ai-btn-icon" @click="confirmNewConversation" :title="$t('aiChat.newConversation')"> <button class="ai-btn-icon" @click="confirmNewConversation" :title="$t('aiChat.newConversation')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
@@ -978,8 +1014,11 @@ function detectMentionTrigger() {
} }
// UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效) // UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
// F-01 阶段6: 同步清 modelOverride(后端 switchConversation/new 已清 session.model_override,
// 前端 ref 不清则会与后端不一致——override 是单对话级 UI 选择,不跨对话持久化)。
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit() if (editingMsgId.value) cancelEdit()
store.modelOverride.value = null
}) })
function selectSkill(s: SkillInfo) { function selectSkill(s: SkillInfo) {
@@ -1196,6 +1235,54 @@ const activeProviderName = computed(() => {
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured') return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
}) })
// ── F-01 阶段6: 顶部模型选择器(自动/指定) ──
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
// 空池(provider 未拉取/无 enabled 模型)时下拉为空 + 强制自动模式。
const activeProviderRecord = computed(() =>
store.state.providers.find(p => p.id === store.state.activeProvider)
|| store.state.providers.find(p => p.is_default)
|| store.state.providers[0]
|| null,
)
const enabledModels = computed(() => {
const p = activeProviderRecord.value
if (!p?.model_configs) return []
return p.model_configs.filter(m => m.enabled)
})
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
const isSpecifyMode = computed(() => store.modelOverride.value !== null)
const selectedModelId = computed(() => store.modelOverride.value || '')
/** 当前选中模型的 model_id(指定模式);自动模式为空 */
const modelPickerHint = computed(() => {
if (!enabledModels.value.length) return t('aiChat.noModels')
return isSpecifyMode.value
? t('aiChat.specifyModeHint')
: t('aiChat.autoModeHint')
})
/** 下拉选项 label:model_id + 4 维度简标(智力/价格),复用 settings i18n 标签字面 */
function modelOptionLabel(m: import('../api/types').ModelConfig): string {
const name = m.label || m.model_id
const intel = t('settings.tagIntel.' + m.intelligence)
const cost = t('settings.tagCost.' + m.cost_tier)
return `${name} · ${intel} · ${cost}`
}
function setAutoMode() {
store.modelOverride.value = null
}
function setSpecifyMode() {
if (!enabledModels.value.length) return
// 进入指定模式默认选第一个 enabled 模型(若已非空且在池中则保留)
const cur = store.modelOverride.value
const inPool = cur && enabledModels.value.some(m => m.model_id === cur)
if (!inPool) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
}
function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null
}
// 当前视图是否正在生成(切走后台生成时光标不显示) // 当前视图是否正在生成(切走后台生成时光标不显示)
const isViewingGenerating = computed(() => const isViewingGenerating = computed(() =>
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId store.state.streaming && store.state.generatingConvId === store.state.activeConversationId
@@ -2397,6 +2484,69 @@ body.ai-sidebar-resizing * {
.ai-approval-badge:hover { .ai-approval-badge:hover {
opacity: 0.85; opacity: 0.85;
} }
/* ═══ F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ═══ */
/* 紧凑内联控件,header 中间区。空池时下拉隐藏 + toggle 禁用,强制自动。 */
.ai-model-picker {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
margin-right: 4px;
padding-left: 8px;
min-width: 0;
}
.ai-model-mode {
display: inline-flex;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.ai-model-mode-btn {
border: none;
background: transparent;
color: var(--df-text-dim);
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.ai-model-mode-btn:hover:not(:disabled) {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-model-mode-btn--active {
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-model-mode-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ai-model-select {
min-width: 0;
max-width: 180px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 10px;
font-family: var(--df-font-mono);
padding: 3px 6px;
outline: none;
cursor: pointer;
}
.ai-model-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-model-empty {
font-size: 10px;
color: var(--df-text-dim);
white-space: nowrap;
}
.ai-header-actions { .ai-header-actions {
display: flex; display: flex;
gap: 4px; gap: 4px;

View File

@@ -17,6 +17,7 @@
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
@@ -35,6 +36,22 @@ const QUEUE_LIMIT = 10
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02) /// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
const QUEUE_TIMEOUT_MS = 30_000 const QUEUE_TIMEOUT_MS = 30_000
/**
* F-01 阶段6: 用户指定模型 override(主对话专用,模块级单例)。
*
* null=自动模式(路由器选);非空字符串=用户从下拉指定的 model_id。
* 仅主对话(agentic)生效:doSend/sendMessage/regenerate/editMessage 透传给后端,
* 标题/扫描/灵感等内部调用不读此字段(后端各自独立 IPC 不带 override)。
*
* 后端兜底(agentic.rs):override 非空且在该 provider model_configs 池中才用,
* 否则落回路由结果——绝不让 override 导致无模型。
*
* 模块级(非组件级):AiChat.vue 顶部下拉读写此 ref,发送链路读此 ref 透传,
* 单实例 AiChat 不需 props/inject 串联。切换对话时后端清 session.model_override,
* 前端应同步清(modelOverride.value = null)保持 UI 与后端一致。
*/
const modelOverride = ref<string | null>(null)
// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts // 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。 // 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
@@ -80,10 +97,12 @@ async function doSend(text: string, skill?: string, force = false) {
: raw : raw
try { try {
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send // force=true 走 force_send IPC(复位残留 generating),否则走正常 send
// F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用)
const override = modelOverride.value
if (force) { if (force) {
await aiApi.forceSend(text.trim(), lang, skill) await aiApi.forceSend(text.trim(), lang, skill, override)
} else { } else {
await aiApi.sendMessage(text.trim(), lang, skill) await aiApi.sendMessage(text.trim(), lang, skill, override)
} }
} catch (e) { } catch (e) {
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位; // IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
@@ -142,7 +161,7 @@ async function regenerate() {
throw new Error(t('aiChat.regenerateUnavailable')) throw new Error(t('aiChat.regenerateUnavailable'))
} }
try { try {
await aiApi.regenerate(convId, lang) await aiApi.regenerate(convId, lang, modelOverride.value)
} catch (e) { } catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态) // IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
state.streaming = false state.streaming = false
@@ -214,7 +233,7 @@ async function editMessage(newMessage: string) {
throw new Error(t('aiChat.editNotLastUser')) throw new Error(t('aiChat.editNotLastUser'))
} }
try { try {
await aiApi.editMessage(convId, newMessage.trim(), lang) await aiApi.editMessage(convId, newMessage.trim(), lang, modelOverride.value)
} catch (e) { } catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除) // IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
state.streaming = false state.streaming = false
@@ -418,6 +437,8 @@ export function useAiSend() {
stopChat, stopChat,
isQueueTimedOut, isQueueTimedOut,
tryForceSend, tryForceSend,
// F-01 阶段6: 模型 override(主对话专用),顶部下拉读写
modelOverride,
// 审批计时器已下沉到 aiShared.ts,此处 re-export 供组件使用 // 审批计时器已下沉到 aiShared.ts,此处 re-export 供组件使用
startApprovalTimer, startApprovalTimer,
clearApprovalTimer, clearApprovalTimer,

View File

@@ -41,6 +41,14 @@ export default {
providerNotConfigured: 'No AI provider configured. Please add one in settings.', providerNotConfigured: 'No AI provider configured. Please add one in settings.',
notConfigured: 'Not configured', notConfigured: 'Not configured',
// ── F-01 阶段6: model picker (auto/specify toggle + dropdown) ──
autoMode: 'Auto',
specifyMode: 'Specify',
autoModeHint: 'Auto mode: router picks the best model per scenario',
specifyModeHint: 'Specify mode: use the selected model, overriding the router (main chat only)',
selectModel: 'Select model',
noModels: 'No models available. Please fetch in settings.',
// ── Empty states (UX-2025-20: example prompt cards + no-provider guidance) ── // ── Empty states (UX-2025-20: example prompt cards + no-provider guidance) ──
emptyNoProvider: 'No AI provider configured', emptyNoProvider: 'No AI provider configured',
emptyNoProviderHint: 'Please add a provider in settings to start chatting', emptyNoProviderHint: 'Please add a provider in settings to start chatting',

View File

@@ -41,6 +41,14 @@ export default {
providerNotConfigured: '未配置 AI 提供商,请在设置中添加', providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
notConfigured: '未配置', notConfigured: '未配置',
// ── F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ──
autoMode: '自动',
specifyMode: '指定',
autoModeHint: '自动模式:路由器按场景自动选最优模型',
specifyModeHint: '指定模式:使用下拉所选模型覆盖路由(仅主对话)',
selectModel: '选择模型',
noModels: '无可用模型,请在设置中拉取',
// ── 空状态(UX-2025-20:示例问题卡片 + 无 provider 引导) ── // ── 空状态(UX-2025-20:示例问题卡片 + 无 provider 引导) ──
emptyNoProvider: '未配置 AI 提供商', emptyNoProvider: '未配置 AI 提供商',
emptyNoProviderHint: '请在设置中添加 Provider 后开始对话', emptyNoProviderHint: '请在设置中添加 Provider 后开始对话',