优化: run_command 实时流式 + 审批浮窗修复 + 文档探索闭环(fetch_url+obscura引导+model_fetch兼容+prompt策略+厂商预设)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '已设为默认',
|
||||
|
||||
@@ -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 守卫处理)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user