新增: F-01模型拉取IPC+Settings拉取UI(FR-S1密钥内存解析闭环)

This commit is contained in:
2026-06-17 00:14:38 +08:00
parent 8a142c212c
commit a26b86e003
7 changed files with 257 additions and 2 deletions

View File

@@ -6,6 +6,8 @@ use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use df_ai::provider::ChatMessage;
// df-ai 重导出 df_ai_core(供下游直接引用 trait/类型);src-tauri 不直接依赖 df-ai-core crate。
use df_ai::df_ai_core::model::ModelConfig;
use df_core::types::new_id;
use df_storage::models::AiProviderRecord;
@@ -827,6 +829,78 @@ pub async fn ai_delete_provider(
Ok(())
}
// ============================================================
// 模型列表拉取 + 单模型探测(F-01 阶段5 IPC)
// ============================================================
/// 将前端 provider_type 规范化为 fetch_and_probe 接受的类型。
///
/// Settings.vue 存的 provider_type 是 "openai_compat" / "anthropic"(对齐 build_provider 工厂),
/// 而 model_fetch::fetch_and_probe 分派用 "openai_compat" / "anthropic_compat"。
/// 两个工厂入口类型语义一致(anthropic 协议),仅命名不同,这里收敛归一。
fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
match provider_type {
"anthropic" => "anthropic_compat".to_string(),
other => other.to_string(),
}
}
/// 测试连接并拉取厂商模型列表(F-01 阶段5)
///
/// 流程:
/// 1. DB 取 AiProviderRecord(get_by_id)
/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB,
/// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>
///
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
#[tauri::command]
pub async fn ai_fetch_models(
state: State<'_, AppState>,
provider_id: String,
) -> Result<Vec<ModelConfig>, String> {
let provider = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息)
let api_key = df_storage::secret::resolve_provider_secret(&provider);
let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type);
let configs = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key)
.await
.map_err(err_str)?;
// 写回 model_configs(更新 updated_at)
let mut updated = provider.clone();
updated.model_configs = configs.clone();
updated.updated_at = now_millis();
state
.ai_providers
.update_full(&updated)
.await
.map_err(err_str)?;
Ok(configs)
}
/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
///
/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。
/// 直接调 df_ai::model_probe::probe(&model_id),返回填充了 probe_source 的 ModelConfig。
#[tauri::command]
pub async fn ai_probe_model(
_state: State<'_, AppState>,
model_id: String,
) -> Result<ModelConfig, String> {
Ok(df_ai::model_probe::probe(&model_id))
}
// ============================================================
// 对话管理
// ============================================================

View File

@@ -122,6 +122,9 @@ pub fn run() {
commands::ai::ai_save_provider,
commands::ai::ai_set_provider,
commands::ai::ai_delete_provider,
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测
commands::ai::ai_fetch_models,
commands::ai::ai_probe_model,
// AI 对话管理
commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list,

View File

@@ -2,7 +2,7 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, SkillInfo } from './types'
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, ModelConfig, SkillInfo } from './types'
export const aiApi = {
/** 发送消息(非阻塞,通过 ai-chat-event 流式返回skill 传技能名则注入其 SKILL.md */
@@ -105,6 +105,25 @@ export const aiApi = {
return invoke('ai_delete_provider', { providerId })
},
/**
* 测试连接并拉取厂商模型列表(F-01 阶段5)。
* 后端:DB 取 provider → FR-S1 内存解析 api_key → fetch_and_probe
* (网络拉模型名 + 探测 4 维度)→ 写回 model_configs → 返回。
* 返回值 ModelConfig 不含 api_key(FR-S1 闭环)。
* 需已落库的 providerId(新建态先保存再拉取)。
*/
fetchModels(providerId: string): Promise<ModelConfig[]> {
return invoke('ai_fetch_models', { providerId })
},
/**
* 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
* 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。
*/
probeModel(modelId: string): Promise<ModelConfig> {
return invoke('ai_probe_model', { modelId })
},
/** 设置 LLM 调用并发上限(全局 / 单对话,运行时即时生效;软收敛不中断进行中调用) */
setConcurrencyConfig(globalLimit: number, perConvLimit: number): Promise<void> {
return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit })

View File

