Merge pull request #87 from multica-ai/Bohan-J/athens

feat(desktop): add provider selection and credential management
This commit is contained in:
Bohan Jiang 2026-02-04 18:47:27 +08:00 committed by GitHub
commit bd1778440a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1149 additions and 46 deletions

View file

@ -190,28 +190,6 @@ ipcMain.handle('skills:add', async (_, source: string) => {
---
## 三、实现优先级
### Phase 1: 基础框架 (MVP)
1. **Layout 组件** - Header + Tabs 导航
2. **Home 页面** - 二维码显示 + 连接状态
3. **Gateway 连接** - 复用 @multica/store
### Phase 2: 管理功能
4. **Tools 页面** - 列表展示 + 开关切换
5. **Skills 页面** - 列表展示 + 基础操作
6. **Settings** - Gateway URL + Theme
### Phase 3: 完善体验
7. **Agent 页面** - 状态监控 + Provider 切换
8. **二维码刷新机制**
9. **错误处理 + Toast 提示**
---
## 四、Hub 集成技术方案
### 架构概述
@ -496,9 +474,17 @@ ChatInput → useMessagesStore.sendMessage()
### 复用层级
| 层级 | 组件/模块 | 复用情况 |
| ---------- | ---------------------------------------- | -------- |
| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 |
| Store 层 | `useMessagesStore` | ✅ 完全复用 |
| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 |
| 传输层 | IPC vs WebSocket | ❌ 各自实现 |
| 层级 | 组件/模块 | 复用情况 |
| -------- | ----------------------------------- | ----------- |
| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 |
| Store 层 | `useMessagesStore` | ✅ 完全复用 |
| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 |
| 传输层 | IPC vs WebSocket | ❌ 各自实现 |
---
## 九、TODO
- [ ] **优化 Memory Tool 逻辑**: 当前 memory tool 和 memory.md 没有统一,需要整合
- [ ] **优化 Agent Profile 加载逻辑**: 改进 Profile 的加载机制
- [ ] **Agent 自我迭代 Profile**: 添加让 Agent 在对话过程中自己修改 Profile 内文件的能力

View file

@ -101,6 +101,27 @@ interface LocalChatEvent {
}
}
interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
interface ElectronAPI {
hub: {
init: () => Promise<unknown>
@ -145,6 +166,16 @@ interface ElectronAPI {
updateStyle: (style: string) => Promise<unknown>
updateUser: (content: string) => Promise<unknown>
}
provider: {
list: () => Promise<ProviderStatus[]>
listAvailable: () => Promise<ProviderStatus[]>
current: () => Promise<CurrentProviderInfo>
set: (providerId: string, modelId?: string) => Promise<{ ok: boolean; provider?: string; model?: string; error?: string }>
getMeta: (providerId: string) => Promise<unknown>
isAvailable: (providerId: string) => Promise<boolean>
saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }>
importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }>
}
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>

View file

@ -5,11 +5,13 @@ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
export { registerSkillsIpcHandlers } from './skills.js'
export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js'
export { registerProfileIpcHandlers } from './profile.js'
export { registerProviderIpcHandlers } from './provider.js'
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
import { registerSkillsIpcHandlers } from './skills.js'
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
import { registerProfileIpcHandlers } from './profile.js'
import { registerProviderIpcHandlers } from './provider.js'
/**
* Register all IPC handlers.
@ -20,6 +22,7 @@ export function registerAllIpcHandlers(): void {
registerAgentIpcHandlers()
registerSkillsIpcHandlers()
registerProfileIpcHandlers()
registerProviderIpcHandlers()
}
/**

View file

@ -0,0 +1,312 @@
/**
* Provider IPC handlers for Electron main process.
*
* Manages LLM provider listing, status checking, and switching.
* Mirrors the CLI `/provider` command functionality.
*/
import { ipcMain } from 'electron'
import { getCurrentHub } from './hub.js'
import {
getProviderList,
getAvailableProviders,
getCurrentProvider,
getProviderMeta,
isProviderAvailable,
getLoginInstructions,
type ProviderInfo,
} from '../../../../src/agent/providers/index.js'
import {
readClaudeCliCredentials,
readCodexCliCredentials,
} from '../../../../src/agent/providers/oauth/cli-credentials.js'
import { credentialManager } from '../../../../src/agent/credentials.js'
/**
* Provider info returned to renderer (matches ProviderInfo from registry).
*/
export interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
/**
* Current provider/model info returned to renderer.
*/
export interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
/**
* Get the default agent from Hub.
*/
function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
}
/**
* Register all Provider-related IPC handlers.
*/
export function registerProviderIpcHandlers(): void {
/**
* List all providers with their status.
* This is the main listing function, similar to CLI `/provider` command.
*/
ipcMain.handle('provider:list', async (): Promise<ProviderStatus[]> => {
const providers = getProviderList()
return providers.map((p: ProviderInfo) => ({
id: p.id,
name: p.name,
authMethod: p.authMethod,
available: p.available,
configured: p.configured,
current: p.current,
defaultModel: p.defaultModel,
models: p.models,
loginUrl: p.loginUrl,
loginCommand: p.loginCommand,
loginInstructions: getLoginInstructions(p.id),
}))
})
/**
* List only available (configured) providers.
*/
ipcMain.handle('provider:listAvailable', async (): Promise<ProviderStatus[]> => {
const providers = getAvailableProviders()
return providers.map((p: ProviderInfo) => ({
id: p.id,
name: p.name,
authMethod: p.authMethod,
available: p.available,
configured: p.configured,
current: p.current,
defaultModel: p.defaultModel,
models: p.models,
loginUrl: p.loginUrl,
loginCommand: p.loginCommand,
loginInstructions: getLoginInstructions(p.id),
}))
})
/**
* Get current provider and model from the active agent.
*/
ipcMain.handle('provider:current', async (): Promise<CurrentProviderInfo> => {
const agent = getDefaultAgent()
if (agent) {
// Get from actual agent instance
const info = agent.getProviderInfo()
const meta = getProviderMeta(info.provider)
return {
provider: info.provider,
model: info.model,
providerName: meta?.name,
available: isProviderAvailable(info.provider),
}
}
// Fallback to credentials default
const defaultProvider = getCurrentProvider()
const meta = getProviderMeta(defaultProvider)
return {
provider: defaultProvider,
model: meta?.defaultModel,
providerName: meta?.name,
available: isProviderAvailable(defaultProvider),
}
})
/**
* Switch the agent to a different provider and/or model.
*/
ipcMain.handle(
'provider:set',
async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => {
const agent = getDefaultAgent()
if (!agent) {
return { ok: false, error: 'No agent available' }
}
// Validate provider exists
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
// Check if provider is available
if (!isProviderAvailable(providerId)) {
const instructions = getLoginInstructions(providerId)
return {
ok: false,
error: `Provider "${providerId}" is not configured.\n${instructions}`,
}
}
// Validate model if specified
if (modelId && !meta.models.includes(modelId)) {
return {
ok: false,
error: `Model "${modelId}" is not available for provider "${providerId}". Available: ${meta.models.join(', ')}`,
}
}
try {
const result = agent.setProvider(providerId, modelId)
console.log(`[IPC] Provider switched to: ${result.provider}, model: ${result.model}`)
return {
ok: true,
provider: result.provider,
model: result.model,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to switch provider: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Get metadata for a specific provider.
*/
ipcMain.handle('provider:getMeta', async (_event, providerId: string) => {
const meta = getProviderMeta(providerId)
if (!meta) {
return { error: `Unknown provider: ${providerId}` }
}
return {
id: meta.id,
name: meta.name,
authMethod: meta.authMethod,
defaultModel: meta.defaultModel,
models: meta.models,
loginUrl: meta.loginUrl,
loginCommand: meta.loginCommand,
available: isProviderAvailable(providerId),
loginInstructions: getLoginInstructions(providerId),
}
})
/**
* Check if a specific provider is available (has valid credentials).
*/
ipcMain.handle('provider:isAvailable', async (_event, providerId: string): Promise<boolean> => {
return isProviderAvailable(providerId)
})
/**
* Save API key for a provider to credentials.json5.
* After saving, the provider should become available.
*/
ipcMain.handle(
'provider:saveApiKey',
async (_event, providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => {
try {
// Validate provider exists and uses API key auth
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
if (meta.authMethod !== 'api-key') {
return { ok: false, error: `Provider "${providerId}" uses ${meta.authMethod} authentication, not API key` }
}
// Save the API key
credentialManager.setLlmProviderApiKey(providerId, apiKey)
console.log(`[IPC] API key saved for provider: ${providerId}`)
return { ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to save API key: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Import OAuth credentials from CLI tools (claude-code, codex).
* Reads from CLI credential storage and saves to credentials.json5.
*/
ipcMain.handle(
'provider:importOAuth',
async (_event, providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => {
try {
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
if (meta.authMethod !== 'oauth') {
return { ok: false, error: `Provider "${providerId}" does not use OAuth authentication` }
}
// Read credentials from CLI tool
if (providerId === 'claude-code') {
const creds = readClaudeCliCredentials()
if (!creds) {
return { ok: false, error: 'No Claude Code credentials found. Run "claude login" first.' }
}
if (creds.expires <= Date.now()) {
return { ok: false, error: 'Claude Code credentials have expired. Run "claude login" again.' }
}
// Save to credentials.json5
const token = creds.type === 'oauth' ? creds.access : creds.token
const refreshToken = creds.type === 'oauth' ? creds.refresh : undefined
credentialManager.setLlmProviderOAuthToken(providerId, token, refreshToken, creds.expires)
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
return { ok: true, expiresAt: creds.expires }
}
if (providerId === 'openai-codex') {
const creds = readCodexCliCredentials()
if (!creds) {
return { ok: false, error: 'No Codex credentials found. Run "codex login" first.' }
}
if (creds.expires <= Date.now()) {
return { ok: false, error: 'Codex credentials have expired. Run "codex login" again.' }
}
// Save to credentials.json5
credentialManager.setLlmProviderOAuthToken(providerId, creds.access, creds.refresh, creds.expires)
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
return { ok: true, expiresAt: creds.expires }
}
return { ok: false, error: `OAuth import not supported for provider: ${providerId}` }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to import OAuth credentials: ${message}`)
return { ok: false, error: message }
}
}
)
}

View file

@ -44,6 +44,27 @@ export interface ProfileData {
userContent: string | undefined
}
export interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
export interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
// Local chat event types (for direct IPC communication without Gateway)
export interface LocalChatEvent {
agentId: string
@ -134,6 +155,29 @@ const electronAPI = {
updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content),
},
// Provider management
provider: {
/** List all providers with their status */
list: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:list'),
/** List only available (configured) providers */
listAvailable: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:listAvailable'),
/** Get current provider and model from the active agent */
current: (): Promise<CurrentProviderInfo> => ipcRenderer.invoke('provider:current'),
/** Switch the agent to a different provider and/or model */
set: (providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> =>
ipcRenderer.invoke('provider:set', providerId, modelId),
/** Get metadata for a specific provider */
getMeta: (providerId: string) => ipcRenderer.invoke('provider:getMeta', providerId),
/** Check if a specific provider is available */
isAvailable: (providerId: string): Promise<boolean> => ipcRenderer.invoke('provider:isAvailable', providerId),
/** Save API key for a provider */
saveApiKey: (providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('provider:saveApiKey', providerId, apiKey),
/** Import OAuth credentials from CLI tools (claude-code, codex) */
importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> =>
ipcRenderer.invoke('provider:importOAuth', providerId),
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to agent events for local direct chat */

View file

@ -0,0 +1,121 @@
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@multica/ui/components/ui/dialog'
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Label } from '@multica/ui/components/ui/label'
import { HugeiconsIcon } from '@hugeicons/react'
import { Loading03Icon, Key01Icon } from '@hugeicons/core-free-icons'
interface ApiKeyDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
providerId: string
providerName: string
onSuccess?: () => void
}
export function ApiKeyDialog({
open,
onOpenChange,
providerId,
providerName,
onSuccess,
}: ApiKeyDialogProps) {
const [apiKey, setApiKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
if (!apiKey.trim()) {
setError('API key is required')
return
}
setSaving(true)
setError(null)
try {
const result = await window.electronAPI.provider.saveApiKey(providerId, apiKey.trim())
if (result.ok) {
setApiKey('')
onOpenChange(false)
onSuccess?.()
} else {
setError(result.error ?? 'Failed to save API key')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
} finally {
setSaving(false)
}
}
const handleClose = (isOpen: boolean) => {
if (!isOpen) {
setApiKey('')
setError(null)
}
onOpenChange(isOpen)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HugeiconsIcon icon={Key01Icon} className="size-5" />
Configure {providerName}
</DialogTitle>
<DialogDescription>
Enter your API key to enable {providerName}. The key will be saved securely in your credentials file.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">API Key</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
onKeyDown={(e) => {
if (e.key === 'Enter' && !saving) {
handleSave()
}
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<p className="text-xs text-muted-foreground">
Your API key is stored locally in <code className="bg-muted px-1 rounded">~/.super-multica/credentials.json5</code>
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !apiKey.trim()}>
{saving && <HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default ApiKeyDialog

View file

@ -0,0 +1,146 @@
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@multica/ui/components/ui/dialog'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { Loading03Icon, CommandLineIcon, RefreshIcon, Tick02Icon } from '@hugeicons/core-free-icons'
interface OAuthDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
providerId: string
providerName: string
loginCommand?: string
onSuccess?: () => void
}
export function OAuthDialog({
open,
onOpenChange,
providerId,
providerName,
loginCommand,
onSuccess,
}: OAuthDialogProps) {
const [importing, setImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [expiresAt, setExpiresAt] = useState<number | null>(null)
const handleImport = async () => {
setImporting(true)
setError(null)
setSuccess(false)
try {
const result = await window.electronAPI.provider.importOAuth(providerId)
if (result.ok) {
setSuccess(true)
setExpiresAt(result.expiresAt ?? null)
// Auto-close after a short delay
setTimeout(() => {
onOpenChange(false)
onSuccess?.()
}, 1500)
} else {
setError(result.error ?? 'Failed to import credentials')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
} finally {
setImporting(false)
}
}
const handleClose = (isOpen: boolean) => {
if (!isOpen) {
setError(null)
setSuccess(false)
setExpiresAt(null)
}
onOpenChange(isOpen)
}
const formatExpiry = (timestamp: number) => {
const remaining = timestamp - Date.now()
if (remaining <= 0) return 'expired'
const hours = Math.floor(remaining / (60 * 60 * 1000))
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000))
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HugeiconsIcon icon={CommandLineIcon} className="size-5" />
Configure {providerName}
</DialogTitle>
<DialogDescription>
{providerName} uses OAuth authentication. Please log in via the command line first.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Login instructions */}
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
1. Open your terminal and run:
</p>
<div className="bg-muted rounded-md p-3 font-mono text-sm">
{loginCommand ?? `${providerId} login`}
</div>
<p className="text-sm text-muted-foreground">
2. Complete the login process in your browser
</p>
<p className="text-sm text-muted-foreground">
3. Click "Refresh" below to import your credentials
</p>
</div>
{/* Status messages */}
{error && (
<div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
{error}
</div>
)}
{success && (
<div className="bg-green-500/10 text-green-600 dark:text-green-400 rounded-md p-3 text-sm flex items-center gap-2">
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
<span>
Credentials imported successfully!
{expiresAt && ` (expires in ${formatExpiry(expiresAt)})`}
</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={importing}>
Cancel
</Button>
<Button onClick={handleImport} disabled={importing || success}>
{importing ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />
) : (
<HugeiconsIcon icon={RefreshIcon} className="size-4 mr-2" />
)}
Refresh
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default OAuthDialog

View file

@ -0,0 +1,101 @@
/**
* Hook for managing LLM providers in the Desktop App.
*
* Provides functionality similar to CLI `/provider` command:
* - List all providers with status
* - Get current provider/model
* - Switch provider/model
*/
import { useState, useEffect, useCallback } from 'react'
// Types are defined in electron-env.d.ts and available globally
interface UseProviderReturn {
/** All providers with their status */
providers: ProviderStatus[]
/** Only available (configured) providers */
availableProviders: ProviderStatus[]
/** Current provider and model info */
current: CurrentProviderInfo | null
/** Loading state */
loading: boolean
/** Error message if any */
error: string | null
/** Refresh provider list and current status */
refresh: () => Promise<void>
/** Switch to a different provider (and optionally model) */
setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }>
/** Get metadata for a specific provider */
getProviderMeta: (providerId: string) => ProviderStatus | undefined
}
export function useProvider(): UseProviderReturn {
const [providers, setProviders] = useState<ProviderStatus[]>([])
const [current, setCurrent] = useState<CurrentProviderInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [providerList, currentInfo] = await Promise.all([
window.electronAPI.provider.list(),
window.electronAPI.provider.current(),
])
setProviders(providerList)
setCurrent(currentInfo)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
console.error('[useProvider] Failed to load providers:', message)
} finally {
setLoading(false)
}
}, [])
// Load providers on mount
useEffect(() => {
refresh()
}, [refresh])
const setProvider = useCallback(async (providerId: string, modelId?: string) => {
setError(null)
try {
const result = await window.electronAPI.provider.set(providerId, modelId)
if (result.ok) {
// Refresh to update current status
await refresh()
return { ok: true }
} else {
setError(result.error ?? 'Unknown error')
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
return { ok: false, error: message }
}
}, [refresh])
const getProviderMeta = useCallback((providerId: string) => {
return providers.find((p) => p.id === providerId)
}, [providers])
const availableProviders = providers.filter((p) => p.available)
return {
providers,
availableProviders,
current,
loading,
error,
refresh,
setProvider,
getProviderMeta,
}
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
@ -8,17 +8,52 @@ import {
Loading03Icon,
AlertCircleIcon,
Edit02Icon,
ArrowDown01Icon,
Tick02Icon,
Alert02Icon,
} from '@hugeicons/core-free-icons'
import { ConnectionQRCode } from '../components/qr-code'
import { DeviceList } from '../components/device-list'
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
import { ApiKeyDialog } from '../components/api-key-dialog'
import { OAuthDialog } from '../components/oauth-dialog'
import { useHub } from '../hooks/use-hub'
import { useProvider } from '../hooks/use-provider'
export default function HomePage() {
const navigate = useNavigate()
const { hubInfo, agents, loading, error } = useHub()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
const [settingsOpen, setSettingsOpen] = useState(false)
const [agentName, setAgentName] = useState<string | undefined>()
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
const [switching, setSwitching] = useState(false)
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
const [selectedProvider, setSelectedProvider] = useState<{
id: string
name: string
authMethod: 'api-key' | 'oauth'
loginCommand?: string
} | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setProviderDropdownOpen(false)
}
}
if (providerDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [providerDropdownOpen])
// Load agent profile info
useEffect(() => {
@ -151,6 +186,92 @@ export default function HomePage() {
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
</div>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
LLM Provider
</p>
<button
className="w-full flex items-center justify-between p-3 rounded-md bg-background border border-border hover:bg-accent/50 transition-colors disabled:opacity-50"
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
disabled={providerLoading || switching}
>
<div className="flex items-center gap-2">
{current?.available ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-green-500" />
) : (
<HugeiconsIcon icon={Alert02Icon} className="size-4 text-yellow-500" />
)}
<div className="text-left">
<p className="font-medium text-sm">{current?.providerName ?? current?.provider ?? 'Loading...'}</p>
<p className="text-xs text-muted-foreground">{current?.model ?? '-'}</p>
</div>
</div>
<HugeiconsIcon
icon={ArrowDown01Icon}
className={`size-4 text-muted-foreground transition-transform ${providerDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Provider Dropdown - Compact Grid */}
{providerDropdownOpen && (
<div className="absolute left-0 right-0 top-full mt-1 z-10 bg-background border border-border rounded-md shadow-lg p-2">
<div className="grid grid-cols-3 gap-1.5">
{providers.map((p) => (
<button
key={p.id}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded text-left text-xs transition-colors ${
p.id === current?.provider
? 'bg-primary/10 border border-primary/30'
: 'hover:bg-accent/50 border border-transparent'
} ${!p.available ? 'opacity-60 hover:opacity-80' : ''}`}
onClick={async () => {
if (!p.available) {
// Show config dialog for unavailable providers
setSelectedProvider({
id: p.id,
name: p.name,
authMethod: p.authMethod,
loginCommand: p.loginCommand,
})
setProviderDropdownOpen(false)
if (p.authMethod === 'oauth') {
setOauthDialogOpen(true)
} else {
setApiKeyDialogOpen(true)
}
return
}
setSwitching(true)
setProviderDropdownOpen(false)
const result = await setProvider(p.id)
setSwitching(false)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
disabled={switching}
title={`${p.name}\n${p.authMethod === 'oauth' ? 'OAuth' : 'API Key'} · ${p.defaultModel}`}
>
<span className={`size-1.5 rounded-full flex-shrink-0 ${
p.available ? 'bg-green-500' : 'bg-muted-foreground/50'
}`} />
<span className="truncate font-medium">
{p.id === 'claude-code' ? 'Claude Code' :
p.id === 'openai-codex' ? 'Codex' :
p.id === 'kimi-coding' ? 'Kimi' :
p.id === 'anthropic' ? 'Anthropic' :
p.id === 'openai' ? 'OpenAI' :
p.id === 'openrouter' ? 'OpenRouter' :
p.name.split(' ')[0]}
</span>
</button>
))}
</div>
</div>
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
@ -167,20 +288,6 @@ export default function HomePage() {
</p>
<p className="font-medium capitalize">{connectionState}</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Active Agents
</p>
<p className="font-medium">{hubInfo?.agentCount ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Primary Agent
</p>
<p className="font-medium text-sm font-mono truncate" title={primaryAgent?.id}>
{primaryAgent?.id ?? 'None'}
</p>
</div>
</div>
</div>
</div>
@ -194,6 +301,43 @@ export default function HomePage() {
{/* Agent Settings Dialog */}
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
{/* API Key Dialog */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{/* OAuth Dialog */}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{/* Bottom: Actions */}
<div className="border-t p-4">
<div className="flex items-center justify-between">

View file

@ -221,4 +221,19 @@ export class AsyncAgent {
getMessages(): AgentMessage[] {
return this.agent.getMessages();
}
/**
* Get current provider and model information.
*/
getProviderInfo(): { provider: string; model: string | undefined } {
return this.agent.getProviderInfo();
}
/**
* Switch to a different provider and/or model.
* This updates the agent's model without recreating the session.
*/
setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } {
return this.agent.setProvider(providerId, modelId);
}
}

View file

@ -1,11 +1,17 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import JSON5 from "json5";
import { DATA_DIR } from "../shared/paths.js";
type ProviderConfig = {
// API Key authentication
apiKey?: string | undefined;
// OAuth authentication
oauthToken?: string | undefined;
oauthRefreshToken?: string | undefined;
oauthExpiresAt?: number | undefined;
// Common
baseUrl?: string | undefined;
model?: string | undefined;
};
@ -223,6 +229,132 @@ export class CredentialManager {
this.skillsConfig = null;
this.resolvedSkillsEnv = null;
}
/**
* Set the API key for a provider and save to credentials.json5.
* Creates the file if it doesn't exist.
*/
setLlmProviderApiKey(provider: string, apiKey: string): void {
const path = getCredentialsPath();
// Load existing config or create new one
let config: CredentialsConfig = { version: 1 };
if (existsSync(path)) {
try {
const raw = readFileSync(path, "utf8");
config = JSON5.parse(raw) as CredentialsConfig;
} catch {
// If parse fails, start fresh
config = { version: 1 };
}
}
// Ensure structure exists
if (!config.llm) {
config.llm = {};
}
if (!config.llm.providers) {
config.llm.providers = {};
}
// Set or update the provider config
const existing = config.llm.providers[provider] ?? {};
config.llm.providers[provider] = {
...existing,
apiKey,
};
// Write back to file
mkdirSync(dirname(path), { recursive: true });
const content = JSON.stringify(config, null, 2);
writeFileSync(path, content, "utf8");
// Reset cache so next read picks up the change
this.reset();
}
/**
* Set OAuth token for a provider and save to credentials.json5.
* Used for OAuth providers like claude-code and openai-codex.
*/
setLlmProviderOAuthToken(
provider: string,
token: string,
refreshToken?: string,
expiresAt?: number,
): void {
const path = getCredentialsPath();
// Load existing config or create new one
let config: CredentialsConfig = { version: 1 };
if (existsSync(path)) {
try {
const raw = readFileSync(path, "utf8");
config = JSON5.parse(raw) as CredentialsConfig;
} catch {
config = { version: 1 };
}
}
// Ensure structure exists
if (!config.llm) {
config.llm = {};
}
if (!config.llm.providers) {
config.llm.providers = {};
}
// Set or update the provider config
const existing = config.llm.providers[provider] ?? {};
config.llm.providers[provider] = {
...existing,
oauthToken: token,
oauthRefreshToken: refreshToken,
oauthExpiresAt: expiresAt,
};
// Write back to file
mkdirSync(dirname(path), { recursive: true });
const content = JSON.stringify(config, null, 2);
writeFileSync(path, content, "utf8");
// Reset cache
this.reset();
}
/**
* Set the default LLM provider and save to credentials.json5.
*/
setDefaultLlmProvider(provider: string): void {
const path = getCredentialsPath();
// Load existing config or create new one
let config: CredentialsConfig = { version: 1 };
if (existsSync(path)) {
try {
const raw = readFileSync(path, "utf8");
config = JSON5.parse(raw) as CredentialsConfig;
} catch {
config = { version: 1 };
}
}
// Ensure structure exists
if (!config.llm) {
config.llm = {};
}
// Set default provider
config.llm.provider = provider;
// Write back to file
mkdirSync(dirname(path), { recursive: true });
const content = JSON.stringify(config, null, 2);
writeFileSync(path, content, "utf8");
// Reset cache
this.reset();
}
}
export const credentialManager = new CredentialManager();

View file

@ -9,6 +9,8 @@ import {
resolveApiKeyForProvider,
resolveBaseUrl,
resolveModelId,
PROVIDER_ALIAS,
getDefaultModel,
} from "./providers/index.js";
import { SessionManager } from "./session/session-manager.js";
import { ProfileManager } from "./profile/index.js";
@ -82,7 +84,7 @@ export class Agent {
private initialized = false;
// Auth profile rotation state
private readonly resolvedProvider: string;
private resolvedProvider: string;
private currentApiKey: string | undefined;
private currentProfileId: string | undefined;
private profileCandidates: string[];
@ -598,6 +600,72 @@ export class Agent {
this.profile?.updateStyle(style);
}
/**
* Get current provider and model information.
*/
getProviderInfo(): { provider: string; model: string | undefined } {
return {
provider: this.resolvedProvider,
model: this.agent.state.model?.id,
};
}
/**
* Switch to a different provider and/or model.
* This updates the agent's model without recreating the session.
*/
setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } {
// Resolve the actual provider (handle aliases like claude-code -> anthropic)
const actualProvider = PROVIDER_ALIAS[providerId] ?? providerId;
// Resolve the model
const targetModel = modelId ?? getDefaultModel(providerId) ?? getDefaultModel(actualProvider);
const model = resolveModel({ provider: providerId, model: targetModel });
if (!model) {
throw new Error(`Failed to resolve model for provider: ${providerId}, model: ${targetModel}`);
}
// Resolve API key for the new provider
// For OAuth providers (claude-code, openai-codex), we need to use the original providerId
// because OAuth credentials are resolved by the original provider name, not the alias
const resolved = resolveApiKeyForProvider(providerId);
if (resolved) {
this.currentApiKey = resolved.apiKey;
this.currentProfileId = resolved.profileId;
} else {
// Fallback: try with actual provider (for API key based providers)
this.currentApiKey = resolveApiKey(actualProvider);
this.currentProfileId = actualProvider;
}
if (!this.currentApiKey) {
throw new Error(`No API key configured for provider: ${providerId}`);
}
// Update the agent's model and API key
const baseUrl = resolveBaseUrl(actualProvider);
const modelWithBaseUrl = baseUrl ? { ...model, baseUrl } : model;
this.agent.setModel(modelWithBaseUrl);
// Update internal state
this.resolvedProvider = providerId;
// Update session metadata
this.session.saveMeta({
provider: actualProvider,
model: model.id,
thinkingLevel: this.agent.state.thinkingLevel,
reasoningMode: this.reasoningMode,
contextWindowTokens: this.contextWindowGuard.tokens,
});
return {
provider: providerId,
model: model.id,
};
}
/**
* Build the full system prompt using the structured builder.
* Combines profile content, tools, skills, and runtime info.