优化: run_command 实时流式 + 审批浮窗修复 + 文档探索闭环(fetch_url+obscura引导+model_fetch兼容+prompt策略+厂商预设)

This commit is contained in:
lxy
2026-08-01 17:58:47 +08:00
parent 0e0c6862ba
commit d664bdc309
25 changed files with 1693 additions and 44 deletions
+37
View File
@@ -68,6 +68,16 @@
<label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
</div>
<!-- D:厂商预设下拉 选中自动填充 providerType/baseUrl/defaultModel(用户可改覆盖)
新建态独立"快捷选择";编辑态回填后切预设仍可覆盖(等价快捷填充),不阻塞手改
"自定义" 不预填任何字段 -->
<div class="form-field">
<label class="form-label">{{ $t('settings.labelPreset') }}</label>
<select v-model="providerForm.presetId" class="setting-select" @change="onPresetChange">
<option value="__custom">{{ $t('settings.presetCustom') }}</option>
<option v-for="p in PROVIDER_PRESETS" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelProviderType') }}</label>
<select v-model="providerForm.providerType" class="setting-select">
@@ -136,6 +146,7 @@ import { reactive, ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig } from '@/api/types'
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
// ============================================================
// AI 提供商域 — 真实后端 CRUD + F-04c 负载均衡池(enabled / weight)
@@ -174,6 +185,10 @@ const providerForm = reactive({
baseUrl: '',
apiKey: '',
defaultModel: '',
// 项 D:厂商预设下拉当前值(__custom=自定义;命中预设 id 时仅作展示与回填源头,
// 实际字段值由 onPresetChange 写入;用户手改任一字段后 presetId 不自动复位,
// 避免编辑态抖动,提交时也不读 presetId —— 以表单实际字段值为准)。
presetId: CUSTOM_PRESET_ID,
saving: false,
// F-01 阶段6:模型拉取 + 列表
fetching: false,
@@ -205,6 +220,9 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.apiKey = '' // 编辑不回填 api_key(list 返回 mask,留空=不改,FR-S1)
providerForm.defaultModel = p.default_model
providerForm.providerType = p.provider_type || 'openai_compat'
// 项 D:编辑态不反推预设(避免用户已手改 baseUrl/model 却被下拉误标成某预设误导);
// 统一标"自定义",下拉仅作新建快捷填充入口。
providerForm.presetId = CUSTOM_PRESET_ID
// F-01 阶段6:回填已保存的 model_configs(深拷贝避免双向绑定污染列表源)
providerForm.models = (p.model_configs ?? []).map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
} else {
@@ -214,6 +232,7 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.baseUrl = ''
providerForm.apiKey = ''
providerForm.defaultModel = ''
providerForm.presetId = CUSTOM_PRESET_ID
providerForm.models = []
}
providerForm.fetching = false
@@ -221,6 +240,24 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.visible = true
}
/**
* 项 D:厂商预设下拉变更 — 命中预设则覆盖填充 providerType/baseUrl/defaultModel。
*
* 设计取舍:
* - 覆盖而非合并:选预设=用户明确意图用某厂商端点,全量填入最省心;用户随后仍可手改任一字段。
* - 不动 name/apiKey:name 用户应自取别名(避免重名"DeepSeek"×N);apiKey 是密钥预设无可填。
* - 选"自定义"不动作(仅清字段是粗暴的;用户可能误点,留原值更安全)。
* - 不复位 presetId:用户手改 baseUrl 等后下拉仍标原预设——这无副作用(提交不读 presetId),
* 强行复位反而要监听每个字段 input,过度工程。
*/
function onPresetChange() {
const preset = findPreset(providerForm.presetId)
if (!preset) return // __custom 或未知 id:不动作
providerForm.providerType = preset.providerType
providerForm.baseUrl = preset.baseUrl
providerForm.defaultModel = preset.defaultModel
}
/**
* 测试连接按钮可用条件(P0-1):
* - baseUrl 必填(无论新建/编辑,表单值都要有)
@@ -0,0 +1,86 @@
/**
* 厂商预设数据(项 D:文档探索失败时兜底 + 配置体验)。
*
* 选中预设后表单自动填入 providerType / baseUrl / defaultModel;用户仍可手改覆盖。
* "自定义" 不预填(纯手填)。endpoints 均来自代码库实测核验,非猜测:
*
* - OpenAI openai_compat https://api.openai.com (openai_compat.rs:45 / presets/models.json)
* - GLM/智谱 openai_compat https://open.bigmodel.cn/api/paas/v4 (model_fetch_helpers.rs:178,实测端点)
* - DeepSeek openai_compat https://api.deepseek.com (openai_compat.rs:45 / model_fetch_helpers.rs:187)
* - SenseNova openai_compat https://api.sensenova.cn/compatible-mode/v1 (scripts/test_sensenova.py 实测,非 token 端点)
* - Anthropic anthropic https://api.anthropic.com (anthropic_compat.rs:51)
*
* chat_url/messages_url 智能拼接(见 openai_compat.rs chat_url / anthropic_compat.rs messages_url):
* - 仅域名(OpenAI/DeepSeek)→ 后端补 /v1/chat/completions
* - 已含版本段 GLM /v4、SenseNova /compatible-mode/v1 → 后端补 /chat/completions
* 故 baseUrl 既可填带 /v1 也可不填,均能工作;此处采用代码库注释示例形态(更接近用户文档预期)。
*/
/** providerType 字面量(对齐后端 ProviderType serde,与 ProviderPanel.vue 下拉 option value 一致) */
export type PresetProviderType = 'openai_compat' | 'anthropic'
export interface ProviderPreset {
/** 预设唯一标识(下拉 option value;__custom 为内置"自定义"项) */
id: string
/** 展示名(中英 i18n key 后缀,见 labelPreset.{id}) */
name: string
providerType: PresetProviderType
baseUrl: string
defaultModel: string
}
/**
* 预设列表(顺序即下拉展示顺序;高频国产厂商前置)。
*
* baseUrl 不带末尾斜杠(对齐后端 chat_url trim_end_matches('/') 兜底,但保持干净)。
* defaultModel 选各厂商最常用、最稳定的 chat 模型(用户可改)。
*/
export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
{
id: 'glm',
name: 'GLM / 智谱',
providerType: 'openai_compat',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
defaultModel: 'glm-4.6',
},
{
id: 'deepseek',
name: 'DeepSeek',
providerType: 'openai_compat',
baseUrl: 'https://api.deepseek.com',
defaultModel: 'deepseek-chat',
},
{
id: 'sensenova',
name: 'SenseNova / 商汤',
providerType: 'openai_compat',
baseUrl: 'https://api.sensenova.cn/compatible-mode/v1',
defaultModel: 'SenseNova-Turbo',
},
{
id: 'openai',
name: 'OpenAI',
providerType: 'openai_compat',
baseUrl: 'https://api.openai.com',
defaultModel: 'gpt-4o',
},
{
id: 'anthropic',
name: 'Anthropic / Claude',
providerType: 'anthropic',
baseUrl: 'https://api.anthropic.com',
defaultModel: 'claude-sonnet-4',
},
] as const
/** "自定义" 项标识(下拉内置项,选中不预填任何字段) */
export const CUSTOM_PRESET_ID = '__custom' as const
/**
* 按 id 查预设;找不到(含 CUSTOM_PRESET_ID)返回 null。
* 调用方:null → 不预填(自定义/编辑态不覆盖用户已填值)。
*/
export function findPreset(id: string): ProviderPreset | null {
if (id === CUSTOM_PRESET_ID) return null
return PROVIDER_PRESETS.find(p => p.id === id) ?? null
}
+7
View File
@@ -43,6 +43,8 @@ export default {
// Provider form
labelName: 'Name',
labelPreset: 'Vendor preset',
presetCustom: 'Custom (manual)',
labelProviderType: 'Protocol',
labelBaseUrl: 'Base URL',
labelApiKey: 'API Key',
@@ -209,6 +211,11 @@ export default {
toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)',
toastSaved: 'Saved',
toastSaveFail: 'Save failed: {msg}',
// P0-2: save-failure classified guidance (persistent banner, not raw Err text)
saveErrKeyringTitle: 'Failed to write key to the OS keychain',
saveErrKeyringDetail: 'Original config left unchanged. Backend reason: {msg}\nSuggestion: check the OS keychain/credential store permissions (Windows Credential Manager / macOS Keychain / Linux secret-service daemon), then click "Save" again to retry.',
saveErrGenericTitle: 'Failed to save provider config',
saveErrGenericDetail: 'Backend reason: {msg}\nSuggestion: double-check the form fields (Name / Base URL / Model), then click "Save" again to retry; check the logs if it keeps failing.',
toastDeleted: 'Deleted',
toastDeleteFail: 'Delete failed: {msg}',
toastSetDefaultOk: 'Set as default',
+7
View File
@@ -43,6 +43,8 @@ export default {
// Provider 表单
labelName: '名称',
labelPreset: '厂商预设',
presetCustom: '自定义(手填)',
labelProviderType: '协议类型',
labelBaseUrl: 'Base URL',
labelApiKey: 'API Key',
@@ -209,6 +211,11 @@ export default {
toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)',
toastSaved: '已保存',
toastSaveFail: '保存失败:{msg}',
// P0-2: 保存失败分类建议(常驻错误横幅,非裸 Err 文本)
saveErrKeyringTitle: '密钥写入系统钥匙串失败',
saveErrKeyringDetail: '已保留原配置未改动。后端原因:{msg}\n建议:检查操作系统钥匙串/凭据管理器权限(Windows 凭据管理器 / macOS 钥匙串 / Linux secret-service 守护进程),确认后再次点击「保存」重试。',
saveErrGenericTitle: '保存提供商配置失败',
saveErrGenericDetail: '后端原因:{msg}\n建议:核对表单字段(名称 / Base URL / 模型)后再次点击「保存」重试;若持续失败请查看日志。',
toastDeleted: '已删除',
toastDeleteFail: '删除失败:{msg}',
toastSetDefaultOk: '已设为默认',
+59 -7
View File
@@ -117,6 +117,11 @@ function displayName(tc: AiToolCallInfo): string {
let _unlistenUpdate: UnlistenFn | null = null
let _positioned = false
/** 关窗兜底:防止 close() promise 卡住或被拒导致「正在关闭…」永久停留。
* close() 在 Tauri 2.x 触发 closeRequested(非强制),某些情况下原生窗口未及时销毁 →
* Vue 组件保持挂载 + 空态 hint 永久可见。此处超时强制 destroy() 兜底。 */
let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
const CLOSE_TIMEOUT_MS = 1500
onMounted(async () => {
// 首屏定位到屏幕右上角(只定位一次,后续保留用户拖动后位置)
@@ -164,8 +169,51 @@ onMounted(async () => {
onBeforeUnmount(() => {
_unlistenUpdate?.()
_unlistenUpdate = null
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
})
/** 关窗统一实现:close 先行(优雅),超时/失败兜底 destroy()(强制销毁)。
* 根因修复(BUG3+):原实现仅 await getCurrentWebviewWindow().close(),而 Tauri 2.x 的
* close() 触发 closeRequested(非强制),若原生窗口因任意原因未及时销毁(已知偶发),
* Vue 组件保持挂载 → 浮窗内空态 hint「正在关闭…」永久可见,UI 卡死。
* - 兜底1:close() 失败 → 立即 destroy() 强制销毁
* - 兜底2:close() 1.5s 未完成 → destroy() 强制销毁(超时定时器)
* - 兜底3:destroy() 也失败 → 控制台报错(不再静默吞掉,便于排查) */
async function closeWithFallback(): Promise<void> {
const win = getCurrentWebviewWindow()
// 超时兜底:close 卡住则强制 destroy
_closeFallbackTimer = setTimeout(() => {
_closeFallbackTimer = null
console.warn('[ApprovalPopup] close() 超时,降级 destroy()')
win.destroy().catch((e: unknown) => {
console.error('[ApprovalPopup] destroy() 兜底也失败:', e)
})
}, CLOSE_TIMEOUT_MS)
try {
await win.close()
// close 成功:取消超时定时器(若已触发则 destroy 已并行无害)
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
} catch (e) {
// close 失败:取消超时,降级 destroy()
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e)
try {
await win.destroy()
} catch (e2) {
console.error('[ApprovalPopup] destroy() 兜底也失败:', e2)
}
}
}
/** 批准:risk 类 approve(true),path 类 authorizeDir('once') */
async function onApprove(tc: AiToolCallInfo) {
if (processingId.value === tc.id) return
@@ -212,12 +260,15 @@ async function onReject(tc: AiToolCallInfo) {
* 误以为卡死。空 = 完成,应即时关浮窗(不等待主窗口 watch closePopup 的跨窗口往返)。 */
async function maybeAutoClose() {
if (approvals.value.length > 0) return
// 先关窗(确保用户立刻看到反馈),再 emit 清主窗口引用(顺序与 onClose 一致)
// 关窗走 closeWithFallback 兜底(close 超时/失败 → destroy 强制销毁),防「正在关闭…」永久
await closeWithFallback()
try {
const { emit } = await import('@tauri-apps/api/event')
// 先关窗(确保用户立刻看到反馈),再 emit 清主窗口引用(顺序与 onClose 一致)
await getCurrentWebviewWindow().close()
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
} catch { /* ignore */ }
} catch (e) {
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
}
}
/** 点击「打开审批面板」:emit 主窗口聚焦 + togglePanel */
@@ -233,13 +284,14 @@ async function onActivate() {
* 改为先 close(即时反馈,core:window:allow-close 已授权),再 emit 清主窗口引用;
* 即便 emit 失败,主窗口 win.once('tauri://destroyed') 也会清 _popupWin(双保险)。 */
async function onClose() {
try {
await getCurrentWebviewWindow().close()
} catch { /* ignore */ }
// 关窗走 closeWithFallback 兜底(close 超时/失败 → destroy 强制销毁)
await closeWithFallback()
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
} catch { /* ignore */ }
} catch (e) {
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
}
}
// 拖动样式反馈(实际拖动由 data-tauri-drag-region + Tauri dataDrag 守卫处理)
+2
View File
@@ -533,4 +533,6 @@ async function runImport() {
}
.toast-info { background: var(--df-accent); color: #fff; }
.toast-error { background: var(--df-danger); color: #fff; }
/* P0-2: ToastType 新增 'success' 兜底样式(本视图当前未用,补全避免裸类) */
.toast-success { background: var(--df-success); color: #fff; }
</style>
+2
View File
@@ -221,6 +221,8 @@ onUnmounted(() => {
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
/* P0-2: 保存成功明确反馈(绿色),与 info(中性强调)区分 */
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }