|
|
|
@@ -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; }
|
|
|
|
|