Merge pull request #87 from multica-ai/Bohan-J/athens
feat(desktop): add provider selection and credential management
This commit is contained in:
commit
bd1778440a
12 changed files with 1149 additions and 46 deletions
|
|
@ -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 内文件的能力
|
||||
|
|
|
|||
31
apps/desktop/electron/electron-env.d.ts
vendored
31
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
312
apps/desktop/electron/ipc/provider.ts
Normal file
312
apps/desktop/electron/ipc/provider.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
121
apps/desktop/src/components/api-key-dialog.tsx
Normal file
121
apps/desktop/src/components/api-key-dialog.tsx
Normal 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
|
||||
146
apps/desktop/src/components/oauth-dialog.tsx
Normal file
146
apps/desktop/src/components/oauth-dialog.tsx
Normal 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
|
||||
101
apps/desktop/src/hooks/use-provider.ts
Normal file
101
apps/desktop/src/hooks/use-provider.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue