新增: 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

@@ -6,13 +6,23 @@ import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProvid
export const aiApi = {
/** 发送消息(非阻塞,通过 ai-chat-event 流式返回skill 传技能名则注入其 SKILL.md */
sendMessage(message: string, language?: string, skill?: string): Promise<string> {
return invoke('ai_chat_send', { message, language: language || 'zh-CN', skill: skill || null })
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,
modelOverride: modelOverride || null,
})
},
/** 强制发送B-260616-02: 复位 generating 残留后走 send 同款流程) */
forceSend(message: string, language?: string, skill?: string): Promise<string> {
return invoke('ai_chat_force_send', { message, language: language || 'zh-CN', skill: skill || null })
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,
modelOverride: modelOverride || null,
})
},
/**
@@ -20,8 +30,12 @@ export const aiApi = {
* 后端:占用 generating → 弹出末尾 AI 回复(保留触发它的 user 消息)→ 重跑 agentic loop。
* 前端消费:与 sendMessage 一样需本地 push 空气泡占位 + 置 streaming(见 useAiSend.regenerate)。
*/
regenerate(conversationId: string, language?: string): Promise<string> {
return invoke('ai_regenerate', { conversationId, language: language || 'zh-CN' })
regenerate(conversationId: string, language?: string, modelOverride?: string | null): Promise<string> {
return invoke('ai_regenerate', {
conversationId,
language: language || 'zh-CN',
modelOverride: modelOverride || null,
})
},
/**
@@ -30,8 +44,13 @@ export const aiApi = {
* 前端消费:调 editMessage 前需移除被编辑消息之后的所有消息(视图截断)+ push 空气泡占位 + 置 streaming。
* 中间 user 消息编辑被后端拒绝(只允许末条)。
*/
editMessage(conversationId: string, newMessage: string, language?: string): Promise<string> {
return invoke('ai_chat_edit', { conversationId, newMessage, language: language || 'zh-CN' })
editMessage(conversationId: string, newMessage: string, language?: string, modelOverride?: string | null): Promise<string> {
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 }) }}
</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">
<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>
@@ -978,8 +1014,11 @@ function detectMentionTrigger() {
}
// UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
// F-01 阶段6: 同步清 modelOverride(后端 switchConversation/new 已清 session.model_override,
// 前端 ref 不清则会与后端不一致——override 是单对话级 UI 选择,不跨对话持久化)。
watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit()
store.modelOverride.value = null
})
function selectSkill(s: SkillInfo) {
@@ -1196,6 +1235,54 @@ const activeProviderName = computed(() => {
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(() =>
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId
@@ -2397,6 +2484,69 @@ body.ai-sidebar-resizing * {
.ai-approval-badge:hover {
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 {
display: flex;
gap: 4px;

View File

@@ -17,6 +17,7 @@
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -35,6 +36,22 @@ const QUEUE_LIMIT = 10
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
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
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
@@ -80,10 +97,12 @@ async function doSend(text: string, skill?: string, force = false) {
: raw
try {
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
// F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用)
const override = modelOverride.value
if (force) {
await aiApi.forceSend(text.trim(), lang, skill)
await aiApi.forceSend(text.trim(), lang, skill, override)
} else {
await aiApi.sendMessage(text.trim(), lang, skill)
await aiApi.sendMessage(text.trim(), lang, skill, override)
}
} catch (e) {
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
@@ -142,7 +161,7 @@ async function regenerate() {
throw new Error(t('aiChat.regenerateUnavailable'))
}
try {
await aiApi.regenerate(convId, lang)
await aiApi.regenerate(convId, lang, modelOverride.value)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
state.streaming = false
@@ -214,7 +233,7 @@ async function editMessage(newMessage: string) {
throw new Error(t('aiChat.editNotLastUser'))
}
try {
await aiApi.editMessage(convId, newMessage.trim(), lang)
await aiApi.editMessage(convId, newMessage.trim(), lang, modelOverride.value)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
state.streaming = false
@@ -418,6 +437,8 @@ export function useAiSend() {
stopChat,
isQueueTimedOut,
tryForceSend,
// F-01 阶段6: 模型 override(主对话专用),顶部下拉读写
modelOverride,
// 审批计时器已下沉到 aiShared.ts,此处 re-export 供组件使用
startApprovalTimer,
clearApprovalTimer,

View File

@@ -41,6 +41,14 @@ export default {
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
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) ──
emptyNoProvider: 'No AI provider configured',
emptyNoProviderHint: 'Please add a provider in settings to start chatting',

View File

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