@miphamai/cli 0.30.1 → 0.31.1

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/bin/mipham.ts CHANGED
@@ -208,8 +208,10 @@ async function runUpdate(): Promise<boolean> {
208
208
  console.log(` Checking npm registry for latest version...`)
209
209
  console.log()
210
210
 
211
- // Check latest version from npm with retry + mirror fallback
211
+ // Check latest version from npm with retry + mirror fallback.
212
+ // Track which registry responded so we can use it for the install step too.
212
213
  let latestVersion = ''
214
+ let workingRegistry = 'https://registry.npmjs.org/' // default
213
215
  try {
214
216
  const { execSync } = await import('node:child_process')
215
217
 
@@ -233,7 +235,10 @@ async function runUpdate(): Promise<boolean> {
233
235
  { encoding: 'utf-8', timeout: attempt.timeout, stdio: ['pipe', 'pipe', 'pipe'] },
234
236
  ).trim()
235
237
  latestVersion = result.replace(/"/g, '')
236
- if (latestVersion) break
238
+ if (latestVersion) {
239
+ workingRegistry = attempt.registry
240
+ break
241
+ }
237
242
  } catch (err) {
238
243
  lastError = err instanceof Error ? err : new Error(String(err))
239
244
  // Continue to next attempt
@@ -278,7 +283,7 @@ async function runUpdate(): Promise<boolean> {
278
283
  console.log()
279
284
  console.log(`Updating ${PACKAGE} to v${latestVersion}...`)
280
285
 
281
- const ok = performUpdate(latestVersion)
286
+ const ok = performUpdate(latestVersion, workingRegistry)
282
287
  if (!ok) {
283
288
  console.log()
284
289
  console.log(`✗ Update failed.`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
@@ -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'
@@ -139,19 +139,30 @@ function isValidSemver(v: string): boolean {
139
139
  /**
140
140
  * Perform the actual update via npm install -g.
141
141
  * Validates the version string before shell execution (prevents command injection).
142
+ *
143
+ * @param version Target semver version (must pass isValidSemver).
144
+ * @param registry Optional npm registry URL — when provided the install uses it
145
+ * directly (useful when the primary registry is slow, e.g. China).
146
+ * Falls back to the npm default if omitted.
142
147
  * Returns true on success.
143
148
  */
144
- export function performUpdate(version: string): boolean {
149
+ export function performUpdate(version: string, registry?: string): boolean {
145
150
  if (!isValidSemver(version)) {
146
151
  process.stderr.write(`⚠ Refusing to install invalid version: "${version}"\n`)
147
152
  return false
148
153
  }
149
154
 
155
+ // Sanitize registry — only allow known URLs to prevent command injection
156
+ const allowedRegistries = REGISTRIES.map((r) => r.url)
157
+ const safeRegistry = registry && allowedRegistries.includes(registry) ? registry : undefined
158
+
159
+ const registryFlag = safeRegistry ? ` --registry=${safeRegistry}` : ''
160
+
150
161
  try {
151
- execSync(`npm install -g ${PACKAGE}@${version}`, {
162
+ execSync(`npm install -g ${PACKAGE}@${version}${registryFlag}`, {
152
163
  encoding: 'utf-8',
153
164
  stdio: 'inherit',
154
- timeout: 120_000,
165
+ timeout: 600_000, // 10 min — slow networks (e.g. China → npmjs) may need 7+ min
155
166
  })
156
167
  return true
157
168
  } catch {
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
- setMessages((prev) => [
294
- ...prev,
295
- { role: 'user', content: input },
296
- { role: 'system', content: result.content },
297
- ])
298
- if (result.nextProvider) setProviderId(result.nextProvider)
299
- if (result.nextModel) setModelId(result.nextModel)
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
  ) : (
@@ -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)
@@ -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 = getActiveModels(providerId)
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, setOllamaModel] = useState('')
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 model = ollamaModel.trim() || 'llama3.2'
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 &lt;model&gt; 下载模型。
534
+ </Text>
535
+ <Text dimColor>以下为预置模型列表,选择后可在对话中通过 /models 切换。</Text>
536
+ </Box>
537
+ )}
416
538
  <Box marginTop={1}>
417
- <Text dimColor>输入 Ollama 模型名称(如 llama3.2, qwen2.5, deepseek-r1):</Text>
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' && <Text>Ollama 模型:{ollamaModel.trim() || 'llama3.2'}</Text>}
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