@miphamai/cli 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config/loader.ts +55 -1
- package/src/i18n-core/locales/en-US.json +11 -2
- package/src/i18n-core/locales/zh-CN.json +11 -2
- package/src/providers/openai-compat.ts +51 -0
- package/src/shared/constants.ts +21 -0
- package/src/ui/app.tsx +136 -8
- package/src/ui/commands.ts +93 -0
- package/src/ui/config-wizard.tsx +138 -24
- package/src/ui/picker.tsx +11 -1
package/package.json
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -6,10 +6,11 @@ import {
|
|
|
6
6
|
readdirSync,
|
|
7
7
|
unlinkSync,
|
|
8
8
|
chmodSync,
|
|
9
|
+
writeFileSync,
|
|
9
10
|
} from 'node:fs'
|
|
10
11
|
import { join } from 'node:path'
|
|
11
12
|
import { homedir } from 'node:os'
|
|
12
|
-
import { parse as parseYaml } from 'yaml'
|
|
13
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
13
14
|
import type {
|
|
14
15
|
MiphamConfig,
|
|
15
16
|
ProviderConfig,
|
|
@@ -409,3 +410,56 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
|
|
|
409
410
|
|
|
410
411
|
return merged
|
|
411
412
|
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Persist an API key for a single provider to the user-level config.yml.
|
|
416
|
+
* Reads the existing YAML, updates/replaces the provider's apiKey field,
|
|
417
|
+
* and writes it back. Creates the config if it doesn't exist.
|
|
418
|
+
*
|
|
419
|
+
* Returns true on success, false on failure.
|
|
420
|
+
*/
|
|
421
|
+
export function saveProviderApiKey(
|
|
422
|
+
providerId: string,
|
|
423
|
+
apiKey: string,
|
|
424
|
+
cwd: string = process.cwd(),
|
|
425
|
+
): boolean {
|
|
426
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
427
|
+
const projectConfigPath = join(cwd, '.mipham', 'config.yml')
|
|
428
|
+
|
|
429
|
+
// Prefer user-level config; fall back to project-level if no user config exists.
|
|
430
|
+
const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
|
|
434
|
+
|
|
435
|
+
// Read existing config (or start fresh)
|
|
436
|
+
let doc: Record<string, unknown> = {}
|
|
437
|
+
if (existsSync(configPath)) {
|
|
438
|
+
const raw = readFileSync(configPath, 'utf-8')
|
|
439
|
+
doc = (parseYaml(raw) as Record<string, unknown>) || {}
|
|
440
|
+
} else if (configPath === projectConfigPath) {
|
|
441
|
+
mkdirSync(join(cwd, '.mipham'), { recursive: true })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Find and update the provider in the providers array
|
|
445
|
+
const providers = (doc.providers as Array<Record<string, unknown>>) || []
|
|
446
|
+
const idx = providers.findIndex((p) => p.id === providerId)
|
|
447
|
+
|
|
448
|
+
if (idx >= 0) {
|
|
449
|
+
providers[idx] = { ...providers[idx], apiKey }
|
|
450
|
+
} else {
|
|
451
|
+
// Provider not in config — append it
|
|
452
|
+
providers.push({ id: providerId, apiKey })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
doc.providers = providers
|
|
456
|
+
|
|
457
|
+
// Write back
|
|
458
|
+
writeFileSync(configPath, stringifyYaml(doc), { encoding: 'utf-8', mode: 0o600 })
|
|
459
|
+
return true
|
|
460
|
+
} catch (err: unknown) {
|
|
461
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
462
|
+
process.stderr.write(`⚠ Mipham Code: failed to save API key for ${providerId}: ${msg}\n`)
|
|
463
|
+
return false
|
|
464
|
+
}
|
|
465
|
+
}
|
|
@@ -190,7 +190,9 @@
|
|
|
190
190
|
},
|
|
191
191
|
"switch": {
|
|
192
192
|
"usage": "Usage: /switch <provider> <model>\nExample: /switch deepseek deepseek-v4-pro",
|
|
193
|
-
"confirmed": "✓ Switched to {provider}/{model}"
|
|
193
|
+
"confirmed": "✓ Switched to {provider}/{model}",
|
|
194
|
+
"needs_api_key": "{provider} requires an API Key. Enter it below, or use Ctrl+P to select a model and input.",
|
|
195
|
+
"unknown_provider": "Unknown provider: {provider}"
|
|
194
196
|
},
|
|
195
197
|
"goal": {
|
|
196
198
|
"set": "✓ Goal set: \"{goal}\"",
|
|
@@ -240,6 +242,11 @@
|
|
|
240
242
|
"header": "Provider Model Context Vision",
|
|
241
243
|
"hint": "Use /switch <provider> <model> to change.\nSee /providers for upcoming models."
|
|
242
244
|
},
|
|
245
|
+
"ollama_refresh": {
|
|
246
|
+
"not_configured": "Ollama provider not configured",
|
|
247
|
+
"done": "Ollama models refreshed: {total} total ({added})",
|
|
248
|
+
"error": "Refresh failed: {error}"
|
|
249
|
+
},
|
|
243
250
|
"provider": {
|
|
244
251
|
"current": "Current provider: {provider}\nModel: {model}"
|
|
245
252
|
},
|
|
@@ -683,7 +690,9 @@
|
|
|
683
690
|
"coming_soon": "[coming]",
|
|
684
691
|
"no_active_models": "(no active models)",
|
|
685
692
|
"select_provider_hint": "Select a provider → Enter to see models",
|
|
686
|
-
"select_model_hint": "Select a model → Enter to switch"
|
|
693
|
+
"select_model_hint": "Select a model → Enter to switch",
|
|
694
|
+
"needs_api_key": "{provider} requires an API Key",
|
|
695
|
+
"api_key_placeholder": "Paste API Key and press Enter..."
|
|
687
696
|
},
|
|
688
697
|
"command_picker": {
|
|
689
698
|
"title": "Commands",
|
|
@@ -190,7 +190,9 @@
|
|
|
190
190
|
},
|
|
191
191
|
"switch": {
|
|
192
192
|
"usage": "用法: /switch <provider> <model>\n示例: /switch deepseek deepseek-v4-pro",
|
|
193
|
-
"confirmed": "✓ 已切换到 {provider}/{model}"
|
|
193
|
+
"confirmed": "✓ 已切换到 {provider}/{model}",
|
|
194
|
+
"needs_api_key": "{provider} 需要 API Key。请在下方输入,或使用 Ctrl+P 选择模型后输入。",
|
|
195
|
+
"unknown_provider": "未知 provider: {provider}"
|
|
194
196
|
},
|
|
195
197
|
"goal": {
|
|
196
198
|
"set": "✓ 目标已设置:\"{goal}\"",
|
|
@@ -240,6 +242,11 @@
|
|
|
240
242
|
"header": "提供商 模型 上下文 视觉",
|
|
241
243
|
"hint": "使用 /switch <provider> <model> 切换。\n查看 /providers 了解即将上线的模型。"
|
|
242
244
|
},
|
|
245
|
+
"ollama_refresh": {
|
|
246
|
+
"not_configured": "Ollama provider 未配置",
|
|
247
|
+
"done": "Ollama 模型列表已刷新:共 {total} 个模型({added})",
|
|
248
|
+
"error": "刷新失败:{error}"
|
|
249
|
+
},
|
|
243
250
|
"provider": {
|
|
244
251
|
"current": "当前提供商: {provider}\n模型: {model}"
|
|
245
252
|
},
|
|
@@ -683,7 +690,9 @@
|
|
|
683
690
|
"coming_soon": "[即将推出]",
|
|
684
691
|
"no_active_models": "(无可用模型)",
|
|
685
692
|
"select_provider_hint": "选择提供商 → Enter 查看模型",
|
|
686
|
-
"select_model_hint": "选择模型 → Enter 切换"
|
|
693
|
+
"select_model_hint": "选择模型 → Enter 切换",
|
|
694
|
+
"needs_api_key": "{provider} 需要 API Key",
|
|
695
|
+
"api_key_placeholder": "粘贴 API Key 后按 Enter..."
|
|
687
696
|
},
|
|
688
697
|
"command_picker": {
|
|
689
698
|
"title": "命令",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
1
2
|
import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
|
|
2
3
|
import type { ProviderInstance, ChatRequest } from './registry'
|
|
3
4
|
import { fetchWithRetry } from './fetch-utils'
|
|
5
|
+
import { OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
4
6
|
|
|
5
7
|
export class OpenAICompatProvider implements ProviderInstance {
|
|
6
8
|
constructor(public config: ProviderConfig) {}
|
|
@@ -167,9 +169,58 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
async listModels(): Promise<ModelInfo[]> {
|
|
172
|
+
if (this.config.id === 'ollama') {
|
|
173
|
+
return this.listOllamaModels()
|
|
174
|
+
}
|
|
170
175
|
return this.config.models.filter((m) => m.status === 'active')
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
private listOllamaModels(): ModelInfo[] {
|
|
179
|
+
const seen = new Set<string>()
|
|
180
|
+
const result: ModelInfo[] = []
|
|
181
|
+
|
|
182
|
+
// 1. ollama list locally downloaded models
|
|
183
|
+
try {
|
|
184
|
+
const out = execSync('ollama list', { timeout: 5000, encoding: 'utf-8' })
|
|
185
|
+
const lines = out.split('\n').slice(1).filter(Boolean)
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const name = line.split(/\s+/)[0]!
|
|
188
|
+
if (!seen.has(name)) {
|
|
189
|
+
seen.add(name)
|
|
190
|
+
result.push({
|
|
191
|
+
id: name,
|
|
192
|
+
name,
|
|
193
|
+
providerId: 'ollama',
|
|
194
|
+
contextWindow: 128_000,
|
|
195
|
+
maxOutput: 32_000,
|
|
196
|
+
vision: false,
|
|
197
|
+
status: 'active',
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// ollama list failed (not installed / not running) → continue with presets
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Preset models (deduplicated)
|
|
206
|
+
for (const preset of OLLAMA_PRESET_MODELS) {
|
|
207
|
+
if (!seen.has(preset.id)) {
|
|
208
|
+
seen.add(preset.id)
|
|
209
|
+
result.push({
|
|
210
|
+
id: preset.id,
|
|
211
|
+
name: `${preset.id} [${preset.source}]`,
|
|
212
|
+
providerId: 'ollama',
|
|
213
|
+
contextWindow: 128_000,
|
|
214
|
+
maxOutput: 32_000,
|
|
215
|
+
vision: false,
|
|
216
|
+
status: 'active',
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
173
224
|
async healthCheck(): Promise<boolean> {
|
|
174
225
|
try {
|
|
175
226
|
const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
|
package/src/shared/constants.ts
CHANGED
|
@@ -362,6 +362,14 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
362
362
|
],
|
|
363
363
|
status: 'upcoming',
|
|
364
364
|
},
|
|
365
|
+
{
|
|
366
|
+
id: 'ollama',
|
|
367
|
+
name: 'Ollama (本地)',
|
|
368
|
+
protocol: 'openai-compatible' as const,
|
|
369
|
+
baseUrl: 'http://localhost:11434/v1',
|
|
370
|
+
apiKey: 'ollama-local',
|
|
371
|
+
models: [], // 动态发现,初值为空
|
|
372
|
+
},
|
|
365
373
|
{
|
|
366
374
|
id: 'openai',
|
|
367
375
|
name: 'OpenAI',
|
|
@@ -443,6 +451,19 @@ export const PROTOCOL_LABELS: Record<string, string> = {
|
|
|
443
451
|
}
|
|
444
452
|
|
|
445
453
|
export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system', 'artifact'] as const
|
|
454
|
+
|
|
455
|
+
/** 预置 Ollama 模型 — 与 ollama list 结果合并去重后展示 */
|
|
456
|
+
export const OLLAMA_PRESET_MODELS = [
|
|
457
|
+
{ id: 'om-V5-Flash', source: 'MiphamAI' },
|
|
458
|
+
{ id: 'om-V5-Pro', source: 'MiphamAI' },
|
|
459
|
+
{ id: 'om-V5-Visual', source: 'MiphamAI' },
|
|
460
|
+
{ id: 'om-V5-Apex', source: 'MiphamAI' },
|
|
461
|
+
{ id: 'deepseek-r1:70b', source: '热门' },
|
|
462
|
+
{ id: 'deepseek-v3', source: '热门' },
|
|
463
|
+
{ id: 'qwen2.5:72b', source: '热门' },
|
|
464
|
+
{ id: 'qwen3', source: '热门' },
|
|
465
|
+
] as const
|
|
466
|
+
|
|
446
467
|
export const CONFIG_FILE_NAME = 'config.yml'
|
|
447
468
|
export const MIPHAM_DIR = '.mipham'
|
|
448
469
|
export const USER_CONFIG_DIR = '.mipham'
|
package/src/ui/app.tsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
|
+
import TextInput from 'ink-text-input'
|
|
3
4
|
import { readFileSync } from 'node:fs'
|
|
4
5
|
import type { QueryEngine } from '../core/engine'
|
|
5
6
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
6
7
|
import type { SkillsLoader } from '../skills/loader'
|
|
7
8
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
8
9
|
import { setPreference } from '../config/preferences'
|
|
10
|
+
import { saveProviderApiKey } from '../config/loader'
|
|
9
11
|
import { AgentRegistry } from '../agent/agent-registry'
|
|
10
12
|
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
11
13
|
import { ChatPanel } from './chat'
|
|
@@ -154,6 +156,12 @@ export function App({
|
|
|
154
156
|
const [modelId, setModelId] = useState(initialModel || config.defaultModel)
|
|
155
157
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
156
158
|
const [agentViewOpen, setAgentViewOpen] = useState(false)
|
|
159
|
+
const [apiKeyPrompt, setApiKeyPrompt] = useState<{
|
|
160
|
+
providerId: string
|
|
161
|
+
modelId: string
|
|
162
|
+
providerName: string
|
|
163
|
+
} | null>(null)
|
|
164
|
+
const [apiKeyInput, setApiKeyInput] = useState('')
|
|
157
165
|
const [_sessionTitle, setSessionTitle] = useState('')
|
|
158
166
|
const [_fastMode, setFastMode] = useState(false)
|
|
159
167
|
const [_effort, setEffort] = useState('high')
|
|
@@ -279,6 +287,40 @@ export function App({
|
|
|
279
287
|
[engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId, t],
|
|
280
288
|
)
|
|
281
289
|
|
|
290
|
+
const handleApiKeySubmit = useCallback(
|
|
291
|
+
(submittedKey: string) => {
|
|
292
|
+
if (!apiKeyPrompt || !submittedKey.trim()) return
|
|
293
|
+
|
|
294
|
+
const trimmed = submittedKey.trim()
|
|
295
|
+
const saved = saveProviderApiKey(apiKeyPrompt.providerId, trimmed)
|
|
296
|
+
|
|
297
|
+
// Update the in-memory provider's apiKey so subsequent checks pass
|
|
298
|
+
const provider = config.providers.find((p) => p.id === apiKeyPrompt.providerId)
|
|
299
|
+
if (provider) {
|
|
300
|
+
provider.apiKey = trimmed
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Execute the switch now that we have the key
|
|
304
|
+
engine.switchProvider(apiKeyPrompt.providerId, apiKeyPrompt.modelId)
|
|
305
|
+
setProviderId(apiKeyPrompt.providerId)
|
|
306
|
+
setModelId(apiKeyPrompt.modelId)
|
|
307
|
+
|
|
308
|
+
setMessages((prev) => [
|
|
309
|
+
...prev,
|
|
310
|
+
{
|
|
311
|
+
role: 'system',
|
|
312
|
+
content: saved
|
|
313
|
+
? `✓ API Key saved for ${apiKeyPrompt.providerName}. Switched to ${apiKeyPrompt.providerId}/${apiKeyPrompt.modelId}.`
|
|
314
|
+
: `⚠ Could not persist API Key to config, but switched to ${apiKeyPrompt.providerId}/${apiKeyPrompt.modelId} for this session.`,
|
|
315
|
+
},
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
setApiKeyPrompt(null)
|
|
319
|
+
setApiKeyInput('')
|
|
320
|
+
},
|
|
321
|
+
[apiKeyPrompt, config, engine],
|
|
322
|
+
)
|
|
323
|
+
|
|
282
324
|
const handleSubmit = useCallback(
|
|
283
325
|
async (input: string) => {
|
|
284
326
|
if (!input.trim()) return
|
|
@@ -290,13 +332,23 @@ export function App({
|
|
|
290
332
|
// /switch takes args, handled separately
|
|
291
333
|
if (command === '/switch') {
|
|
292
334
|
const result = await handleSwitch(mkCtx(), args)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
335
|
+
if (result.needsApiKey) {
|
|
336
|
+
setApiKeyPrompt(result.needsApiKey)
|
|
337
|
+
setApiKeyInput('')
|
|
338
|
+
setMessages((prev) => [
|
|
339
|
+
...prev,
|
|
340
|
+
{ role: 'user', content: input },
|
|
341
|
+
{ role: 'system', content: result.content },
|
|
342
|
+
])
|
|
343
|
+
} else {
|
|
344
|
+
setMessages((prev) => [
|
|
345
|
+
...prev,
|
|
346
|
+
{ role: 'user', content: input },
|
|
347
|
+
{ role: 'system', content: result.content },
|
|
348
|
+
])
|
|
349
|
+
if (result.nextProvider) setProviderId(result.nextProvider)
|
|
350
|
+
if (result.nextModel) setModelId(result.nextModel)
|
|
351
|
+
}
|
|
300
352
|
if (result.exit) process.exit(0)
|
|
301
353
|
return
|
|
302
354
|
}
|
|
@@ -335,11 +387,21 @@ export function App({
|
|
|
335
387
|
if (handler) {
|
|
336
388
|
const result = await handler(mkCtx(), args)
|
|
337
389
|
forwardToAI = result.forwardToAI
|
|
390
|
+
// Handle API key prompt from command result
|
|
391
|
+
if (result.needsApiKey) {
|
|
392
|
+
setApiKeyPrompt(result.needsApiKey)
|
|
393
|
+
setApiKeyInput('')
|
|
394
|
+
}
|
|
338
395
|
setMessages((prev) => [
|
|
339
396
|
...prev,
|
|
340
397
|
{ role: 'user', content: input },
|
|
341
398
|
{ role: 'system', content: result.content },
|
|
342
399
|
])
|
|
400
|
+
if (result.needsApiKey) {
|
|
401
|
+
// Don't process nextProvider/model when waiting for API key
|
|
402
|
+
if (result.exit) process.exit(0)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
343
405
|
if (result.clearMessages) setMessages([])
|
|
344
406
|
if (result.nextProvider) setProviderId(result.nextProvider)
|
|
345
407
|
if (result.nextModel) setModelId(result.nextModel)
|
|
@@ -584,9 +646,14 @@ export function App({
|
|
|
584
646
|
)
|
|
585
647
|
|
|
586
648
|
useInput((_input, key) => {
|
|
587
|
-
// Escape: close picker → abort loading (does NOT exit app)
|
|
649
|
+
// Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
|
|
588
650
|
// Vim mode toggle is handled by InputBar
|
|
589
651
|
if (key.escape) {
|
|
652
|
+
if (apiKeyPrompt) {
|
|
653
|
+
setApiKeyPrompt(null)
|
|
654
|
+
setApiKeyInput('')
|
|
655
|
+
return
|
|
656
|
+
}
|
|
590
657
|
if (pickerOpen) {
|
|
591
658
|
setPickerOpen(false)
|
|
592
659
|
return
|
|
@@ -603,6 +670,55 @@ export function App({
|
|
|
603
670
|
// are handled in InputBar to avoid ink-text-input conflicts
|
|
604
671
|
})
|
|
605
672
|
|
|
673
|
+
// ── API Key prompt modal ──
|
|
674
|
+
if (apiKeyPrompt) {
|
|
675
|
+
return (
|
|
676
|
+
<Box flexDirection="column" padding={1} height="100%">
|
|
677
|
+
{/* Header */}
|
|
678
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
679
|
+
<Text color="#FFD700" bold>
|
|
680
|
+
Mipham Code
|
|
681
|
+
</Text>
|
|
682
|
+
<Text dimColor>v{version || '0.0.0'}</Text>
|
|
683
|
+
</Box>
|
|
684
|
+
|
|
685
|
+
{/* Chat panel — show existing messages */}
|
|
686
|
+
<ChatPanel messages={messages} focusMode={false} />
|
|
687
|
+
|
|
688
|
+
{/* API Key input prompt */}
|
|
689
|
+
<Box flexDirection="column" marginTop={1}>
|
|
690
|
+
<Text dimColor>──────────────────────────────</Text>
|
|
691
|
+
<Box
|
|
692
|
+
flexDirection="column"
|
|
693
|
+
marginY={1}
|
|
694
|
+
borderStyle="round"
|
|
695
|
+
borderColor="yellow"
|
|
696
|
+
padding={1}
|
|
697
|
+
>
|
|
698
|
+
<Text bold color="yellow">
|
|
699
|
+
{t('ui.picker.needs_api_key', { provider: apiKeyPrompt.providerName })}
|
|
700
|
+
</Text>
|
|
701
|
+
<Text dimColor>
|
|
702
|
+
{apiKeyPrompt.providerId}/{apiKeyPrompt.modelId}
|
|
703
|
+
</Text>
|
|
704
|
+
<Box marginTop={1}>
|
|
705
|
+
<TextInput
|
|
706
|
+
value={apiKeyInput}
|
|
707
|
+
onChange={setApiKeyInput}
|
|
708
|
+
onSubmit={handleApiKeySubmit}
|
|
709
|
+
placeholder={t('ui.picker.api_key_placeholder')}
|
|
710
|
+
/>
|
|
711
|
+
</Box>
|
|
712
|
+
<Box marginTop={1}>
|
|
713
|
+
<Text dimColor>Esc {t('ui.status.esc_to_interrupt')}</Text>
|
|
714
|
+
</Box>
|
|
715
|
+
</Box>
|
|
716
|
+
<Text dimColor>──────────────────────────────</Text>
|
|
717
|
+
</Box>
|
|
718
|
+
</Box>
|
|
719
|
+
)
|
|
720
|
+
}
|
|
721
|
+
|
|
606
722
|
return (
|
|
607
723
|
<Box flexDirection="column" padding={1} height="100%">
|
|
608
724
|
{/* Header — left-aligned */}
|
|
@@ -645,6 +761,18 @@ export function App({
|
|
|
645
761
|
{ role: 'system', content: `✓ Switched to ${newProvider}/${newModel}` },
|
|
646
762
|
])
|
|
647
763
|
}}
|
|
764
|
+
onNeedsApiKey={(providerId, modelId, providerName) => {
|
|
765
|
+
setApiKeyPrompt({ providerId, modelId, providerName })
|
|
766
|
+
setApiKeyInput('')
|
|
767
|
+
setPickerOpen(false)
|
|
768
|
+
setMessages((prev) => [
|
|
769
|
+
...prev,
|
|
770
|
+
{
|
|
771
|
+
role: 'system',
|
|
772
|
+
content: t('ui.picker.needs_api_key', { provider: providerName }),
|
|
773
|
+
},
|
|
774
|
+
])
|
|
775
|
+
}}
|
|
648
776
|
onClose={() => setPickerOpen(false)}
|
|
649
777
|
/>
|
|
650
778
|
) : (
|
package/src/ui/commands.ts
CHANGED
|
@@ -41,6 +41,8 @@ import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands
|
|
|
41
41
|
import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
|
|
42
42
|
import { keysCmd } from '../commands/keys'
|
|
43
43
|
import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
|
|
44
|
+
import { execSync } from 'node:child_process'
|
|
45
|
+
import { OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
44
46
|
|
|
45
47
|
export interface CommandContext {
|
|
46
48
|
engine: QueryEngine
|
|
@@ -81,6 +83,12 @@ export interface CommandResult {
|
|
|
81
83
|
forwardToAI?: string
|
|
82
84
|
/** If set, restored messages to load into the session (used by /resume last). */
|
|
83
85
|
forwardedMessages?: Message[]
|
|
86
|
+
/** API Key 补录请求:切换前需用户输入 Key */
|
|
87
|
+
needsApiKey?: {
|
|
88
|
+
providerId: string
|
|
89
|
+
modelId: string
|
|
90
|
+
providerName: string
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
export type CommandHandler = (
|
|
@@ -142,6 +150,7 @@ const helpCmd: CommandHandler = (ctx) => {
|
|
|
142
150
|
/fast [on|off] Toggle fast mode
|
|
143
151
|
/effort <lvl> Set reasoning effort (low|medium|high|xhigh|max)
|
|
144
152
|
/theme [dark|light|auto] Set terminal theme
|
|
153
|
+
/ollama-refresh Refresh Ollama model list at runtime
|
|
145
154
|
|
|
146
155
|
── Tools & Skills ──────────────────
|
|
147
156
|
/tools List available tools (${toolsCount} total)
|
|
@@ -351,6 +360,66 @@ const modelsCmd: CommandHandler = (ctx) => {
|
|
|
351
360
|
}
|
|
352
361
|
}
|
|
353
362
|
|
|
363
|
+
const ollamaRefreshCmd: CommandHandler = (ctx) => {
|
|
364
|
+
const t = resolveT(ctx)
|
|
365
|
+
const ollamaProvider = ctx.config.providers.find((p) => p.id === 'ollama')
|
|
366
|
+
if (!ollamaProvider) {
|
|
367
|
+
return { content: t('commands.ollama_refresh.not_configured') }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const before = ollamaProvider.models.length
|
|
371
|
+
try {
|
|
372
|
+
const seen = new Set<string>()
|
|
373
|
+
const refreshed: Array<{ id: string }> = []
|
|
374
|
+
|
|
375
|
+
// 1. Scan locally downloaded models via `ollama list`
|
|
376
|
+
try {
|
|
377
|
+
const out = execSync('ollama list', { timeout: 5000, encoding: 'utf-8' })
|
|
378
|
+
const lines = out.split('\n').slice(1).filter(Boolean)
|
|
379
|
+
for (const line of lines) {
|
|
380
|
+
const name = line.split(/\s+/)[0]!
|
|
381
|
+
if (!seen.has(name)) {
|
|
382
|
+
seen.add(name)
|
|
383
|
+
refreshed.push({ id: name })
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
// ollama list failed (not installed / not running) — continue with presets
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 2. Merge preset models (deduplicated)
|
|
391
|
+
for (const preset of OLLAMA_PRESET_MODELS) {
|
|
392
|
+
if (!seen.has(preset.id)) {
|
|
393
|
+
seen.add(preset.id)
|
|
394
|
+
refreshed.push({ id: preset.id })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 3. Update the in-memory provider model list
|
|
399
|
+
ollamaProvider.models = refreshed.map((m) => ({
|
|
400
|
+
id: m.id,
|
|
401
|
+
name: m.id,
|
|
402
|
+
providerId: 'ollama',
|
|
403
|
+
contextWindow: 128_000,
|
|
404
|
+
maxOutput: 32_000,
|
|
405
|
+
vision: false,
|
|
406
|
+
status: 'active' as const,
|
|
407
|
+
}))
|
|
408
|
+
|
|
409
|
+
const after = refreshed.length
|
|
410
|
+
const added = after - before
|
|
411
|
+
return {
|
|
412
|
+
content: t('commands.ollama_refresh.done', {
|
|
413
|
+
total: String(after),
|
|
414
|
+
added: added > 0 ? `+${added}` : String(added),
|
|
415
|
+
}),
|
|
416
|
+
}
|
|
417
|
+
} catch (err: unknown) {
|
|
418
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
419
|
+
return { content: t('commands.ollama_refresh.error', { error: msg }) }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
354
423
|
const providerCmd: CommandHandler = (ctx) => {
|
|
355
424
|
const t = resolveT(ctx)
|
|
356
425
|
return {
|
|
@@ -369,12 +438,34 @@ const providersCmd: CommandHandler = (ctx) => {
|
|
|
369
438
|
}
|
|
370
439
|
}
|
|
371
440
|
|
|
441
|
+
/** Detect whether an API key is missing (empty, placeholder, or env-var reference that resolves to empty). */
|
|
442
|
+
function isApiKeyMissing(apiKey: string): boolean {
|
|
443
|
+
if (!apiKey || apiKey.trim() === '') return true
|
|
444
|
+
if (apiKey === 'ollama-local') return false // Ollama local — no key needed
|
|
445
|
+
if (/^\$\{[A-Z_]+\}$/.test(apiKey.trim())) return true // ${VAR} placeholder
|
|
446
|
+
return false
|
|
447
|
+
}
|
|
448
|
+
|
|
372
449
|
const switchCmd: CommandHandler = (ctx, args) => {
|
|
373
450
|
const t = resolveT(ctx)
|
|
374
451
|
const [newProvider, newModel] = args
|
|
375
452
|
if (!newProvider || !newModel) {
|
|
376
453
|
return { content: t('commands.switch.usage') }
|
|
377
454
|
}
|
|
455
|
+
|
|
456
|
+
const provider = ctx.config.providers.find((p) => p.id === newProvider)
|
|
457
|
+
if (!provider) {
|
|
458
|
+
return { content: t('commands.switch.unknown_provider', { provider: newProvider }) }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Check API Key before switching
|
|
462
|
+
if (isApiKeyMissing(provider.apiKey)) {
|
|
463
|
+
return {
|
|
464
|
+
content: t('commands.switch.needs_api_key', { provider: provider.name }),
|
|
465
|
+
needsApiKey: { providerId: newProvider, modelId: newModel, providerName: provider.name },
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
378
469
|
ctx.engine.switchProvider(newProvider, newModel)
|
|
379
470
|
return {
|
|
380
471
|
content: t('commands.switch.confirmed', { provider: newProvider, model: newModel }),
|
|
@@ -3169,6 +3260,7 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
3169
3260
|
'/pick': 'Model & Provider',
|
|
3170
3261
|
'/model': 'Model & Provider',
|
|
3171
3262
|
'/models': 'Model & Provider',
|
|
3263
|
+
'/ollama-refresh': 'Model & Provider',
|
|
3172
3264
|
'/provider': 'Model & Provider',
|
|
3173
3265
|
'/providers': 'Model & Provider',
|
|
3174
3266
|
'/switch': 'Model & Provider',
|
|
@@ -3313,6 +3405,7 @@ registry.set('/ultracode', ultracodeCmd)
|
|
|
3313
3405
|
// Model & Provider
|
|
3314
3406
|
registry.set('/model', modelCmd)
|
|
3315
3407
|
registry.set('/models', modelsCmd)
|
|
3408
|
+
registry.set('/ollama-refresh', ollamaRefreshCmd)
|
|
3316
3409
|
registry.set('/provider', providerCmd)
|
|
3317
3410
|
registry.set('/providers', providersCmd)
|
|
3318
3411
|
registry.set('/config', configCmd)
|
package/src/ui/config-wizard.tsx
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import React, { useState, useEffect } from 'react'
|
|
12
12
|
import { Box, Text, useInput } from 'ink'
|
|
13
13
|
import TextInput from 'ink-text-input'
|
|
14
|
-
import { DEFAULT_PROVIDERS } from '../shared/constants'
|
|
14
|
+
import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
15
15
|
import type { ProviderConfig, ModelInfo } from '../shared/types'
|
|
16
16
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
17
17
|
import { join } from 'node:path'
|
|
@@ -45,7 +45,53 @@ function writeConfigFile(providerId: string, modelId: string, apiKey: string): v
|
|
|
45
45
|
mkdirSync(configDir, { recursive: true })
|
|
46
46
|
|
|
47
47
|
const provider = DEFAULT_PROVIDERS.find((p) => p.id === providerId)
|
|
48
|
-
const models =
|
|
48
|
+
const models =
|
|
49
|
+
providerId === 'ollama' ? getOllamaModelListForConfig() : getActiveModels(providerId)
|
|
50
|
+
|
|
51
|
+
function getOllamaModelListForConfig(): ModelInfo[] {
|
|
52
|
+
// 与 getOllamaModelList 逻辑一致,但用于配置写入
|
|
53
|
+
const seen = new Set<string>()
|
|
54
|
+
const result: ModelInfo[] = []
|
|
55
|
+
|
|
56
|
+
// ollama list
|
|
57
|
+
try {
|
|
58
|
+
const out = execSync('ollama list', { timeout: 5000, encoding: 'utf-8' })
|
|
59
|
+
const lines = out.split('\n').slice(1).filter(Boolean)
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const name = line.split(/\s+/)[0]!
|
|
62
|
+
if (!seen.has(name)) {
|
|
63
|
+
seen.add(name)
|
|
64
|
+
result.push({
|
|
65
|
+
id: name,
|
|
66
|
+
name,
|
|
67
|
+
providerId: 'ollama',
|
|
68
|
+
contextWindow: 128_000,
|
|
69
|
+
maxOutput: 32_000,
|
|
70
|
+
vision: false,
|
|
71
|
+
status: 'active',
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
/* ollama not available */
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const p of OLLAMA_PRESET_MODELS) {
|
|
80
|
+
if (!seen.has(p.id)) {
|
|
81
|
+
seen.add(p.id)
|
|
82
|
+
result.push({
|
|
83
|
+
id: p.id,
|
|
84
|
+
name: p.id,
|
|
85
|
+
providerId: 'ollama',
|
|
86
|
+
contextWindow: 128_000,
|
|
87
|
+
maxOutput: 32_000,
|
|
88
|
+
vision: false,
|
|
89
|
+
status: 'active',
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result
|
|
94
|
+
}
|
|
49
95
|
const lines = [
|
|
50
96
|
'# Mipham Code Configuration',
|
|
51
97
|
`# Generated by Config Wizard — ${new Date().toISOString()}`,
|
|
@@ -88,6 +134,36 @@ function checkOllama(): { installed: boolean; running: boolean; models: string[]
|
|
|
88
134
|
}
|
|
89
135
|
}
|
|
90
136
|
|
|
137
|
+
// ── Ollama model list helpers ──
|
|
138
|
+
|
|
139
|
+
interface OllamaModelItem {
|
|
140
|
+
id: string
|
|
141
|
+
source: 'local' | 'MiphamAI' | '热门'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getOllamaModelList(installedModels: string[]): OllamaModelItem[] {
|
|
145
|
+
const seen = new Set<string>()
|
|
146
|
+
const result: OllamaModelItem[] = []
|
|
147
|
+
|
|
148
|
+
// ollama list 返回的本地模型
|
|
149
|
+
for (const name of installedModels) {
|
|
150
|
+
if (!seen.has(name)) {
|
|
151
|
+
seen.add(name)
|
|
152
|
+
result.push({ id: name, source: 'local' })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 预置模型(去重)
|
|
157
|
+
for (const preset of OLLAMA_PRESET_MODELS) {
|
|
158
|
+
if (!seen.has(preset.id)) {
|
|
159
|
+
seen.add(preset.id)
|
|
160
|
+
result.push({ id: preset.id, source: preset.source as 'MiphamAI' | '热门' })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
|
|
91
167
|
// ── Component ──
|
|
92
168
|
|
|
93
169
|
export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
@@ -104,8 +180,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
104
180
|
const [cursor, setCursor] = useState(0)
|
|
105
181
|
|
|
106
182
|
// Ollama
|
|
107
|
-
const [ollamaModel,
|
|
183
|
+
const [ollamaModel, _setOllamaModel] = useState('')
|
|
108
184
|
const [ollamaStatus, setOllamaStatus] = useState<ReturnType<typeof checkOllama> | null>(null)
|
|
185
|
+
const [ollamaCursor, setOllamaCursor] = useState(0)
|
|
186
|
+
const ollamaModelList = ollamaStatus ? getOllamaModelList(ollamaStatus.models) : []
|
|
109
187
|
|
|
110
188
|
// ── Derived data ──
|
|
111
189
|
|
|
@@ -115,6 +193,7 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
115
193
|
// Reset cursor when step changes
|
|
116
194
|
const goStep = (s: Step) => {
|
|
117
195
|
setCursor(0)
|
|
196
|
+
setOllamaCursor(0)
|
|
118
197
|
setError(null)
|
|
119
198
|
setStep(s)
|
|
120
199
|
}
|
|
@@ -135,7 +214,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
135
214
|
}
|
|
136
215
|
|
|
137
216
|
const finishLocal = () => {
|
|
138
|
-
const
|
|
217
|
+
const selectedModel = ollamaModelList[ollamaCursor]
|
|
218
|
+
const model = selectedModel?.id || ollamaModel.trim() || 'llama3.2'
|
|
139
219
|
try {
|
|
140
220
|
writeConfigFile('ollama', model, 'ollama-local')
|
|
141
221
|
onComplete({ providerId: 'ollama', modelId: model, apiKey: 'ollama-local' })
|
|
@@ -249,6 +329,24 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
249
329
|
return
|
|
250
330
|
}
|
|
251
331
|
|
|
332
|
+
// ── Ollama model selection ──
|
|
333
|
+
if (step === 'ollama') {
|
|
334
|
+
if (ollamaModelList.length === 0) return
|
|
335
|
+
if (key.upArrow) {
|
|
336
|
+
setOllamaCursor((c) => (c > 0 ? c - 1 : ollamaModelList.length - 1))
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
if (key.downArrow) {
|
|
340
|
+
setOllamaCursor((c) => (c < ollamaModelList.length - 1 ? c + 1 : 0))
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (key.return) {
|
|
344
|
+
goStep('confirm')
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
252
350
|
// ── Confirm ──
|
|
253
351
|
if (step === 'confirm') {
|
|
254
352
|
if (key.return) {
|
|
@@ -261,8 +359,6 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
261
359
|
|
|
262
360
|
// ── Render ──
|
|
263
361
|
|
|
264
|
-
const dim = 'gray'
|
|
265
|
-
|
|
266
362
|
return (
|
|
267
363
|
<Box flexDirection="column" padding={1}>
|
|
268
364
|
{/* Header */}
|
|
@@ -404,29 +500,43 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
404
500
|
<Box flexDirection="column" marginBottom={1}>
|
|
405
501
|
<Text>
|
|
406
502
|
状态:{ollamaStatus.installed ? '✅ 已安装' : '❌ 未安装'}
|
|
407
|
-
{ollamaStatus.running
|
|
503
|
+
{ollamaStatus.running
|
|
504
|
+
? ' · 运行中'
|
|
505
|
+
: ollamaStatus.installed
|
|
506
|
+
? ' · 未运行(请执行 ollama serve)'
|
|
507
|
+
: ''}
|
|
408
508
|
</Text>
|
|
409
|
-
{ollamaStatus.models.length > 0 && (
|
|
410
|
-
<Text dimColor>已下载模型:{ollamaStatus.models.join(', ')}</Text>
|
|
411
|
-
)}
|
|
412
509
|
</Box>
|
|
413
510
|
) : (
|
|
414
511
|
<Text dimColor>正在检测 Ollama...</Text>
|
|
415
512
|
)}
|
|
513
|
+
{ollamaModelList.length > 0 && (
|
|
514
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
515
|
+
<Box marginBottom={1}>
|
|
516
|
+
<Text dimColor>已下载模型(↑↓ 选择 · Enter 确认):</Text>
|
|
517
|
+
</Box>
|
|
518
|
+
{ollamaModelList.map((m, i) => (
|
|
519
|
+
<Box key={m.id}>
|
|
520
|
+
<Text color={i === ollamaCursor ? 'cyan' : undefined}>
|
|
521
|
+
{i === ollamaCursor ? '▶' : ' '} {m.id}
|
|
522
|
+
</Text>
|
|
523
|
+
{m.source !== 'local' && (
|
|
524
|
+
<Text dimColor> [{m.source === 'MiphamAI' ? 'MiphamAI' : '热门'}]</Text>
|
|
525
|
+
)}
|
|
526
|
+
</Box>
|
|
527
|
+
))}
|
|
528
|
+
</Box>
|
|
529
|
+
)}
|
|
530
|
+
{ollamaStatus && ollamaModelList.length === 0 && (
|
|
531
|
+
<Box marginBottom={1}>
|
|
532
|
+
<Text dimColor>
|
|
533
|
+
未检测到已下载模型。请先运行 ollama pull <model> 下载模型。
|
|
534
|
+
</Text>
|
|
535
|
+
<Text dimColor>以下为预置模型列表,选择后可在对话中通过 /models 切换。</Text>
|
|
536
|
+
</Box>
|
|
537
|
+
)}
|
|
416
538
|
<Box marginTop={1}>
|
|
417
|
-
<Text dimColor
|
|
418
|
-
</Box>
|
|
419
|
-
<Box marginTop={1}>
|
|
420
|
-
<Text color="yellow">🖥️ </Text>
|
|
421
|
-
<TextInput
|
|
422
|
-
value={ollamaModel}
|
|
423
|
-
onChange={setOllamaModel}
|
|
424
|
-
onSubmit={() => goStep('confirm')}
|
|
425
|
-
placeholder="模型名,默认 llama3.2"
|
|
426
|
-
/>
|
|
427
|
-
</Box>
|
|
428
|
-
<Box marginTop={1}>
|
|
429
|
-
<Text dimColor>Enter 继续 · Esc 返回</Text>
|
|
539
|
+
<Text dimColor>↑↓ 选择 · Enter 确认 · Esc 返回</Text>
|
|
430
540
|
</Box>
|
|
431
541
|
</Box>
|
|
432
542
|
)}
|
|
@@ -449,7 +559,11 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
449
559
|
</Text>
|
|
450
560
|
</>
|
|
451
561
|
)}
|
|
452
|
-
{mode === 'local' &&
|
|
562
|
+
{mode === 'local' && (
|
|
563
|
+
<Text>
|
|
564
|
+
Ollama 模型:{ollamaModelList[ollamaCursor]?.id || ollamaModel.trim() || 'llama3.2'}
|
|
565
|
+
</Text>
|
|
566
|
+
)}
|
|
453
567
|
<Box marginTop={1}>
|
|
454
568
|
<Text dimColor>配置文件将保存到 ~/.mipham/config.yml</Text>
|
|
455
569
|
</Box>
|
package/src/ui/picker.tsx
CHANGED
|
@@ -8,6 +8,7 @@ interface PickerProps {
|
|
|
8
8
|
currentProvider: string
|
|
9
9
|
currentModel: string
|
|
10
10
|
onSelect: (providerId: string, modelId: string) => void
|
|
11
|
+
onNeedsApiKey?: (providerId: string, modelId: string, providerName: string) => void
|
|
11
12
|
onClose: () => void
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -18,6 +19,7 @@ export function ModelPicker({
|
|
|
18
19
|
currentProvider,
|
|
19
20
|
currentModel,
|
|
20
21
|
onSelect,
|
|
22
|
+
onNeedsApiKey,
|
|
21
23
|
onClose,
|
|
22
24
|
}: PickerProps) {
|
|
23
25
|
const { t } = useI18n()
|
|
@@ -60,9 +62,17 @@ export function ModelPicker({
|
|
|
60
62
|
if (!selectedProvider) return
|
|
61
63
|
const model = models[modelIdx]
|
|
62
64
|
if (model) {
|
|
65
|
+
// Check API key before confirming switch
|
|
66
|
+
const apiKey = selectedProvider.apiKey
|
|
67
|
+
if (!apiKey || apiKey.trim() === '' || /^\$\{[A-Z_]+\}$/.test(apiKey.trim())) {
|
|
68
|
+
if (selectedProvider.id !== 'ollama' && onNeedsApiKey) {
|
|
69
|
+
onNeedsApiKey(selectedProvider.id, model.id, selectedProvider.name)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
}
|
|
63
73
|
onSelect(selectedProvider.id, model.id)
|
|
64
74
|
}
|
|
65
|
-
}, [selectedProvider, models, modelIdx, onSelect])
|
|
75
|
+
}, [selectedProvider, models, modelIdx, onSelect, onNeedsApiKey])
|
|
66
76
|
|
|
67
77
|
useInput((input, key) => {
|
|
68
78
|
// Global keys
|