@@ -181,12 +181,57 @@ export interface AiProviderConfig {
base_url: string
default_model: string
models: string | null
/** 模型能力配置数组(F-01:4 维度 + 路由控制;DB TEXT 列 JSON 解析,老库可能缺失) */
model_configs?: ModelConfig[]
is_default: boolean
config: string | null
created_at: string
updated_at: string
}
// ============================================================
// F-01 模型能力系统(ModelConfig,与 crates/df-ai-core/src/model.rs 严格对齐)
// ============================================================
/** 维度 1 — 模态(模型能接收什么输入)。serde snake_case 字面量。 */
export type Modality = 'text' | 'vision' | 'audio' | 'video'
/** 维度 2 — 能力(模型能做什么)。serde snake_case 字面量。 */
export type Capability = 'tool_use' | 'embedding' | 'code_gen'
/** 维度 3 — 价格分级(成本)。serde snake_case 字面量。 */
export type CostTier = 'free' | 'low' | 'medium' | 'high'
/** 维度 4 — 智力分级。serde snake_case 字面量。 */
export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra'
/** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */
export type ProbeSource = 'user_set' | 'preset_table' | 'heuristic' | 'default'
/** 单模型完整配置(4 维度 + 路由控制 + 探测元数据)。 */
export interface ModelConfig {
/** 模型名(如 "glm-4v-flash") */
model_id: string
/** 启用/禁用(false=配了但不参与路由) */
enabled: boolean
/** 用户自定义别名(可选) */
label?: string
/** 模态集合 */
modalities: Modality[]
/** 能力集合 */
capabilities: Capability[]
/** 价格分级 */
cost_tier: CostTier
/** 智力分级 */
intelligence: IntelligenceTier
/** 权重(0-100,同能力候选中优先选权重高的) */
weight: number
/** 上下文窗口大小(tokens) */
context_window: number
/** 探测来源(可选) */
probe_source?: ProbeSource
}
/**
* AI 错误分类字面量集(B-260615-42)。
* 与后端 `commands::ai::ErrorType` 的 serde(rename_all = "snake_case") 严格对齐。

View File

@@ -29,6 +29,28 @@ export default {
providerTypeAnthropic: 'Anthropic-compatible (GLM subscription / Claude)',
saving: 'Saving...',
// Model fetch (F-01 phase 6)
testConnection: 'Test connection & fetch models',
fetchingModels: 'Fetching...',
fetchHintSaveFirst: 'Save the provider first, then fetch models',
labelWeight: 'Weight',
modelListTitle: 'Fetched models ({count})',
modelListHint: 'Enabled / weight are session-local edits; the next "Test connection" overwrites with fresh probe results',
saveModels: 'Save model config',
toastFetchOk: 'Fetched {count} models',
toastFetchFail: 'Fetch failed: {msg}',
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
fetchFailedAuth: 'Authentication failed, check your API Key: {msg}',
fetchFailedNotFound: 'Endpoint not found, this vendor may not support model listing: {msg}',
toastModelEditLocalOnly: 'Enabled / weight are local edits, overwritten on next fetch',
// Model trait tags (4 dimensions + probe source)
tagModality: { text: 'Text', vision: 'Vision', audio: 'Audio', video: 'Video' },
tagCapability: { tool_use: 'Tool use', embedding: 'Embedding', code_gen: 'Code' },
tagCost: { free: 'Free', low: 'Low', medium: 'Medium', high: 'High' },
tagIntel: { lite: 'Lite', standard: 'Standard', plus: 'Plus', ultra: 'Ultra' },
tagSource: { user_set: 'Manual', preset_table: 'Preset', heuristic: 'Heuristic', default: 'Default' },
// ===== Connection panel =====
panelConnection: '🔗 Connections',
addConnection: '+ Add connection',

View File

@@ -29,6 +29,28 @@ export default {
providerTypeAnthropic: 'Anthropic 兼容GLM 订阅 / Claude',
saving: '保存中...',
// 模型拉取(F-01 阶段6)
testConnection: '测试连接并拉取模型',
fetchingModels: '拉取中...',
fetchHintSaveFirst: '请先保存 Provider 后再拉取模型',
labelWeight: '权重',
modelListTitle: '已拉取模型({count} 个)',
modelListHint: '已启用 / 权重为本次会话内调整,下次「测试连接」会以最新探测结果覆盖',
saveModels: '保存模型配置',
toastFetchOk: '已拉取 {count} 个模型',
toastFetchFail: '拉取模型失败:{msg}',
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
fetchFailedAuth: '鉴权失败,请检查 API Key{msg}',
fetchFailedNotFound: '端点不存在,该厂商可能不支持模型列表拉取:{msg}',
toastModelEditLocalOnly: '已启用 / 权重为本地编辑,下次拉取时覆盖',
// 模型特征标签(4 维度 + 探测来源)
tagModality: { text: '文本', vision: '视觉', audio: '音频', video: '视频' },
tagCapability: { tool_use: '工具调用', embedding: '向量', code_gen: '代码' },
tagCost: { free: '免费', low: '低价', medium: '中价', high: '高价' },
tagIntel: { lite: '轻量', standard: '标准', plus: '增强', ultra: '旗舰' },
tagSource: { user_set: '手动', preset_table: '预设', heuristic: '推断', default: '默认' },
// ===== 连接管理面板 =====
panelConnection: '🔗 连接管理',
addConnection: '+ 添加连接',

View File

@@ -92,6 +92,38 @@
<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">
{{ 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) -->
<div class="form-field form-field--full" v-if="providerForm.models.length > 0">
<label class="form-label">{{ $t('settings.modelListTitle', { count: providerForm.models.length }) }}</label>
<div class="model-list">
<div class="model-row" v-for="m in providerForm.models" :key="m.model_id">
<div class="model-row-main">
<label class="toggle toggle--sm">
<input type="checkbox" v-model="m.enabled" />
<span class="toggle-slider"></span>
</label>
<span class="model-id">{{ m.model_id }}</span>
<div class="model-tags">
<span v-for="md in m.modalities" :key="'md-' + md" class="tag tag-modality">{{ $t('settings.tagModality.' + md) }}</span>
<span v-for="cap in m.capabilities" :key="'cap-' + cap" class="tag tag-capability">{{ $t('settings.tagCapability.' + cap) }}</span>
<span class="tag tag-cost" :class="'tag-cost--' + m.cost_tier">{{ $t('settings.tagCost.' + m.cost_tier) }}</span>
<span class="tag tag-intel" :class="'tag-intel--' + m.intelligence">{{ $t('settings.tagIntel.' + m.intelligence) }}</span>
<span v-if="m.probe_source" class="tag tag-source">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
</div>
</div>
<div class="model-row-side">
<label class="model-weight-label">{{ $t('settings.labelWeight') }}</label>
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
</div>
</div>
</div>
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
</div>
</div>
</section>
@@ -397,7 +429,7 @@ import { aiApi, knowledgeApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { useAiStore } from '@/stores/ai'
import { useConfirm } from '@/composables/useConfirm'
import type { AiProviderConfig } from '@/api/types'
import type { AiProviderConfig, ModelConfig } from '@/api/types'
import i18n from '@/i18n'
const { t } = useI18n()
@@ -437,6 +469,9 @@ const providerForm = reactive({
apiKey: '',
defaultModel: '',
saving: false,
// F-01 阶段6:模型拉取 + 列表
fetching: false,
models: [] as ModelConfig[],
})
async function loadProviders() {
@@ -458,6 +493,8 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.apiKey = '' // 编辑不回填 api_key(list 返回 mask,留空=不改,FR-S1)
providerForm.defaultModel = p.default_model
providerForm.providerType = p.provider_type || 'openai_compat'
// F-01 阶段6:回填已保存的 model_configs(深拷贝避免双向绑定污染列表源)
providerForm.models = (p.model_configs ?? []).map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
} else {
providerForm.editId = ''
providerForm.name = ''
@@ -465,10 +502,43 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.baseUrl = ''
providerForm.apiKey = ''
providerForm.defaultModel = ''
providerForm.models = []
}
providerForm.fetching = false
providerForm.visible = true
}
/**
* 测试连接并拉取模型列表(F-01 阶段6)。
* 后端 ai_fetch_models:DB 取 provider + 内存解析 api_key(FR-S1)+ 网络拉取 + 探测 4 维度 + 写回。
* 新建态(editId 空)无 provider_id → 按钮已 disabled,提示先保存。
*
* enabled toggle / weight input 为本地会话内编辑(仅本批展示交互,持久化另见 issues);
* 下次「测试连接」会以最新探测结果覆盖 model_configs 落库。
*/
async function fetchModels() {
if (!providerForm.editId) {
showToast(t('settings.fetchHintSaveFirst'), 'warning')
return
}
providerForm.fetching = true
try {
const models = await aiApi.fetchModels(providerForm.editId)
providerForm.models = models.map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
showToast(t('settings.toastFetchOk', { count: models.length }), 'info')
} catch (e) {
const msg = errMsg(e)
// 按 model_fetch 错误映射友好提示:超时 / 鉴权失败 / 端点不存在 / 厂商服务异常 / 不支持
let key = 'settings.toastFetchFail'
if (msg.includes('超时')) key = 'settings.fetchFailedTimeout'
else if (msg.includes('鉴权失败')) key = 'settings.fetchFailedAuth'
else if (msg.includes('端点不存在') || msg.includes('404')) key = 'settings.fetchFailedNotFound'
showToast(t(key, { msg }), 'error')
} finally {
providerForm.fetching = false
}
}
async function saveProvider() {
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId