initial commit

This commit is contained in:
farion1231
2025-08-04 22:16:26 +08:00
commit e0a9c1ab4c
20 changed files with 1127 additions and 0 deletions

94
src/main/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import path from 'path'
import Store from 'electron-store'
import { Provider, AppConfig } from '../shared/types'
import { checkProviderStatus, switchProvider, getClaudeCodeConfig } from './services'
const store = new Store<AppConfig>()
let mainWindow: BrowserWindow | null = null
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hiddenInset',
autoHideMenuBar: true
})
if (app.isPackaged) {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
} else {
mainWindow.loadURL('http://localhost:3000')
mainWindow.webContents.openDevTools()
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// IPC handlers
ipcMain.handle('getProviders', () => {
return store.get('providers', {})
})
ipcMain.handle('getCurrentProvider', () => {
return store.get('current', '')
})
ipcMain.handle('addProvider', (_, provider: Provider) => {
const providers = store.get('providers', {})
providers[provider.id] = provider
store.set('providers', providers)
return true
})
ipcMain.handle('deleteProvider', (_, id: string) => {
const providers = store.get('providers', {})
delete providers[id]
store.set('providers', providers)
return true
})
ipcMain.handle('checkStatus', async (_, provider: Provider) => {
return await checkProviderStatus(provider)
})
ipcMain.handle('switchProvider', async (_, providerId: string) => {
const providers = store.get('providers', {})
const provider = providers[providerId]
if (provider) {
const success = await switchProvider(provider)
if (success) {
store.set('current', providerId)
}
return success
}
return false
})
ipcMain.handle('getClaudeCodeConfigPath', () => {
return getClaudeCodeConfig().path
})

12
src/main/preload.ts Normal file
View File

@@ -0,0 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'
import { Provider } from '../shared/types'
contextBridge.exposeInMainWorld('electronAPI', {
getProviders: () => ipcRenderer.invoke('getProviders'),
getCurrentProvider: () => ipcRenderer.invoke('getCurrentProvider'),
addProvider: (provider: Provider) => ipcRenderer.invoke('addProvider', provider),
deleteProvider: (id: string) => ipcRenderer.invoke('deleteProvider', id),
checkStatus: (provider: Provider) => ipcRenderer.invoke('checkStatus', provider),
switchProvider: (providerId: string) => ipcRenderer.invoke('switchProvider', providerId),
getClaudeCodeConfigPath: () => ipcRenderer.invoke('getClaudeCodeConfigPath')
})

86
src/main/services.ts Normal file
View File

@@ -0,0 +1,86 @@
import axios from 'axios'
import fs from 'fs/promises'
import path from 'path'
import os from 'os'
import { Provider, ProviderStatus } from '../shared/types'
export async function checkProviderStatus(provider: Provider): Promise<ProviderStatus> {
const startTime = Date.now()
try {
// 简单的健康检查请求
const response = await axios.post(
`${provider.apiUrl}/v1/messages`,
{
model: provider.model || 'claude-3-opus-20240229',
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 1
},
{
headers: {
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json'
},
timeout: 5000
}
)
const responseTime = Date.now() - startTime
return {
isOnline: true,
responseTime,
lastChecked: new Date()
}
} catch (error) {
return {
isOnline: false,
responseTime: -1,
lastChecked: new Date(),
error: error instanceof Error ? error.message : '未知错误'
}
}
}
export function getClaudeCodeConfig() {
// Claude Code 配置文件路径
const configDir = path.join(os.homedir(), '.claude')
const configPath = path.join(configDir, 'settings.json')
return { path: configPath, dir: configDir }
}
export async function switchProvider(provider: Provider): Promise<boolean> {
try {
const { path: configPath, dir: configDir } = getClaudeCodeConfig()
// 确保目录存在
await fs.mkdir(configDir, { recursive: true })
// 读取现有配置
let config: any = {}
try {
const content = await fs.readFile(configPath, 'utf-8')
config = JSON.parse(content)
} catch {
// 文件不存在或解析失败,使用空配置
}
// 更新配置
config.api = {
...config.api,
baseURL: provider.apiUrl,
apiKey: provider.apiKey,
model: provider.model
}
// 写回配置文件
await fs.writeFile(configPath, JSON.stringify(config, null, 2))
return true
} catch (error) {
console.error('切换供应商失败:', error)
return false
}
}

72
src/renderer/App.css Normal file
View File

@@ -0,0 +1,72 @@
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background: #2c3e50;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
font-size: 1.5rem;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 1rem;
}
.refresh-btn, .add-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.refresh-btn {
background: #3498db;
color: white;
}
.refresh-btn:hover:not(:disabled) {
background: #2980b9;
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.add-btn {
background: #27ae60;
color: white;
}
.add-btn:hover {
background: #229954;
}
.app-main {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.config-path {
margin-top: 2rem;
padding: 1rem;
background: #ecf0f1;
border-radius: 4px;
font-size: 0.9rem;
color: #7f8c8d;
}

131
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react'
import { Provider, ProviderStatus } from '../shared/types'
import ProviderList from './components/ProviderList'
import AddProviderModal from './components/AddProviderModal'
import './App.css'
function App() {
const [providers, setProviders] = useState<Record<string, Provider>>({})
const [currentProviderId, setCurrentProviderId] = useState<string>('')
const [statuses, setStatuses] = useState<Record<string, ProviderStatus>>({})
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [configPath, setConfigPath] = useState<string>('')
// 加载供应商列表
useEffect(() => {
loadProviders()
loadConfigPath()
}, [])
// 定时检查状态
useEffect(() => {
checkAllStatuses()
const interval = setInterval(checkAllStatuses, 30000) // 每30秒检查一次
return () => clearInterval(interval)
}, [providers])
const loadProviders = async () => {
const loadedProviders = await window.electronAPI.getProviders()
const currentId = await window.electronAPI.getCurrentProvider()
setProviders(loadedProviders)
setCurrentProviderId(currentId)
}
const loadConfigPath = async () => {
const path = await window.electronAPI.getClaudeCodeConfigPath()
setConfigPath(path)
}
const checkAllStatuses = async () => {
if (Object.keys(providers).length === 0) return
setIsRefreshing(true)
const newStatuses: Record<string, ProviderStatus> = {}
await Promise.all(
Object.values(providers).map(async (provider) => {
const status = await window.electronAPI.checkStatus(provider)
newStatuses[provider.id] = status
})
)
setStatuses(newStatuses)
setIsRefreshing(false)
}
const handleAddProvider = async (provider: Omit<Provider, 'id'>) => {
const newProvider: Provider = {
...provider,
id: Date.now().toString()
}
await window.electronAPI.addProvider(newProvider)
await loadProviders()
setIsAddModalOpen(false)
}
const handleDeleteProvider = async (id: string) => {
if (confirm('确定要删除这个供应商吗?')) {
await window.electronAPI.deleteProvider(id)
await loadProviders()
}
}
const handleSwitchProvider = async (id: string) => {
const success = await window.electronAPI.switchProvider(id)
if (success) {
setCurrentProviderId(id)
alert('切换成功!')
} else {
alert('切换失败,请检查配置')
}
}
return (
<div className="app">
<header className="app-header">
<h1>Claude Code </h1>
<div className="header-actions">
<button
className="refresh-btn"
onClick={checkAllStatuses}
disabled={isRefreshing}
>
{isRefreshing ? '检查中...' : '刷新状态'}
</button>
<button
className="add-btn"
onClick={() => setIsAddModalOpen(true)}
>
</button>
</div>
</header>
<main className="app-main">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
statuses={statuses}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
/>
{configPath && (
<div className="config-path">
: {configPath}
</div>
)}
</main>
{isAddModalOpen && (
<AddProviderModal
onAdd={handleAddProvider}
onClose={() => setIsAddModalOpen(false)}
/>
)}
</div>
)
}
export default App

View File

@@ -0,0 +1,122 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 2rem;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.modal-content h2 {
margin-bottom: 1.5rem;
color: #2c3e50;
}
.presets {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #ecf0f1;
}
.presets label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.preset-buttons {
display: flex;
gap: 0.5rem;
}
.preset-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #3498db;
background: white;
color: #3498db;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.preset-btn:hover {
background: #3498db;
color: white;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.625rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #3498db;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.cancel-btn,
.submit-btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
}
.cancel-btn {
background: #ecf0f1;
color: #555;
}
.cancel-btn:hover {
background: #bdc3c7;
}
.submit-btn {
background: #27ae60;
color: white;
}
.submit-btn:hover {
background: #229954;
}

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react'
import { Provider } from '../../shared/types'
import './AddProviderModal.css'
interface AddProviderModalProps {
onAdd: (provider: Omit<Provider, 'id'>) => void
onClose: () => void
}
const AddProviderModal: React.FC<AddProviderModalProps> = ({ onAdd, onClose }) => {
const [formData, setFormData] = useState({
name: '',
apiUrl: '',
apiKey: '',
model: 'claude-3-opus-20240229'
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name || !formData.apiUrl || !formData.apiKey) {
alert('请填写所有必填字段')
return
}
onAdd(formData)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
// 预设的供应商配置
const presets = [
{
name: '官方 Anthropic',
apiUrl: 'https://api.anthropic.com',
model: 'claude-3-opus-20240229'
},
{
name: 'OpenRouter',
apiUrl: 'https://openrouter.ai/api/v1',
model: 'anthropic/claude-3-opus'
}
]
const applyPreset = (preset: typeof presets[0]) => {
setFormData({
...formData,
name: preset.name,
apiUrl: preset.apiUrl,
model: preset.model
})
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2></h2>
<div className="presets">
<label></label>
<div className="preset-buttons">
{presets.map((preset, index) => (
<button
key={index}
type="button"
className="preset-btn"
onClick={() => applyPreset(preset)}
>
{preset.name}
</button>
))}
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name"> *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="例如:官方 Anthropic"
required
/>
</div>
<div className="form-group">
<label htmlFor="apiUrl">API *</label>
<input
type="url"
id="apiUrl"
name="apiUrl"
value={formData.apiUrl}
onChange={handleChange}
placeholder="https://api.anthropic.com"
required
/>
</div>
<div className="form-group">
<label htmlFor="apiKey">API Key *</label>
<input
type="password"
id="apiKey"
name="apiKey"
value={formData.apiKey}
onChange={handleChange}
placeholder="sk-ant-..."
required
/>
</div>
<div className="form-group">
<label htmlFor="model"></label>
<input
type="text"
id="model"
name="model"
value={formData.model}
onChange={handleChange}
placeholder="claude-3-opus-20240229"
/>
</div>
<div className="form-actions">
<button type="button" className="cancel-btn" onClick={onClose}>
</button>
<button type="submit" className="submit-btn">
</button>
</div>
</form>
</div>
</div>
)
}
export default AddProviderModal

View File

@@ -0,0 +1,127 @@
.provider-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #7f8c8d;
}
.empty-state p:first-child {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.provider-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-item {
display: flex;
align-items: center;
padding: 1rem;
border: 2px solid #ecf0f1;
border-radius: 6px;
transition: all 0.2s;
}
.provider-item:hover {
border-color: #3498db;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.provider-item.current {
border-color: #27ae60;
background: #f0fdf4;
}
.provider-info {
flex: 1;
min-width: 0;
}
.provider-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.provider-name input[type="radio"] {
cursor: pointer;
}
.provider-name input[type="radio"]:disabled {
cursor: not-allowed;
}
.current-badge {
background: #27ae60;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
}
.provider-url {
color: #7f8c8d;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.provider-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: 2rem;
}
.status-icon {
font-size: 1.1rem;
}
.status-text {
color: #555;
font-size: 0.9rem;
}
.response-time {
color: #3498db;
font-size: 0.85rem;
font-family: monospace;
}
.provider-actions {
display: flex;
gap: 0.5rem;
}
.delete-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #e74c3c;
background: white;
color: #e74c3c;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.delete-btn:hover:not(:disabled) {
background: #e74c3c;
color: white;
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,97 @@
import React from 'react'
import { Provider, ProviderStatus } from '../../shared/types'
import './ProviderList.css'
interface ProviderListProps {
providers: Record<string, Provider>
currentProviderId: string
statuses: Record<string, ProviderStatus>
onSwitch: (id: string) => void
onDelete: (id: string) => void
}
const ProviderList: React.FC<ProviderListProps> = ({
providers,
currentProviderId,
statuses,
onSwitch,
onDelete
}) => {
const formatResponseTime = (time: number) => {
if (time < 0) return '-'
return `${time}ms`
}
const getStatusIcon = (status?: ProviderStatus) => {
if (!status) return '⏳'
return status.isOnline ? '✅' : '❌'
}
const getStatusText = (status?: ProviderStatus) => {
if (!status) return '检查中...'
if (status.isOnline) return '正常'
return status.error || '连接失败'
}
return (
<div className="provider-list">
{Object.values(providers).length === 0 ? (
<div className="empty-state">
<p></p>
<p>"添加供应商"</p>
</div>
) : (
<div className="provider-items">
{Object.values(providers).map((provider) => {
const status = statuses[provider.id]
const isCurrent = provider.id === currentProviderId
return (
<div
key={provider.id}
className={`provider-item ${isCurrent ? 'current' : ''}`}
>
<div className="provider-info">
<div className="provider-name">
<input
type="radio"
name="provider"
checked={isCurrent}
onChange={() => onSwitch(provider.id)}
disabled={!status?.isOnline}
/>
<span>{provider.name}</span>
{isCurrent && <span className="current-badge">使</span>}
</div>
<div className="provider-url">{provider.apiUrl}</div>
</div>
<div className="provider-status">
<span className="status-icon">{getStatusIcon(status)}</span>
<span className="status-text">{getStatusText(status)}</span>
{status?.isOnline && (
<span className="response-time">
{formatResponseTime(status.responseTime)}
</span>
)}
</div>
<div className="provider-actions">
<button
className="delete-btn"
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
>
</button>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
export default ProviderList

21
src/renderer/index.css Normal file
View File

@@ -0,0 +1,21 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
}

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Code 供应商切换器</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

33
src/shared/types.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface Provider {
id: string
name: string
apiUrl: string
apiKey: string
model?: string
}
export interface ProviderStatus {
isOnline: boolean
responseTime: number
lastChecked: Date
error?: string
}
export interface AppConfig {
providers: Record<string, Provider>
current: string
}
declare global {
interface Window {
electronAPI: {
getProviders: () => Promise<Record<string, Provider>>
getCurrentProvider: () => Promise<string>
addProvider: (provider: Provider) => Promise<boolean>
deleteProvider: (id: string) => Promise<boolean>
checkStatus: (provider: Provider) => Promise<ProviderStatus>
switchProvider: (providerId: string) => Promise<boolean>
getClaudeCodeConfigPath: () => Promise<string>
}
}
}