Skip to content
4 changes: 3 additions & 1 deletion server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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');
Expand Down
24 changes: 13 additions & 11 deletions server/src/routes/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<string, unknown>;
const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency, reasoningEffort, mimoPlan } = req.body as Record<string, unknown>;

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' });
Expand All @@ -118,6 +119,7 @@ router.put('/api/configs/ai/bulk', (req, res) => {
useCustomPrompt?: boolean;
concurrency?: number;
reasoningEffort?: string;
mimoPlan?: string;
}>;

if (!Array.isArray(configs)) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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++;
}
Expand Down Expand Up @@ -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<string, unknown>;
const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency, reasoningEffort, mimoPlan } = req.body as Record<string, unknown>;

let encryptedKey: string | null = null;
if (apiKey && typeof apiKey === 'string' && !apiKey.startsWith('***')) {
Expand All @@ -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' });
Expand All @@ -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' });
Expand Down
7 changes: 5 additions & 2 deletions server/src/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/$/, '')
Expand All @@ -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 } }
Expand Down
7 changes: 4 additions & 3 deletions server/src/routes/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions server/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface AIConfigRow {
use_custom_prompt: number;
concurrency: number;
reasoning_effort: string | null;
mimo_plan: string | null;
}

export interface WebDAVConfigRow {
Expand Down
110 changes: 86 additions & 24 deletions src/components/settings/AIConfigPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,16 +21,57 @@ type AIFormState = {
useCustomPrompt: boolean;
concurrency: number;
reasoningEffort: '' | AIReasoningEffort;
mimoPlan: MiMoPlan;
};

const MIMO_PLAN_ENDPOINTS: Record<MiMoPlan, string> = {
api: 'https://api.xiaomimimo.com/v1',
'token-plan': 'https://token-plan-cn.xiaomimimo.com/v1',
};

const DEFAULT_API_ENDPOINTS: Record<AIApiType, string> = {
openai: 'https://api.openai.com/v1',
'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<AIConfigPanelProps> = ({ t }) => {
const {
aiConfigs,
Expand Down Expand Up @@ -79,6 +120,7 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
useCustomPrompt: false,
concurrency: 1,
reasoningEffort: '',
mimoPlan: 'api',
});

// Auto-fill baseUrl when API type changes
Expand All @@ -99,6 +141,18 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
}
}, [form.apiType, form.baseUrl]);

// Auto-fill baseUrl when MiMo plan changes
const prevMimoPlanRef = useRef<MiMoPlan>('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: '',
Expand All @@ -110,12 +164,14 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
useCustomPrompt: false,
concurrency: 1,
reasoningEffort: '',
mimoPlan: 'api',
});
setShowForm(false);
setEditingId(null);
setShowCustomPrompt(false);
setShowDefaultPrompt(false);
prevApiTypeRef.current = 'openai';
prevMimoPlanRef.current = 'api';
};

const handleSave = () => {
Expand All @@ -137,6 +193,7 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
useCustomPrompt: form.useCustomPrompt,
concurrency: form.concurrency,
reasoningEffort: form.reasoningEffort || undefined,
mimoPlan: form.apiType === 'mimo' ? form.mimoPlan : undefined,
isActive: existingConfig.isActive,
};
updateAIConfig(editingId, updates);
Expand All @@ -154,6 +211,7 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
useCustomPrompt: form.useCustomPrompt,
concurrency: form.concurrency,
reasoningEffort: form.reasoningEffort || undefined,
mimoPlan: form.apiType === 'mimo' ? form.mimoPlan : undefined,
};
addAIConfig(config);
}
Expand All @@ -164,6 +222,7 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ 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',
Expand All @@ -174,6 +233,7 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
useCustomPrompt: config.useCustomPrompt || false,
concurrency: config.concurrency || 1,
reasoningEffort: config.reasoningEffort || '',
mimoPlan: config.mimoPlan || 'api',
});
setEditingId(config.id);
setShowForm(true);
Expand Down Expand Up @@ -392,9 +452,32 @@ Repository information:
<option value="openai-responses">OpenAI (Responses)</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="deepseek">DeepSeek</option>
<option value="mimo">Xiaomi MiMo</option>
<option value="openai-compatible">OpenAI Compatible (Custom Endpoint)</option>
</select>
</div>

{form.apiType === 'mimo' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-text-secondary mb-1">
{t('MiMo 渠道', 'MiMo Channel')} *
</label>
<select
value={form.mimoPlan}
onChange={(e) => setForm(prev => ({ ...prev, mimoPlan: e.target.value as MiMoPlan }))}
className="w-full px-3 py-2 border border-black/[0.06] dark:border-white/[0.04] rounded-lg bg-white dark:bg-panel-dark text-gray-900 dark:text-text-primary focus:ring-2 focus:ring-brand-violet focus:border-transparent focus:outline-none"
>
<option value="api">{t('API(按量付费)', 'API (Pay-as-you-go)')}</option>
<option value="token-plan">{t('Token Plan(订阅制)', 'Token Plan (Subscription)')}</option>
</select>
<p className="text-xs text-gray-500 dark:text-text-tertiary mt-1">
{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')}
</p>
</div>
)}

<div>
<label className="block text-sm font-medium text-gray-900 dark:text-text-secondary mb-1">
Expand All @@ -405,31 +488,10 @@ Repository information:
value={form.baseUrl}
onChange={(e) => setForm(prev => ({ ...prev, baseUrl: e.target.value }))}
className="w-full px-3 py-2 border border-black/[0.06] dark:border-white/[0.04] rounded-lg bg-white dark:bg-panel-dark text-gray-900 dark:text-text-primary focus:ring-2 focus:ring-brand-violet focus:border-transparent focus:outline-none"
placeholder={
form.apiType === 'openai' || form.apiType === 'openai-responses'
? 'https://api.openai.com/v1'
: form.apiType === 'claude'
? 'https://api.anthropic.com/v1'
: form.apiType === 'openai-compatible'
? 'https://integrate.api.nvidia.com/v1/chat/completions'
: 'https://generativelanguage.googleapis.com/v1beta'
}
placeholder={getEndpointPlaceholder(form.apiType, form.mimoPlan)}
/>
<p className="text-xs text-gray-500 dark:text-text-tertiary mt-1">
{form.apiType === 'openai-compatible'
? t(
'填写完整的API调用地址,包含完整路径',
'Enter the full API endpoint URL including the complete path'
)
: form.apiType === 'gemini'
? t(
'只填到 v1beta 即可,路径会自动生成',
'Only include the version prefix v1beta, the path will be generated automatically'
)
: 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.'
)}
{getEndpointHelpText(form.apiType, t)}
</p>
{form.baseUrl && (
<p className="text-xs text-gray-500 dark:text-text-tertiary mt-1">
Expand Down
Loading
Loading