From a26b86e003c79d7b9460f685cd361aea7159a6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 17 Jun 2026 00:14:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20F-01=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8B=89=E5=8F=96IPC+Settings=E6=8B=89=E5=8F=96UI(FR-S1?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=86=85=E5=AD=98=E8=A7=A3=E6=9E=90=E9=97=AD?= =?UTF-8?q?=E7=8E=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/ai/commands.rs | 74 +++++++++++++++++++++++++++ src-tauri/src/lib.rs | 3 ++ src/api/ai.ts | 21 +++++++- src/api/types.ts | 45 ++++++++++++++++ src/i18n/en/settings.ts | 22 ++++++++ src/i18n/zh-CN/settings.ts | 22 ++++++++ src/views/Settings.vue | 72 +++++++++++++++++++++++++- 7 files changed, 257 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs index ce68be6..aec5ded 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands.rs @@ -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(每个模型名已探测出 4 维度) +/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec +/// +/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。 +#[tauri::command] +pub async fn ai_fetch_models( + state: State<'_, AppState>, + provider_id: String, +) -> Result, 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 { + Ok(df_ai::model_probe::probe(&model_id)) +} + // ============================================================ // 对话管理 // ============================================================ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa073ab..3e213ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/api/ai.ts b/src/api/ai.ts index 606db74..267af04 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -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 { + return invoke('ai_fetch_models', { providerId }) + }, + + /** + * 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。 + * 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。 + */ + probeModel(modelId: string): Promise { + return invoke('ai_probe_model', { modelId }) + }, + /** 设置 LLM 调用并发上限(全局 / 单对话,运行时即时生效;软收敛不中断进行中调用) */ setConcurrencyConfig(globalLimit: number, perConvLimit: number): Promise { return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit }) diff --git a/src/api/types.ts b/src/api/types.ts index b95ef26..b9e508b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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") 严格对齐。 diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index 208a71a..8dfd9e6 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -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', diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index a6cef75..38925bf 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -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: '+ 添加连接', diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 2da2efe..10732de 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -92,6 +92,38 @@ + + {{ $t('settings.fetchHintSaveFirst') }} + + + +
+ +
+
+
+ + {{ m.model_id }} +
+ {{ $t('settings.tagModality.' + md) }} + {{ $t('settings.tagCapability.' + cap) }} + {{ $t('settings.tagCost.' + m.cost_tier) }} + {{ $t('settings.tagIntel.' + m.intelligence) }} + {{ $t('settings.tagSource.' + m.probe_source) }} +
+
+
+ + +
+
+
+
{{ $t('settings.modelListHint') }}
@@ -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