新增: F-01阶段6 AiChat模型下拉+override穿透
This commit is contained in:
@@ -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,
|
||||
})
|
||||
},
|
||||
|
||||
/** 批准/拒绝工具调用 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 后开始对话',
|
||||
|
||||
Reference in New Issue
Block a user