优化: Provider 配置体验(测试读表单值 + 保存反馈 + 报错友好 + 厂商预设,部分完成)

This commit is contained in:
lxy
2026-08-01 16:35:49 +08:00
parent 1a5d543fa1
commit 0e0c6862ba
8 changed files with 168 additions and 18 deletions
@@ -365,6 +365,53 @@ pub async fn ai_fetch_models(
Ok(merged)
}
/// 一次性测试连接(P0-1):用**表单当前值**而非 DB 值测试,不写库。
///
/// 解决:`ai_fetch_models` 从 DB 读 provider,用户改了表单未保存就点「测试连接」时
/// 测的是旧值(base_url/api_key 已改但 DB 未落),给出误导性「成功/失败」。
///
/// 行为:
/// - api_key 非空 → 直接用表单值(base_url + api_key)测,完全不读 DB。
/// - api_key 空 + provider_id 给出 → 编辑态用户没重填密钥(留空=不改),
/// 从 DB 取该 provider 记录经 FR-S1 内存解析出真实 key,base_url 仍用表单值
/// (用户可能改了 URL,密钥沿用旧)。
/// - api_key 空 + provider_id 空 → 新建态没填 key,直接报错(无来源可解析)。
///
/// 一次性:不写回 model_configs(测试≠落库);成功后用户可继续编辑或保存。
/// 保留 `ai_fetch_models`(从 DB 读 + 写回,用于「已保存配置重测」场景),本 IPC 不破坏它。
#[tauri::command]
pub async fn ai_test_connection(
state: State<'_, AppState>,
provider_type: String,
base_url: String,
api_key: String,
provider_id: Option<String>,
) -> Result<Vec<ModelConfig>, String> {
// 解析测试用 api_key:表单非空用表单;空则从 DB provider 沿用旧密钥(编辑不改 key 场景)
let api_key = if !api_key.is_empty() {
api_key
} else {
let pid = provider_id.ok_or_else(|| {
"测试连接失败:api_key 为空且无已保存配置可沿用,请填写 api_key".to_string()
})?;
let provider = state
.ai_providers
.get_by_id(&pid)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", pid))?;
// FR-S1:内存解析,绝不外泄(不入日志/返回值/错误信息)
df_storage::secret::resolve_provider_secret(&provider)
};
let fetch_type = normalize_provider_type_for_fetch(&provider_type);
let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &base_url, &api_key)
.await
.map_err(err_str)?;
Ok(probed)
}
/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
///
/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。
+2
View File
@@ -442,6 +442,8 @@ pub fn run() {
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测
commands::ai::ai_fetch_models,
commands::ai::ai_probe_model,
// P0-1:用表单当前值一次性测试连接(不写库,新建/编辑未保存均可测)
commands::ai::ai_test_connection,
// AI 对话管理
commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list,
+21
View File
@@ -204,6 +204,27 @@ export const aiApi = {
return invoke('ai_fetch_models', { providerId })
},
/**
* 一次性测试连接(P0-1):用**表单当前值**测试,不写库。
* - 新建态:传表单填的 providerType/baseUrl/apiKey,providerId=null。
* - 编辑态(apiKey 留空=不改):传 providerId 让后端从 DB 沿用旧密钥;
* apiKey 非空则直接用表单新值。
* 成功返回模型列表(ModelConfig,不含 api_key),失败抛错。
*/
testConnection(input: {
providerType: string
baseUrl: string
apiKey: string
providerId?: string | null
}): Promise<ModelConfig[]> {
return invoke('ai_test_connection', {
providerType: input.providerType,
baseUrl: input.baseUrl,
apiKey: input.apiKey,
providerId: input.providerId ?? null,
})
},
/**
* 单模型探测:纯 CPU 启发式 + 预设表,无网络。
* 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。
+2
View File
@@ -790,6 +790,8 @@ async function initContextEventListeners(): Promise<void> {
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
/* P0-2: ToastType 新增 'success' 兜底样式 */
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
+1 -1
View File
@@ -87,7 +87,7 @@ import SettingRow from './SettingRow.vue'
const { t } = useI18n()
const appSettings = useAppSettingsStore()
// F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void }>()
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
// 即时持久化:添加/删除立即写后端 KV + 内存白名单,无需显式"保存"(符合"操作即生效"心智,
+2 -1
View File
@@ -81,7 +81,8 @@ const { t } = useI18n()
const appSettings = useAppSettingsStore()
const emit = defineEmits<{
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void
// P0-2: 对齐 ToastType 加 'success'(本面板暂未用,保类型一致)
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
}>()
const props = defineProps<{
+91 -15
View File
@@ -87,14 +87,19 @@
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
</div>
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)
含分类建议:keyring/迁移失败 查系统钥匙串权限;其它 通用重试
用户看到错误同时表单保持编辑态,改完点保存即可重试 -->
<div v-if="providerForm.saveError" class="save-error-banner">
<pre class="save-error-msg">{{ providerForm.saveError }}</pre>
</div>
<div class="form-actions">
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
</button>
<button class="btn btn-ghost" @click="fetchModels" :disabled="providerForm.fetching || !providerForm.editId">
<button class="btn btn-ghost" @click="fetchModels" :disabled="providerForm.fetching || !canTestConnection">
{{ providerForm.fetching ? $t('settings.fetchingModels') : $t('settings.testConnection') }}
</button>
<span v-if="!providerForm.editId" class="form-hint">{{ $t('settings.fetchHintSaveFirst') }}</span>
</div>
<!-- 拉取到的模型列表(F-01 阶段6) -->
@@ -127,7 +132,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig } from '@/api/types'
@@ -148,7 +153,8 @@ async function syncAiStoreProviders() {
}
const emit = defineEmits<{
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void
// P0-2: 加 'success'(保存成功绿色 toast)
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
(e: 'providers-changed'): void
}>()
@@ -172,6 +178,10 @@ const providerForm = reactive({
// F-01 阶段6:模型拉取 + 列表
fetching: false,
models: [] as ModelConfig[],
// P0-2: 保存失败的持久错误横幅(非自隐 toast)。
// toast 3s 自隐易被忽略,保存失败时表单内常驻错误条 + 可操作建议,
// 直到下次保存尝试/重开表单才清除。null=无错误。
saveError: null as string | null,
})
async function loadProviders() {
@@ -207,25 +217,44 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.models = []
}
providerForm.fetching = false
providerForm.saveError = null // P0-2: 重开表单清旧错误横幅
providerForm.visible = true
}
/**
* 测试连接并拉取模型列表(F-01 阶段6)。
* 后端 ai_fetch_models:DB 取 provider + 内存解析 api_key(FR-S1)+ 网络拉取 + 探测 4 维度 + 写回。
* 新建态(editId 空)无 provider_id → 按钮已 disabled,提示先保存。
* 测试连接按钮可用条件(P0-1):
* - baseUrl 必填(无论新建/编辑,表单值都要有)
* - 新建态(editId 空):api_key 必填(无已保存配置可沿用)
* - 编辑态(editId 有):api_key 可空(留空=沿用已保存密钥)
*/
const canTestConnection = computed(() => {
if (!providerForm.baseUrl) return false
if (!providerForm.editId && !providerForm.apiKey) return false
return true
})
/**
* 测试连接并拉取模型列表(P0-1)。
* 用**表单当前值**测试(非 DB 值):新建态直接传表单值;编辑态传表单值 + editId,
* apiKey 留空时后端从已保存配置沿用密钥。一次性测试不写库。
*
* enabled toggle / weight input 为本地会话内编辑(仅本批展示交互,持久化另见 issues);
* 下次「测试连接」会以最新探测结果覆盖 model_configs 落库。
* 注:本路径成功后仅更新 model_configs 视图,不落库;用户需点「保存」才会持久化
* (避免「测试中」产生半成品配置)。原 ai_fetch_models(从 DB 读 + 写回)保留,
* 供未来「已保存配置重测」场景,当前 UI 不再走它。
*/
async function fetchModels() {
if (!providerForm.editId) {
emit('toast', t('settings.fetchHintSaveFirst'), 'warning')
if (!canTestConnection.value) {
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
return
}
providerForm.fetching = true
try {
const models = await aiApi.fetchModels(providerForm.editId)
const models = await aiApi.testConnection({
providerType: providerForm.providerType,
baseUrl: providerForm.baseUrl,
apiKey: providerForm.apiKey,
providerId: providerForm.editId || null,
})
providerForm.models = models.map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info')
} catch (e) {
@@ -241,6 +270,29 @@ async function fetchModels() {
}
}
/**
* P0-2: 把后端返回的错误文本分类成「用户可操作的建议」。
*
* 后端 commands/provider.rs 已对密钥相关失败返回友好中文 Err(含原因+建议),
* 这里只做轻量分类挂上"建议下一步",避免裸 Err 文本吓到用户:
* - keyring/迁移类:后端 Err 含「钥匙串」/「迁移」→ 指引查系统钥匙串权限
* - 其余(DB/字段/未知):透传后端文案,笼统提示重试
*
* 注:不依赖具体 Err 子串判定成败(后端 Err 语义=失败),分类仅用于建议文案。
*/
function classifySaveError(raw: string): { title: string; detail: string } {
if (raw.includes('钥匙串') || raw.includes('迁移')) {
return {
title: t('settings.saveErrKeyringTitle'),
detail: t('settings.saveErrKeyringDetail', { msg: raw }),
}
}
return {
title: t('settings.saveErrGenericTitle'),
detail: t('settings.saveErrGenericDetail', { msg: raw }),
}
}
async function saveProvider() {
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId
@@ -259,12 +311,19 @@ async function saveProvider() {
defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models,
})
// P0-2: 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
providerForm.saveError = null
providerForm.visible = false
await loadProviders()
emit('toast', t('settings.toastSaved'), 'info')
emit('toast', t('settings.toastSaved'), 'success')
} catch (e) {
console.error(t('settings.toastSaveFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastSaveFail', { msg: errMsg(e) }), 'error')
// P0-2: 失败时①表单保持编辑态(不关)②表单内常驻错误横幅(分类建议,非自隐)
// ③仍发 error toast(用户视线不在表单时仍可见),双通道确保反馈不被忽略。
const raw = errMsg(e)
console.error(t('settings.toastSaveFail', { msg: raw }), e)
const { title, detail } = classifySaveError(raw)
providerForm.saveError = title + '\n' + detail
emit('toast', t('settings.toastSaveFail', { msg: raw }), 'error')
} finally {
providerForm.saving = false
}
@@ -436,6 +495,23 @@ onMounted(loadProviders)
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
/* P0-2: 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
.save-error-banner {
background: var(--df-danger-bg);
border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm);
padding: 10px 12px;
}
.save-error-msg {
margin: 0;
font-family: var(--df-font-sans);
font-size: 12px;
line-height: 1.55;
color: var(--df-danger);
white-space: pre-wrap; /* 保留 title\ndetail 换行 + 长建议自动折行 */
word-break: break-word;
}
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
.setting-input:focus { border-color: var(--df-accent); }
.setting-input--sm { min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
+2 -1
View File
@@ -13,7 +13,8 @@
*/
import { reactive, onUnmounted } from 'vue'
export type ToastType = 'info' | 'error' | 'warning'
// P0-2: 加 'success'(保存成功用绿色 toast,此前成功/中性都用 info 致反馈不明确)
export type ToastType = 'info' | 'error' | 'warning' | 'success'
export interface ToastState {
visible: boolean