/** * Interactive first-run Config Wizard * * Replaces manual config.yml editing for non-technical users. * Flow: Welcome → Mode → Provider → Model → API Key → Confirm → Done * * Local path: Ollama setup * Cloud path: pick provider → pick model → paste API key → auto-configure */ import React, { useState, useEffect } from 'react' import { Box, Text, useInput } from 'ink' import { useI18n } from '../i18n-context' import TextInput from 'ink-text-input' import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants' import type { ModelInfo } from '../shared/types' import { mkdirSync } from 'node:fs' import { atomicWriteFileSync } from '../shared/atomic-write' import { join } from 'node:path' import { execSync } from 'node:child_process' import { getCredentialKey, encryptApiKey } from '../config/credential-crypto' import { CLOUD_PROVIDERS, getActiveModels, buildConfigYaml } from '../config/wizard-config' import { miphamHome } from '../core/paths.ts' // ── Types ── type Step = 'welcome' | 'mode' | 'provider' | 'model' | 'apikey' | 'ollama' | 'confirm' | 'done' interface Props { onComplete: (config: { providerId: string; modelId: string; apiKey: string }) => void onSkip: () => void } // ── Constants ── const SELECTED_COLOR = 'cyan' // ── Helpers ── function writeConfigFile(providerId: string, modelId: string, apiKey: string): void { const configDir = miphamHome() mkdirSync(configDir, { recursive: true }) const models = providerId === 'ollama' ? getOllamaModelListForConfig() : [] function getOllamaModelListForConfig(): ModelInfo[] { // 与 getOllamaModelList 逻辑一致,但用于配置写入 const seen = new Set() const result: ModelInfo[] = [] // ollama list try { const out = execSync('ollama list', { timeout: 5000, encoding: 'utf-8' }) const lines = out.split('\n').slice(1).filter(Boolean) for (const line of lines) { const name = line.split(/\s+/)[0]! if (!seen.has(name)) { seen.add(name) result.push({ id: name, name, providerId: 'ollama', contextWindow: 128_000, maxOutput: 32_000, vision: false, status: 'active', }) } } } catch { /* ollama not available */ } for (const p of OLLAMA_PRESET_MODELS) { if (!seen.has(p.id)) { seen.add(p.id) result.push({ id: p.id, name: p.id, providerId: 'ollama', contextWindow: 128_000, maxOutput: 32_000, vision: false, status: 'active', }) } } return result } const storedKey = encryptApiKey(apiKey, getCredentialKey(configDir)) atomicWriteFileSync( join(configDir, 'config.yml'), buildConfigYaml(providerId, modelId, storedKey, models), ) } function checkOllama(): { installed: boolean; running: boolean; models: string[] } { try { execSync('ollama --version', { timeout: 5000, stdio: 'ignore' }) const out = execSync('ollama list', { timeout: 5000, encoding: 'utf-8' }) const models = out .split('\n') .slice(1) .filter(Boolean) .map((l) => l.split(/\s+/)[0]!) return { installed: true, running: true, models } } catch { try { execSync('ollama --version', { timeout: 5000, stdio: 'ignore' }) return { installed: true, running: false, models: [] } } catch { return { installed: false, running: false, models: [] } } } } // ── Ollama model list helpers ── interface OllamaModelItem { id: string source: 'local' | 'MiphamAI' | '热门' } function getOllamaModelList(installedModels: string[]): OllamaModelItem[] { const seen = new Set() const result: OllamaModelItem[] = [] // ollama list 返回的本地模型 for (const name of installedModels) { if (!seen.has(name)) { seen.add(name) result.push({ id: name, source: 'local' }) } } // 预置模型(去重) for (const preset of OLLAMA_PRESET_MODELS) { if (!seen.has(preset.id)) { seen.add(preset.id) result.push({ id: preset.id, source: preset.source as 'MiphamAI' | '热门' }) } } return result } // ── Component ── export function ConfigWizard({ onComplete, onSkip }: Props) { const { t } = useI18n() const [step, setStep] = useState('welcome') // Selections const [mode, setMode] = useState<'cloud' | 'local' | null>(null) const [providerId, setProviderId] = useState(null) const [modelId, setModelId] = useState(null) const [apiKey, setApiKey] = useState('') const [error, setError] = useState(null) // List navigation cursor const [cursor, setCursor] = useState(0) // Ollama const [ollamaModel, _setOllamaModel] = useState('') const [ollamaStatus, setOllamaStatus] = useState | null>(null) const [ollamaCursor, setOllamaCursor] = useState(0) const ollamaModelList = ollamaStatus ? getOllamaModelList(ollamaStatus.models) : [] // ── Derived data ── const providerList = CLOUD_PROVIDERS const modelList = providerId ? getActiveModels(providerId) : [] // Reset cursor when step changes const goStep = (s: Step) => { setCursor(0) setOllamaCursor(0) setError(null) setStep(s) } // ── Completion handlers ── const finishCloud = () => { if (!providerId || !modelId || !apiKey.trim()) { setError(t('ui.wizard.error_apikey_required')) return } try { writeConfigFile(providerId, modelId, apiKey.trim()) onComplete({ providerId, modelId, apiKey: apiKey.trim() }) } catch (err) { setError(t('ui.wizard.error_write_failed', { err: String(err) })) } } const finishLocal = () => { const selectedModel = ollamaModelList[ollamaCursor] const model = selectedModel?.id || ollamaModel.trim() || 'llama3.2' try { writeConfigFile('ollama', model, 'ollama-local') onComplete({ providerId: 'ollama', modelId: model, apiKey: 'ollama-local' }) } catch (err) { setError(t('ui.wizard.error_write_failed', { err: String(err) })) } } // ── Ollama initialization ── useEffect(() => { if (step === 'ollama' && !ollamaStatus) { setOllamaStatus(checkOllama()) } }, [step, ollamaStatus]) // ── Keyboard: list navigation steps (no TextInput) ── useInput((_input, key) => { // Esc always skips if (key.escape) { if (step === 'welcome') { onSkip() return } // Go back one step if (step === 'mode') { goStep('welcome') return } if (step === 'provider') { goStep('mode') return } if (step === 'model') { goStep('provider') return } if (step === 'apikey') { goStep('model') return } if (step === 'ollama') { goStep('mode') return } if (step === 'confirm') { if (mode === 'cloud') { goStep('apikey') return } if (mode === 'local') { goStep('ollama') return } } return } // ── Welcome ── if (step === 'welcome') { if (key.return) goStep('mode') return } // ── Mode selection ── if (step === 'mode') { if (key.upArrow) setCursor((c) => (c === 0 ? 1 : 0)) if (key.downArrow) setCursor((c) => (c === 0 ? 1 : 0)) if (key.return) { if (cursor === 0) { // Cloud setMode('cloud') goStep('provider') } else { // Local setMode('local') setOllamaStatus(checkOllama()) goStep('ollama') } } return } // ── Cloud provider selection ── if (step === 'provider') { if (key.upArrow) setCursor((c) => (c > 0 ? c - 1 : providerList.length - 1)) if (key.downArrow) setCursor((c) => (c < providerList.length - 1 ? c + 1 : 0)) if (key.return) { const p = providerList[cursor] if (p) { setProviderId(p.id) goStep('model') } } return } // ── Cloud model selection ── if (step === 'model') { if (modelList.length === 0) return if (key.upArrow) setCursor((c) => (c > 0 ? c - 1 : modelList.length - 1)) if (key.downArrow) setCursor((c) => (c < modelList.length - 1 ? c + 1 : 0)) if (key.return) { const m = modelList[cursor] if (m) { setModelId(m.id) goStep('apikey') } } return } // ── Ollama model selection ── if (step === 'ollama') { if (ollamaModelList.length === 0) return if (key.upArrow) { setOllamaCursor((c) => (c > 0 ? c - 1 : ollamaModelList.length - 1)) return } if (key.downArrow) { setOllamaCursor((c) => (c < ollamaModelList.length - 1 ? c + 1 : 0)) return } if (key.return) { goStep('confirm') return } return } // ── Confirm ── if (step === 'confirm') { if (key.return) { if (mode === 'cloud') finishCloud() else finishLocal() } return } }) // ── Render ── return ( {/* Header */} Mipham Code {t('ui.wizard.header_subtitle')} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ {/* ── Welcome ── */} {step === 'welcome' && ( {t('ui.wizard.welcome_title')} {t('ui.wizard.welcome_intro')} {t('ui.wizard.welcome_time')} {t('ui.wizard.welcome_start')} {t('ui.wizard.welcome_skip')} )} {/* ── Mode ── */} {step === 'mode' && ( {t('ui.wizard.mode_title')} {cursor === 0 ? '▶' : ' '} {t('ui.wizard.mode_cloud')} {t('ui.wizard.mode_cloud_hint', { count: String(CLOUD_PROVIDERS.length) })} {cursor === 1 ? '▶' : ' '} {t('ui.wizard.mode_local')} {t('ui.wizard.mode_local_hint')} {t('ui.wizard.nav_hint')} )} {/* ── Cloud Provider ── */} {step === 'provider' && ( {t('ui.wizard.provider_title')} {providerList.map((p, i) => ( {i === cursor ? '▶' : ' '} {p.name} {t('ui.wizard.provider_count', { count: String(p.models.filter((m) => m.status === 'active').length), })} ))} {t('ui.wizard.nav_hint')} )} {/* ── Cloud Model ── */} {step === 'model' && ( {t('ui.wizard.model_title', { provider: DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name || providerId || '', })} {modelList.map((m, i) => ( {i === cursor ? '▶' : ' '} {m.name} ({m.id}) ))} {t('ui.wizard.nav_hint')} )} {/* ── API Key input ── */} {step === 'apikey' && ( {t('ui.wizard.apikey_title')} {t('ui.wizard.apikey_provider', { provider: DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name || '', })} {t('ui.wizard.apikey_model', { model: getActiveModels(providerId!).find((m) => m.id === modelId)?.name || '', })} {error && ( ⚠ {error} )} 🔑 goStep('confirm')} placeholder={t('ui.wizard.apikey_placeholder')} /> {t('ui.wizard.apikey_continue')} )} {/* ── Ollama ── */} {step === 'ollama' && ( {t('ui.wizard.ollama_title')} {ollamaStatus ? ( {t('ui.wizard.ollama_status')} {ollamaStatus.installed ? t('ui.wizard.ollama_installed') : t('ui.wizard.ollama_not_installed')} {ollamaStatus.running ? t('ui.wizard.ollama_running') : ollamaStatus.installed ? t('ui.wizard.ollama_not_running') : ''} ) : ( {t('ui.wizard.ollama_detecting')} )} {ollamaModelList.length > 0 && ( {t('ui.wizard.ollama_downloaded')} {ollamaModelList.map((m, i) => ( {i === ollamaCursor ? '▶' : ' '} {m.id} {m.source !== 'local' && ( {' '} [{m.source === 'MiphamAI' ? 'MiphamAI' : t('ui.wizard.ollama_source_hot')}] )} ))} )} {ollamaStatus && ollamaModelList.length === 0 && ( {t('ui.wizard.ollama_no_models')} {t('ui.wizard.ollama_preset_hint')} )} {t('ui.wizard.nav_hint')} )} {/* ── Confirm ── */} {step === 'confirm' && ( {t('ui.wizard.confirm_title')} {t('ui.wizard.confirm_connection')} {mode === 'cloud' ? t('ui.wizard.confirm_cloud') : t('ui.wizard.confirm_local')} {mode === 'cloud' && ( <> {t('ui.wizard.confirm_provider', { provider: DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name || '', })} {t('ui.wizard.confirm_model', { model: modelId || '' })} {t('ui.wizard.confirm_apikey', { masked: `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`, })} )} {mode === 'local' && ( {t('ui.wizard.confirm_ollama_model', { model: ollamaModelList[ollamaCursor]?.id || ollamaModel.trim() || 'llama3.2', })} )} {t('ui.wizard.confirm_save_path')} {error && ( ⚠ {error} )} {t('ui.wizard.confirm_save')} {t('ui.wizard.confirm_back')} )} {/* ── Done ── */} {step === 'done' && ( {t('ui.wizard.done_title')} {t('ui.wizard.done_launching')} )} ) }