新增: F-01阶段6 AiChat模型下拉+override穿透
This commit is contained in:
@@ -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-14):stream_llm select! 监听 notified() 即时唤醒
|
// notify 同取一份 Arc 引用(B-260615-14):stream_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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 批准/拒绝工具调用 */
|
/** 批准/拒绝工具调用 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 后开始对话',
|
||||||
|
|||||||
Reference in New Issue
Block a user