diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 2c48dfae..7b7a01d1 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -80,7 +80,8 @@ export function initializeSchema(db: Database.Database): void { custom_prompt TEXT, use_custom_prompt INTEGER DEFAULT 0, concurrency INTEGER DEFAULT 1, - reasoning_effort TEXT + reasoning_effort TEXT, + mimo_plan TEXT ); CREATE TABLE IF NOT EXISTS webdav_configs ( @@ -109,6 +110,7 @@ export function initializeSchema(db: Database.Database): void { `); addColumnIfMissing(db, 'ai_configs', 'reasoning_effort', 'TEXT'); + addColumnIfMissing(db, 'ai_configs', 'mimo_plan', 'TEXT'); addColumnIfMissing(db, 'repositories', 'category_locked', 'INTEGER DEFAULT 0'); addColumnIfMissing(db, 'releases', 'zipball_url', 'TEXT'); addColumnIfMissing(db, 'releases', 'tarball_url', 'TEXT'); diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 15271730..0305dd1c 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -67,6 +67,7 @@ router.get('/api/configs/ai', (req, res) => { useCustomPrompt: !!row.use_custom_prompt, concurrency: row.concurrency ?? 1, reasoningEffort: row.reasoning_effort ?? null, + mimoPlan: row.mimo_plan ?? null, }; }); res.json(configs); @@ -80,18 +81,18 @@ router.get('/api/configs/ai', (req, res) => { router.post('/api/configs/ai', (req, res) => { try { const db = getDb(); - const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency, reasoningEffort } = req.body as Record; + const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency, reasoningEffort, mimoPlan } = req.body as Record; const encryptedKey = apiKey && typeof apiKey === 'string' ? encrypt(apiKey, config.encryptionKey) : null; const result = db.prepare( - 'INSERT INTO ai_configs (name, api_type, model, base_url, api_key_encrypted, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO ai_configs (name, api_type, model, base_url, api_key_encrypted, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort, mimo_plan) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( name ?? '', apiType ?? 'openai', model ?? '', baseUrl ?? null, - encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1, reasoningEffort ?? null + encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1, reasoningEffort ?? null, mimoPlan ?? null ); - res.status(201).json({ id: result.lastInsertRowid, name, apiType, model, baseUrl, apiKey: maskApiKey(apiKey as string), isActive: !!isActive, reasoningEffort: reasoningEffort ?? null }); + res.status(201).json({ id: result.lastInsertRowid, name, apiType, model, baseUrl, apiKey: maskApiKey(apiKey as string), isActive: !!isActive, reasoningEffort: reasoningEffort ?? null, mimoPlan: mimoPlan ?? null }); } catch (err) { logger.errorFromError('configs.createAI', 'POST /api/configs/ai error', err); res.status(500).json({ error: 'Failed to create AI config', code: 'CREATE_AI_CONFIG_FAILED' }); @@ -118,6 +119,7 @@ router.put('/api/configs/ai/bulk', (req, res) => { useCustomPrompt?: boolean; concurrency?: number; reasoningEffort?: string; + mimoPlan?: string; }>; if (!Array.isArray(configs)) { @@ -135,8 +137,8 @@ router.put('/api/configs/ai/bulk', (req, res) => { db.prepare('DELETE FROM ai_configs').run(); const stmt = db.prepare(` - INSERT INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort, mimo_plan) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const c of configs) { @@ -170,7 +172,7 @@ router.put('/api/configs/ai/bulk', (req, res) => { stmt.run( c.id, c.name ?? '', c.apiType ?? 'openai', c.baseUrl ?? '', encryptedKey, c.model ?? '', c.isActive ? 1 : 0, - c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1, c.reasoningEffort ?? null + c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1, c.reasoningEffort ?? null, c.mimoPlan ?? null ); syncResult.inserted++; } @@ -209,7 +211,7 @@ router.put('/api/configs/ai/:id', (req, res) => { try { const db = getDb(); const id = req.params.id; - const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency, reasoningEffort } = req.body as Record; + const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency, reasoningEffort, mimoPlan } = req.body as Record; let encryptedKey: string | null = null; if (apiKey && typeof apiKey === 'string' && !apiKey.startsWith('***')) { @@ -221,8 +223,8 @@ router.put('/api/configs/ai/:id', (req, res) => { } const result = db.prepare( - 'UPDATE ai_configs SET name = ?, api_type = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_active = ?, custom_prompt = ?, use_custom_prompt = ?, concurrency = ?, reasoning_effort = ? WHERE id = ?' - ).run(name ?? '', apiType ?? 'openai', model ?? '', baseUrl ?? null, encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1, reasoningEffort ?? null, id); + 'UPDATE ai_configs SET name = ?, api_type = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_active = ?, custom_prompt = ?, use_custom_prompt = ?, concurrency = ?, reasoning_effort = ?, mimo_plan = ? WHERE id = ?' + ).run(name ?? '', apiType ?? 'openai', model ?? '', baseUrl ?? null, encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1, reasoningEffort ?? null, mimoPlan ?? null, id); if (result.changes === 0) { res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); @@ -233,7 +235,7 @@ router.put('/api/configs/ai/:id', (req, res) => { try { maskedKey = maskApiKey(decrypt(encryptedKey, config.encryptionKey)); } catch { maskedKey = '****'; } } - res.json({ id, name, apiType, model, baseUrl, apiKey: maskedKey, isActive: !!isActive, reasoningEffort: reasoningEffort ?? null }); + res.json({ id, name, apiType, model, baseUrl, apiKey: maskedKey, isActive: !!isActive, reasoningEffort: reasoningEffort ?? null, mimoPlan: mimoPlan ?? null }); } catch (err) { logger.errorFromError('configs.updateAI', 'PUT /api/configs/ai error', err); res.status(500).json({ error: 'Failed to update AI config', code: 'UPDATE_AI_CONFIG_FAILED' }); diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index bc15a363..217c9bd0 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -176,7 +176,7 @@ router.post('/api/proxy/ai', async (req, res) => { 'Accept': 'application/json', }; - if (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible') { + if (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible' || apiType === 'deepseek' || apiType === 'mimo') { // openai-compatible 类型直接使用 baseUrl 作为完整地址 targetUrl = apiType === 'openai-compatible' ? baseUrl.replace(/\/$/, '') @@ -197,11 +197,14 @@ router.post('/api/proxy/ai', async (req, res) => { targetUrl = urlObj.toString(); } + // DeepSeek Reasoner does not support the reasoning parameter + const isDeepSeekReasoner = model.trim() === 'deepseek-reasoner'; const effectiveRequestBody = ( reasoningEffort + && !isDeepSeekReasoner && typeof requestBody === 'object' && requestBody !== null - && (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible') + && (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible' || apiType === 'deepseek' || apiType === 'mimo') && !('reasoning' in requestBody) ) ? { ...requestBody, reasoning: { effort: reasoningEffort } } diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index 2368449c..b53daf7e 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -200,13 +200,14 @@ router.post('/api/sync/import', (req, res) => { const existingKey = (existing?.api_key_encrypted as string) ?? null; // Skip masked keys, keep existing encrypted value db.prepare(` - INSERT OR REPLACE INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort, mimo_plan) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( c.id, c.name ?? '', c.api_type ?? c.apiType ?? 'openai', c.base_url ?? c.baseUrl ?? null, existingKey, c.model ?? '', (c.is_active ?? c.isActive) ? 1 : 0, c.custom_prompt ?? c.customPrompt ?? null, - (c.use_custom_prompt ?? c.useCustomPrompt) ? 1 : 0, c.concurrency ?? 1, c.reasoning_effort ?? c.reasoningEffort ?? null + (c.use_custom_prompt ?? c.useCustomPrompt) ? 1 : 0, c.concurrency ?? 1, c.reasoning_effort ?? c.reasoningEffort ?? null, + c.mimo_plan ?? c.mimoPlan ?? null ); } counts.ai_configs = aiConfigs.length; diff --git a/server/src/types/api.ts b/server/src/types/api.ts index 04741672..5c2b3034 100644 --- a/server/src/types/api.ts +++ b/server/src/types/api.ts @@ -67,6 +67,7 @@ export interface AIConfigRow { use_custom_prompt: number; concurrency: number; reasoning_effort: string | null; + mimo_plan: string | null; } export interface WebDAVConfigRow { @@ -128,6 +129,7 @@ export interface SyncAIConfigsRequest { useCustomPrompt?: boolean; concurrency?: number; reasoningEffort?: string; + mimoPlan?: string; }>; } diff --git a/src/components/settings/AIConfigPanel.tsx b/src/components/settings/AIConfigPanel.tsx index 3a32559b..a1e2575b 100644 --- a/src/components/settings/AIConfigPanel.tsx +++ b/src/components/settings/AIConfigPanel.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { Bot, Plus, Edit3, Trash2, Save, X, TestTube, RefreshCw, MessageSquare, Eye, EyeOff, AlertCircle } from 'lucide-react'; -import { AIConfig, AIApiType, AIReasoningEffort } from '../../types'; +import { AIConfig, AIApiType, AIReasoningEffort, MiMoPlan } from '../../types'; import { useAppStore } from '../../store/useAppStore'; import { AIService } from '../../services/aiService'; import { buildFinalApiUrl } from '../../utils/apiUrlBuilder'; @@ -21,6 +21,12 @@ type AIFormState = { useCustomPrompt: boolean; concurrency: number; reasoningEffort: '' | AIReasoningEffort; + mimoPlan: MiMoPlan; +}; + +const MIMO_PLAN_ENDPOINTS: Record = { + api: 'https://api.xiaomimimo.com/v1', + 'token-plan': 'https://token-plan-cn.xiaomimimo.com/v1', }; const DEFAULT_API_ENDPOINTS: Record = { @@ -28,9 +34,44 @@ const DEFAULT_API_ENDPOINTS: Record = { 'openai-responses': 'https://api.openai.com/v1', claude: 'https://api.anthropic.com/v1', gemini: 'https://generativelanguage.googleapis.com/v1beta', + deepseek: 'https://api.deepseek.com', + mimo: MIMO_PLAN_ENDPOINTS.api, 'openai-compatible': '', }; +function getEndpointPlaceholder(apiType: AIApiType, mimoPlan: MiMoPlan): string { + switch (apiType) { + case 'openai': + case 'openai-responses': + return 'https://api.openai.com/v1'; + case 'claude': + return 'https://api.anthropic.com/v1'; + case 'deepseek': + return 'https://api.deepseek.com'; + case 'mimo': + return MIMO_PLAN_ENDPOINTS[mimoPlan]; + case 'openai-compatible': + return 'https://integrate.api.nvidia.com/v1/chat/completions'; + default: + return 'https://generativelanguage.googleapis.com/v1beta'; + } +} + +function getEndpointHelpText(apiType: AIApiType, t: (zh: string, en: string) => string): string { + switch (apiType) { + case 'openai-compatible': + return t('填写完整的API调用地址,包含完整路径', 'Enter the full API endpoint URL including the complete path'); + case 'gemini': + return t('只填到 v1beta 即可,路径会自动生成', 'Only include the version prefix v1beta, the path will be generated automatically'); + case 'deepseek': + return t('填写到域名即可(如 https://api.deepseek.com),路径会自动生成', 'Only include the domain (e.g. https://api.deepseek.com), the path will be generated automatically'); + case 'mimo': + return t('填写到 /v1 即可(如 https://api.xiaomimimo.com/v1),路径会自动生成', 'Only include up to /v1 (e.g. https://api.xiaomimimo.com/v1), the path will be generated automatically'); + default: + return t('只填到版本号即可(如 .../v1 或 .../v1beta),不要包含 /chat/completions、/responses、/messages', 'Only include the version prefix (e.g. .../v1 or .../v1beta). Do not include /chat/completions, /responses, or /messages.'); + } +} + export const AIConfigPanel: React.FC = ({ t }) => { const { aiConfigs, @@ -79,6 +120,7 @@ export const AIConfigPanel: React.FC = ({ t }) => { useCustomPrompt: false, concurrency: 1, reasoningEffort: '', + mimoPlan: 'api', }); // Auto-fill baseUrl when API type changes @@ -99,6 +141,18 @@ export const AIConfigPanel: React.FC = ({ t }) => { } }, [form.apiType, form.baseUrl]); + // Auto-fill baseUrl when MiMo plan changes + const prevMimoPlanRef = useRef('api'); + useEffect(() => { + if (form.apiType === 'mimo' && form.mimoPlan !== prevMimoPlanRef.current) { + const prevEndpoint = MIMO_PLAN_ENDPOINTS[prevMimoPlanRef.current]; + if (form.baseUrl === '' || form.baseUrl === prevEndpoint) { + setForm(prev => ({ ...prev, baseUrl: MIMO_PLAN_ENDPOINTS[form.mimoPlan] })); + } + prevMimoPlanRef.current = form.mimoPlan; + } + }, [form.apiType, form.mimoPlan]); + const resetForm = () => { setForm({ name: '', @@ -110,12 +164,14 @@ export const AIConfigPanel: React.FC = ({ t }) => { useCustomPrompt: false, concurrency: 1, reasoningEffort: '', + mimoPlan: 'api', }); setShowForm(false); setEditingId(null); setShowCustomPrompt(false); setShowDefaultPrompt(false); prevApiTypeRef.current = 'openai'; + prevMimoPlanRef.current = 'api'; }; const handleSave = () => { @@ -137,6 +193,7 @@ export const AIConfigPanel: React.FC = ({ t }) => { useCustomPrompt: form.useCustomPrompt, concurrency: form.concurrency, reasoningEffort: form.reasoningEffort || undefined, + mimoPlan: form.apiType === 'mimo' ? form.mimoPlan : undefined, isActive: existingConfig.isActive, }; updateAIConfig(editingId, updates); @@ -154,6 +211,7 @@ export const AIConfigPanel: React.FC = ({ t }) => { useCustomPrompt: form.useCustomPrompt, concurrency: form.concurrency, reasoningEffort: form.reasoningEffort || undefined, + mimoPlan: form.apiType === 'mimo' ? form.mimoPlan : undefined, }; addAIConfig(config); } @@ -164,6 +222,7 @@ export const AIConfigPanel: React.FC = ({ t }) => { const handleEdit = (config: AIConfig) => { // Sync ref to prevent auto-fill effect from overwriting loaded config prevApiTypeRef.current = config.apiType || 'openai'; + prevMimoPlanRef.current = config.mimoPlan || 'api'; setForm({ name: config.name, apiType: config.apiType || 'openai', @@ -174,6 +233,7 @@ export const AIConfigPanel: React.FC = ({ t }) => { useCustomPrompt: config.useCustomPrompt || false, concurrency: config.concurrency || 1, reasoningEffort: config.reasoningEffort || '', + mimoPlan: config.mimoPlan || 'api', }); setEditingId(config.id); setShowForm(true); @@ -392,9 +452,32 @@ Repository information: + + + + {form.apiType === 'mimo' && ( +
+ + +

+ {form.mimoPlan === 'api' + ? t('API Key 以 sk- 开头,端点 api.xiaomimimo.com', 'API Key starts with sk-, endpoint api.xiaomimimo.com') + : t('API Key 以 tp- 开头,端点 token-plan-cn.xiaomimimo.com', 'API Key starts with tp-, endpoint token-plan-cn.xiaomimimo.com')} +

+
+ )}