mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-09 17:29:43 +08:00
refactor(code-cli): split renderer — rebuild Code CLI page, drop OpenClaw page
Renderer half of the code-cli refactor (stacked on the main-process PR). - Rebuild Code CLI page with sidebar, config cards, provider cards, config edit panel, version status - Multi-config per CLI tool UI (named configs) - Drop the standalone OpenClaw page (OpenClaw is now managed under Code CLI); remove OpenClawPage, UpdateButton, route, sidebar entry - Update useCodeCli hook + store + i18n for the new config model Part 2 of 2. Depends on the main-process PR; base will move to main after it merges. Note: 11 web typecheck errors remain from rebasing the code-cli renderer onto latest main's UI/preference types — to be resolved before flipping this PR out of draft. Signed-off-by: Pleasurecruise <3196812536@qq.com>
This commit is contained in:
@@ -14,8 +14,6 @@ import {
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
|
||||
import { OpenClawSidebarIcon } from '../Icons/SvgIcon'
|
||||
|
||||
export type IconComponent = React.FC<{ size?: number; strokeWidth?: number; className?: string }>
|
||||
|
||||
// ─── Route → Icon mapping ─────────────────────────────────────────────────────
|
||||
@@ -31,7 +29,6 @@ export const ROUTE_ICONS: Record<string, IconComponent> = {
|
||||
'/app/files': Folder,
|
||||
'/app/code': Code,
|
||||
'/app/notes': NotepadText,
|
||||
'/app/openclaw': OpenClawSidebarIcon,
|
||||
'/settings': Settings
|
||||
}
|
||||
|
||||
|
||||
@@ -1,280 +1,221 @@
|
||||
import { CodeCli, TerminalApp } from '@shared/types/codeCli'
|
||||
import type { CodeCliConfigs } from '@shared/data/preference/preferenceTypes'
|
||||
import { codeCLI } from '@shared/types/codeCli'
|
||||
import { mockUsePreference, MockUsePreferenceUtils } from '@test-mocks/renderer/usePreference'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCodeCli } from '../useCodeCli'
|
||||
|
||||
/**
|
||||
* Helper: set up the unified mock to return a specific overrides value
|
||||
* and capture calls to the setter.
|
||||
*/
|
||||
function setupOverridesMock(overrides: Record<string, any>) {
|
||||
const mockSetOverrides = vi.fn().mockResolvedValue(undefined)
|
||||
/** Set up the preference mock for `feature.code_cli.configs` and capture setter calls. */
|
||||
function setupConfigsMock(configs: CodeCliConfigs) {
|
||||
const mockSetConfigs = vi.fn().mockResolvedValue(undefined)
|
||||
mockUsePreference.mockImplementation((key: string) => {
|
||||
if (key === 'feature.code_cli.overrides') {
|
||||
return [overrides, mockSetOverrides]
|
||||
if (key === 'feature.code_cli.configs') {
|
||||
return [configs, mockSetConfigs]
|
||||
}
|
||||
return [null, vi.fn().mockResolvedValue(undefined)]
|
||||
return [{} as CodeCliConfigs, vi.fn().mockResolvedValue(undefined)]
|
||||
})
|
||||
return mockSetOverrides
|
||||
return mockSetConfigs
|
||||
}
|
||||
|
||||
/** Set updater-style setter that receives a function (mirrors real usePreference updater). */
|
||||
function setupUpdaterMock(configs: CodeCliConfigs) {
|
||||
let current = configs
|
||||
const mockSetConfigs = vi.fn((updater: (prev: CodeCliConfigs) => CodeCliConfigs) => {
|
||||
current = updater(current)
|
||||
return Promise.resolve()
|
||||
})
|
||||
mockUsePreference.mockImplementation((key: string) => {
|
||||
if (key === 'feature.code_cli.configs') {
|
||||
return [current, mockSetConfigs]
|
||||
}
|
||||
return [{} as CodeCliConfigs, vi.fn().mockResolvedValue(undefined)]
|
||||
})
|
||||
return mockSetConfigs
|
||||
}
|
||||
|
||||
const cfg = (
|
||||
overrides: Partial<{ id: string; name: string; providerId: string; modelId: string; directory: string }> = {}
|
||||
) => ({
|
||||
id: overrides.id ?? 'cfg1',
|
||||
name: overrides.name ?? 'Work Claude',
|
||||
providerId: overrides.providerId ?? 'anthropic',
|
||||
modelId: overrides.modelId ?? 'anthropic::claude-4',
|
||||
createdAt: 1,
|
||||
sortIndex: 0,
|
||||
...(overrides.directory ? { directory: overrides.directory } : {})
|
||||
})
|
||||
|
||||
const state = (providers: Record<string, ReturnType<typeof cfg>>, current: string | null) => ({
|
||||
providers,
|
||||
current
|
||||
})
|
||||
|
||||
describe('useCodeCli', () => {
|
||||
beforeEach(() => {
|
||||
MockUsePreferenceUtils.resetMocks()
|
||||
})
|
||||
|
||||
describe('selectedCliTool', () => {
|
||||
it('should return default tool when no tool is enabled', () => {
|
||||
setupOverridesMock({})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.selectedCliTool).toBe(CodeCli.QWEN_CODE)
|
||||
})
|
||||
|
||||
it('should return the enabled tool', () => {
|
||||
setupOverridesMock({ 'claude-code': { enabled: true } })
|
||||
it('should default to claude-code', () => {
|
||||
setupConfigsMock({} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.selectedCliTool).toBe(CodeCli.CLAUDE_CODE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentConfig', () => {
|
||||
it('should return per-tool modelId', () => {
|
||||
setupOverridesMock({ 'claude-code': { enabled: true, modelId: 'anthropic::claude-3-opus' } })
|
||||
it('selectTool should switch the selected tool (navigation state)', () => {
|
||||
setupConfigsMock({
|
||||
'openai-codex': state({ cfg1: cfg() }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.selectedModel).toBe('anthropic::claude-3-opus')
|
||||
})
|
||||
|
||||
it('should return default terminal when none set', () => {
|
||||
setupOverridesMock({})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.selectedTerminal).toBe(TerminalApp.SYSTEM_DEFAULT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCliTool', () => {
|
||||
it('should disable current tool and enable new tool', async () => {
|
||||
const mockSetter = setupOverridesMock({ 'qwen-code': { enabled: true } })
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setCliTool(CodeCli.CLAUDE_CODE)
|
||||
act(() => {
|
||||
result.current.selectTool(codeCLI.openaiCodex)
|
||||
})
|
||||
|
||||
expect(mockSetter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'qwen-code': expect.objectContaining({ enabled: false }),
|
||||
'claude-code': expect.objectContaining({ enabled: true })
|
||||
})
|
||||
)
|
||||
expect(result.current.selectedCliTool).toBe(codeCLI.openaiCodex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setModel', () => {
|
||||
it('should update modelId for current tool', async () => {
|
||||
const mockSetter = setupOverridesMock({ 'qwen-code': { enabled: true } })
|
||||
describe('currentConfig / orderedList', () => {
|
||||
it('should expose the current config', () => {
|
||||
setupConfigsMock({
|
||||
'claude-code': state({ cfg1: cfg() }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.currentConfig?.id).toBe('cfg1')
|
||||
expect(result.current.selectedModel).toBe('anthropic::claude-4')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setModel('openai::gpt-4')
|
||||
})
|
||||
|
||||
expect(mockSetter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'qwen-code': expect.objectContaining({ modelId: 'openai::gpt-4' })
|
||||
})
|
||||
)
|
||||
it('should expose an ordered config list', () => {
|
||||
setupConfigsMock({
|
||||
'claude-code': state(
|
||||
{
|
||||
a: cfg({ id: 'a', name: 'A', sortIndex: undefined as unknown as number }),
|
||||
b: cfg({ id: 'b', name: 'B' })
|
||||
},
|
||||
'b'
|
||||
)
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.orderedList.map((c) => c.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canLaunch', () => {
|
||||
it('should be true when tool, directory, and model are set', () => {
|
||||
setupOverridesMock({
|
||||
'qwen-code': { enabled: true, modelId: 'openai::gpt-4', currentDirectory: '/tmp/project' }
|
||||
})
|
||||
it('should be true when the current config has model and directory', () => {
|
||||
setupConfigsMock({
|
||||
'claude-code': state({ cfg1: cfg({ directory: '/tmp/project' }) }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.canLaunch).toBe(true)
|
||||
})
|
||||
|
||||
it('should be true for github-copilot-cli without model', () => {
|
||||
setupOverridesMock({
|
||||
'github-copilot-cli': { enabled: true, currentDirectory: '/tmp/project' }
|
||||
})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.canLaunch).toBe(true)
|
||||
})
|
||||
|
||||
it('should be true for qoder-cli without model', () => {
|
||||
setupOverridesMock({
|
||||
'qoder-cli': { enabled: true, currentDirectory: '/tmp/project' }
|
||||
})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.canLaunch).toBe(true)
|
||||
})
|
||||
|
||||
it('should be false when no directory is set', () => {
|
||||
setupOverridesMock({
|
||||
'qwen-code': { enabled: true, modelId: 'openai::gpt-4' }
|
||||
})
|
||||
it('should be false when the current config has no directory', () => {
|
||||
setupConfigsMock({
|
||||
'claude-code': state({ cfg1: cfg() }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.canLaunch).toBe(false)
|
||||
})
|
||||
|
||||
it('should be false when no model is set for non-copilot tool', () => {
|
||||
setupOverridesMock({
|
||||
'qwen-code': { enabled: true, currentDirectory: '/tmp/project' }
|
||||
})
|
||||
it('should be false when there is no current config', () => {
|
||||
setupConfigsMock({
|
||||
'claude-code': state({ cfg1: cfg() }, null)
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
expect(result.current.canLaunch).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentDir', () => {
|
||||
it('should set directory and add to directories list', async () => {
|
||||
const mockSetter = setupOverridesMock({ 'qwen-code': { enabled: true } })
|
||||
describe('addConfig', () => {
|
||||
it('should add a new named config with a generated id', async () => {
|
||||
const mockSetter = setupUpdaterMock({} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
let newId = ''
|
||||
await act(async () => {
|
||||
await result.current.setCurrentDir('/new/project')
|
||||
})
|
||||
|
||||
expect(mockSetter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'qwen-code': expect.objectContaining({
|
||||
currentDirectory: '/new/project',
|
||||
directories: ['/new/project']
|
||||
})
|
||||
newId = await result.current.addConfig(codeCLI.claudeCode, {
|
||||
name: 'New',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'anthropic::claude-4'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should move existing directory to front of list', async () => {
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, directories: ['/a', '/b', '/c'] }
|
||||
})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setCurrentDir('/c')
|
||||
})
|
||||
|
||||
const calledValue = mockSetter.mock.calls[0][0]
|
||||
expect(calledValue['qwen-code'].directories).toEqual(['/c', '/a', '/b'])
|
||||
})
|
||||
|
||||
it('should limit directories list to 10 entries', async () => {
|
||||
const dirs = Array.from({ length: 10 }, (_, i) => `/dir${i}`)
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, directories: dirs }
|
||||
})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setCurrentDir('/new-dir')
|
||||
})
|
||||
|
||||
const calledValue = mockSetter.mock.calls[0][0]
|
||||
expect(calledValue['qwen-code'].directories).toHaveLength(10)
|
||||
expect(calledValue['qwen-code'].directories[0]).toBe('/new-dir')
|
||||
})
|
||||
|
||||
it('should not modify directories when directory is empty', async () => {
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, directories: ['/a', '/b'] }
|
||||
})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setCurrentDir('')
|
||||
})
|
||||
|
||||
const calledValue = mockSetter.mock.calls[0][0]
|
||||
expect(calledValue['qwen-code'].directories).toEqual(['/a', '/b'])
|
||||
expect(calledValue['qwen-code'].currentDirectory).toBe('')
|
||||
expect(newId).toBeTruthy()
|
||||
expect(mockSetter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeDir', () => {
|
||||
it('should remove directory from the list', async () => {
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, directories: ['/a', '/b', '/c'] }
|
||||
})
|
||||
describe('updateConfig', () => {
|
||||
it('should patch an existing config', async () => {
|
||||
const mockSetter = setupUpdaterMock({
|
||||
'claude-code': state({ cfg1: cfg() }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.removeDir('/b')
|
||||
await result.current.updateConfig(codeCLI.claudeCode, 'cfg1', { name: 'Renamed' })
|
||||
})
|
||||
|
||||
const calledValue = mockSetter.mock.calls[0][0]
|
||||
expect(calledValue['qwen-code'].directories).toEqual(['/a', '/c'])
|
||||
expect(mockSetter).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset currentDirectory when removing the current directory', async () => {
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, currentDirectory: '/a', directories: ['/a', '/b'] }
|
||||
})
|
||||
it('should be a no-op when the config id does not exist', async () => {
|
||||
const mockSetter = setupUpdaterMock({
|
||||
'claude-code': state({ cfg1: cfg() }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.removeDir('/a')
|
||||
await result.current.updateConfig(codeCLI.claudeCode, 'missing', { name: 'X' })
|
||||
})
|
||||
|
||||
const calledValue = mockSetter.mock.calls[0][0]
|
||||
expect(calledValue['qwen-code'].currentDirectory).toBe('')
|
||||
expect(calledValue['qwen-code'].directories).toEqual(['/b'])
|
||||
})
|
||||
|
||||
it('should not reset currentDirectory when removing a different directory', async () => {
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, currentDirectory: '/a', directories: ['/a', '/b'] }
|
||||
})
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.removeDir('/b')
|
||||
})
|
||||
|
||||
const calledValue = mockSetter.mock.calls[0][0]
|
||||
expect(calledValue['qwen-code'].currentDirectory).toBe('/a')
|
||||
expect(calledValue['qwen-code'].directories).toEqual(['/a'])
|
||||
// Setter still called (it patches), but the providers map is unchanged by the reducer
|
||||
expect(mockSetter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetSettings', () => {
|
||||
it('should reset overrides to empty object', async () => {
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, modelId: 'some-model' }
|
||||
})
|
||||
describe('deleteConfig', () => {
|
||||
it('should remove the config and clear current if it was active', async () => {
|
||||
const mockSetter = setupUpdaterMock({
|
||||
'claude-code': state({ cfg1: cfg() }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.resetSettings()
|
||||
await result.current.deleteConfig(codeCLI.claudeCode, 'cfg1')
|
||||
})
|
||||
|
||||
expect(mockSetter).toHaveBeenCalledWith({})
|
||||
expect(mockSetter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearDirs', () => {
|
||||
it('should clear directories and currentDirectory', async () => {
|
||||
const mockSetter = setupOverridesMock({
|
||||
'qwen-code': { enabled: true, currentDirectory: '/a', directories: ['/a', '/b'] }
|
||||
})
|
||||
describe('setCurrentConfig', () => {
|
||||
it('should set the tool current pointer', async () => {
|
||||
const mockSetter = setupUpdaterMock({
|
||||
'claude-code': state({ cfg1: cfg(), cfg2: cfg({ id: 'cfg2', name: 'B' }) }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearDirs()
|
||||
await result.current.setCurrentConfig(codeCLI.claudeCode, 'cfg2')
|
||||
})
|
||||
|
||||
expect(mockSetter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'qwen-code': expect.objectContaining({
|
||||
directories: [],
|
||||
currentDirectory: ''
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(mockSetter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setDirectory', () => {
|
||||
it('should set a config directory and prepend to the tool MRU list', async () => {
|
||||
const mockSetter = setupUpdaterMock({
|
||||
'claude-code': state({ cfg1: cfg() }, 'cfg1')
|
||||
} as CodeCliConfigs)
|
||||
const { result } = renderHook(() => useCodeCli())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setDirectory('cfg1', '/new/project')
|
||||
})
|
||||
|
||||
expect(mockSetter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,108 +1,201 @@
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { loggerService } from '@logger'
|
||||
import type { CodeCliId, CodeCliOverride, CodeCliOverrides } from '@shared/data/preference/preferenceTypes'
|
||||
import { CODE_CLI_PRESET_MAP } from '@shared/data/presets/codeCli'
|
||||
import { CodeCli } from '@shared/types/codeCli'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type {
|
||||
CliNamedConfig,
|
||||
CodeCliConfigs,
|
||||
CodeCliId,
|
||||
CodeCliToolState
|
||||
} from '@shared/data/preference/preferenceTypes'
|
||||
import { codeCLI } from '@shared/types/codeCli'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
const logger = loggerService.withContext('useCodeCli')
|
||||
|
||||
const DEFAULT_TOOL = CodeCli.QWEN_CODE as CodeCliId
|
||||
const PREFERENCE_KEY = 'feature.code_cli.configs'
|
||||
const DEFAULT_TOOL = codeCLI.claudeCode as CodeCliId
|
||||
|
||||
function getEffectiveToolConfig(toolId: CodeCliId, overrides: CodeCliOverrides): Required<CodeCliOverride> {
|
||||
const preset = CODE_CLI_PRESET_MAP[toolId]
|
||||
const override = overrides[toolId] ?? {}
|
||||
return {
|
||||
enabled: override.enabled ?? preset.enabled,
|
||||
modelId: override.modelId ?? preset.modelId,
|
||||
envVars: override.envVars ?? preset.envVars,
|
||||
terminal: override.terminal ?? preset.terminal,
|
||||
currentDirectory: override.currentDirectory ?? preset.currentDirectory,
|
||||
directories: override.directories ?? [...preset.directories]
|
||||
}
|
||||
const EMPTY_TOOL_STATE: CodeCliToolState = { providers: {}, current: null }
|
||||
|
||||
function getToolState(toolId: CodeCliId, configs: CodeCliConfigs): CodeCliToolState {
|
||||
return configs[toolId] ?? EMPTY_TOOL_STATE
|
||||
}
|
||||
|
||||
/** Generate a short unique client id (timestamp + random). */
|
||||
function generateConfigId(): string {
|
||||
return `cfg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/** Ordered list of configs for a tool (sorted by sortIndex then insertion). */
|
||||
function orderedConfigs(state: CodeCliToolState): CliNamedConfig[] {
|
||||
const entries = Object.values(state.providers)
|
||||
return entries.sort((a, b) => {
|
||||
const ai = a.sortIndex ?? 0
|
||||
const bi = b.sortIndex ?? 0
|
||||
if (ai !== bi) return ai - bi
|
||||
return (a.createdAt ?? 0) - (b.createdAt ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
export const useCodeCli = () => {
|
||||
const [overrides, setOverrides] = usePreference('feature.code_cli.overrides')
|
||||
const [configs, setConfigs] = usePreference(PREFERENCE_KEY)
|
||||
|
||||
const selectedCliTool = useMemo(() => {
|
||||
for (const [toolId, override] of Object.entries(overrides)) {
|
||||
if (override?.enabled) {
|
||||
return toolId as CodeCli
|
||||
}
|
||||
}
|
||||
return DEFAULT_TOOL as CodeCli
|
||||
}, [overrides])
|
||||
const [selectedCliTool, setSelectedCliTool] = useState<codeCLI>(DEFAULT_TOOL)
|
||||
|
||||
const selectTool = useCallback((tool: codeCLI) => {
|
||||
setSelectedCliTool(tool)
|
||||
}, [])
|
||||
|
||||
const currentToolState = useMemo(() => getToolState(selectedCliTool, configs), [selectedCliTool, configs])
|
||||
|
||||
const orderedList = useMemo(() => orderedConfigs(currentToolState), [currentToolState])
|
||||
|
||||
const currentConfig = useMemo(
|
||||
() => getEffectiveToolConfig(selectedCliTool as CodeCliId, overrides),
|
||||
[selectedCliTool, overrides]
|
||||
() => (currentToolState.current ? (currentToolState.providers[currentToolState.current] ?? null) : null),
|
||||
[currentToolState]
|
||||
)
|
||||
|
||||
const selectedModel = currentConfig.modelId
|
||||
const selectedTerminal = currentConfig.terminal
|
||||
const environmentVariables = currentConfig.envVars
|
||||
const directories = currentConfig.directories
|
||||
const currentDirectory = currentConfig.currentDirectory
|
||||
const selectedModel = currentConfig?.modelId ?? null
|
||||
const selectedTerminal = currentToolState.terminal
|
||||
const directories = currentToolState.directories ?? []
|
||||
|
||||
const canLaunch = Boolean(
|
||||
selectedCliTool &&
|
||||
currentDirectory &&
|
||||
(selectedCliTool === CodeCli.GITHUB_COPILOT_CLI || selectedCliTool === CodeCli.QODER_CLI || selectedModel)
|
||||
)
|
||||
const canLaunch = Boolean(currentConfig?.modelId && currentConfig?.directory)
|
||||
|
||||
const updateCurrentTool = useCallback(
|
||||
async (patch: Partial<CodeCliOverride>) => {
|
||||
const toolId = selectedCliTool as CodeCliId
|
||||
const existing = overrides[toolId] ?? {}
|
||||
await setOverrides({
|
||||
...overrides,
|
||||
[toolId]: { ...existing, ...patch }
|
||||
/** Patch a single tool's state. */
|
||||
const patchToolState = useCallback(
|
||||
async (toolId: CodeCliId, patch: (prev: CodeCliToolState) => CodeCliToolState) => {
|
||||
await setConfigs((prevConfigs: CodeCliConfigs) => {
|
||||
const prev = getToolState(toolId, prevConfigs)
|
||||
return { ...prevConfigs, [toolId]: patch(prev) }
|
||||
})
|
||||
},
|
||||
[overrides, setOverrides, selectedCliTool]
|
||||
[setConfigs]
|
||||
)
|
||||
|
||||
const setCliTool = useCallback(
|
||||
async (tool: CodeCli) => {
|
||||
const newOverrides = { ...overrides }
|
||||
const currentId = selectedCliTool as CodeCliId
|
||||
if (newOverrides[currentId]) {
|
||||
newOverrides[currentId] = { ...newOverrides[currentId], enabled: false }
|
||||
/** Add a new named config for a tool. Returns the new config id. */
|
||||
const addConfig = useCallback(
|
||||
async (
|
||||
toolId: CodeCliId,
|
||||
partial: Pick<CliNamedConfig, 'name' | 'providerId' | 'modelId'> & Partial<CliNamedConfig>
|
||||
): Promise<string> => {
|
||||
const id = generateConfigId()
|
||||
const now = Date.now()
|
||||
const orderLen = orderedConfigs(getToolState(toolId, configs)).length
|
||||
const config: CliNamedConfig = {
|
||||
id,
|
||||
name: partial.name,
|
||||
providerId: partial.providerId,
|
||||
modelId: partial.modelId,
|
||||
createdAt: now,
|
||||
sortIndex: orderLen,
|
||||
...(partial.advanced ? { advanced: partial.advanced } : {}),
|
||||
...(partial.directory ? { directory: partial.directory } : {}),
|
||||
...(partial.notes ? { notes: partial.notes } : {}),
|
||||
...(partial.icon ? { icon: partial.icon } : {}),
|
||||
...(partial.iconColor ? { iconColor: partial.iconColor } : {})
|
||||
}
|
||||
const newId = tool as CodeCliId
|
||||
newOverrides[newId] = { ...newOverrides[newId], enabled: true }
|
||||
await setOverrides(newOverrides)
|
||||
await patchToolState(toolId, (prev) => ({
|
||||
...prev,
|
||||
providers: { ...prev.providers, [id]: config }
|
||||
}))
|
||||
logger.info('Added CLI config', { toolId, configId: id })
|
||||
return id
|
||||
},
|
||||
[overrides, setOverrides, selectedCliTool]
|
||||
[configs, patchToolState]
|
||||
)
|
||||
|
||||
const setModel = useCallback(
|
||||
async (modelId: string | null) => {
|
||||
await updateCurrentTool({ modelId })
|
||||
/** Update an existing named config (by id) for a tool. */
|
||||
const updateConfig = useCallback(
|
||||
async (toolId: CodeCliId, configId: string, patch: Partial<CliNamedConfig>) => {
|
||||
await patchToolState(toolId, (prev) => {
|
||||
const existing = prev.providers[configId]
|
||||
if (!existing) return prev
|
||||
return { ...prev, providers: { ...prev.providers, [configId]: { ...existing, ...patch } } }
|
||||
})
|
||||
},
|
||||
[updateCurrentTool]
|
||||
[patchToolState]
|
||||
)
|
||||
|
||||
/** Duplicate an existing config under a new id. */
|
||||
const duplicateConfig = useCallback(
|
||||
async (toolId: CodeCliId, configId: string): Promise<string | null> => {
|
||||
const existing = getToolState(toolId, configs).providers[configId]
|
||||
if (!existing) return null
|
||||
const id = generateConfigId()
|
||||
const orderLen = orderedConfigs(getToolState(toolId, configs)).length
|
||||
const copy: CliNamedConfig = {
|
||||
...existing,
|
||||
id,
|
||||
name: `${existing.name} copy`,
|
||||
createdAt: Date.now(),
|
||||
sortIndex: orderLen
|
||||
}
|
||||
await patchToolState(toolId, (prev) => ({
|
||||
...prev,
|
||||
providers: { ...prev.providers, [id]: copy }
|
||||
}))
|
||||
logger.info('Duplicated CLI config', { toolId, from: configId, to: id })
|
||||
return id
|
||||
},
|
||||
[configs, patchToolState]
|
||||
)
|
||||
|
||||
/** Delete a named config; clears `current` if it was active. */
|
||||
const deleteConfig = useCallback(
|
||||
async (toolId: CodeCliId, configId: string) => {
|
||||
await patchToolState(toolId, (prev) => {
|
||||
const nextProviders = { ...prev.providers }
|
||||
delete nextProviders[configId]
|
||||
return {
|
||||
...prev,
|
||||
providers: nextProviders,
|
||||
current: prev.current === configId ? null : prev.current
|
||||
}
|
||||
})
|
||||
},
|
||||
[patchToolState]
|
||||
)
|
||||
|
||||
/** Set the tool's active config. */
|
||||
const setCurrentConfig = useCallback(
|
||||
async (toolId: CodeCliId, configId: string) => {
|
||||
await patchToolState(toolId, (prev) => ({ ...prev, current: configId }))
|
||||
},
|
||||
[patchToolState]
|
||||
)
|
||||
|
||||
/** Reorder configs by id list (drag-to-reorder). */
|
||||
const reorderConfigs = useCallback(
|
||||
async (toolId: CodeCliId, orderedIds: string[]) => {
|
||||
await patchToolState(toolId, (prev) => {
|
||||
const nextProviders: Record<string, CliNamedConfig> = {}
|
||||
orderedIds.forEach((id, index) => {
|
||||
const existing = prev.providers[id]
|
||||
if (existing) nextProviders[id] = { ...existing, sortIndex: index }
|
||||
})
|
||||
const remainder = orderedConfigs(prev)
|
||||
.filter((c) => !orderedIds.includes(c.id))
|
||||
.map((c, i) => ({ ...c, sortIndex: orderedIds.length + i }))
|
||||
for (const c of remainder) nextProviders[c.id] = c
|
||||
return { ...prev, providers: nextProviders }
|
||||
})
|
||||
},
|
||||
[patchToolState]
|
||||
)
|
||||
|
||||
/** Set the tool-level terminal (shared across the tool's configs). */
|
||||
const setTerminal = useCallback(
|
||||
async (terminal: string) => {
|
||||
await updateCurrentTool({ terminal })
|
||||
await patchToolState(selectedCliTool as CodeCliId, (prev) => ({ ...prev, terminal }))
|
||||
},
|
||||
[updateCurrentTool]
|
||||
[patchToolState, selectedCliTool]
|
||||
)
|
||||
|
||||
const setEnvVars = useCallback(
|
||||
async (envVars: string) => {
|
||||
await updateCurrentTool({ envVars })
|
||||
},
|
||||
[updateCurrentTool]
|
||||
)
|
||||
|
||||
const setCurrentDir = useCallback(
|
||||
async (directory: string) => {
|
||||
/** Set a config's working directory and refresh the tool-level MRU list. */
|
||||
const setDirectory = useCallback(
|
||||
async (configId: string, directory: string) => {
|
||||
const toolId = selectedCliTool as CodeCliId
|
||||
const existing = overrides[toolId] ?? {}
|
||||
const currentDirs = existing.directories ?? []
|
||||
const state = getToolState(toolId, configs)
|
||||
const currentDirs = state.directories ?? []
|
||||
let newDirs: string[]
|
||||
if (directory && !currentDirs.includes(directory)) {
|
||||
newDirs = [directory, ...currentDirs].slice(0, 10)
|
||||
@@ -111,70 +204,68 @@ export const useCodeCli = () => {
|
||||
} else {
|
||||
newDirs = currentDirs
|
||||
}
|
||||
await setOverrides({
|
||||
...overrides,
|
||||
[toolId]: { ...existing, currentDirectory: directory, directories: newDirs }
|
||||
})
|
||||
await patchToolState(toolId, (prev) => ({
|
||||
...prev,
|
||||
directories: newDirs,
|
||||
providers: prev.providers[configId]
|
||||
? { ...prev.providers, [configId]: { ...prev.providers[configId], directory } }
|
||||
: prev.providers
|
||||
}))
|
||||
},
|
||||
[overrides, setOverrides, selectedCliTool]
|
||||
[configs, patchToolState, selectedCliTool]
|
||||
)
|
||||
|
||||
/** Pick a folder via native dialog and assign it to the given config. */
|
||||
const selectFolder = useCallback(
|
||||
async (configId: string): Promise<string | null> => {
|
||||
try {
|
||||
const folderPath = await window.api.file.selectFolder()
|
||||
if (folderPath) {
|
||||
await setDirectory(configId, folderPath)
|
||||
return folderPath
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Failed to select folder:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[setDirectory]
|
||||
)
|
||||
|
||||
const removeDir = useCallback(
|
||||
async (directory: string) => {
|
||||
const toolId = selectedCliTool as CodeCliId
|
||||
const existing = overrides[toolId] ?? {}
|
||||
const currentDirs = existing.directories ?? []
|
||||
const newDirs = currentDirs.filter((d) => d !== directory)
|
||||
const patch: Partial<CodeCliOverride> = { directories: newDirs }
|
||||
if (existing.currentDirectory === directory) {
|
||||
patch.currentDirectory = ''
|
||||
}
|
||||
await setOverrides({
|
||||
...overrides,
|
||||
[toolId]: { ...existing, ...patch }
|
||||
await patchToolState(selectedCliTool as CodeCliId, (prev) => {
|
||||
const currentDirs = prev.directories ?? []
|
||||
const newDirs = currentDirs.filter((d) => d !== directory)
|
||||
return { ...prev, directories: newDirs }
|
||||
})
|
||||
},
|
||||
[overrides, setOverrides, selectedCliTool]
|
||||
[patchToolState, selectedCliTool]
|
||||
)
|
||||
|
||||
const clearDirs = useCallback(async () => {
|
||||
await updateCurrentTool({ directories: [], currentDirectory: '' })
|
||||
}, [updateCurrentTool])
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
await setOverrides({})
|
||||
}, [setOverrides])
|
||||
|
||||
const selectFolder = useCallback(async () => {
|
||||
try {
|
||||
const folderPath = await window.api.file.selectFolder()
|
||||
if (folderPath) {
|
||||
await setCurrentDir(folderPath)
|
||||
return folderPath
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Failed to select folder:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}, [setCurrentDir])
|
||||
|
||||
return {
|
||||
selectedCliTool,
|
||||
configs,
|
||||
currentToolState,
|
||||
orderedList,
|
||||
currentConfig,
|
||||
selectedModel,
|
||||
selectedTerminal,
|
||||
environmentVariables,
|
||||
directories,
|
||||
currentDirectory,
|
||||
canLaunch,
|
||||
setCliTool,
|
||||
setModel,
|
||||
// config CRUD
|
||||
addConfig,
|
||||
updateConfig,
|
||||
duplicateConfig,
|
||||
deleteConfig,
|
||||
setCurrentConfig,
|
||||
reorderConfigs,
|
||||
// tool-level
|
||||
selectTool,
|
||||
setTerminal,
|
||||
setEnvVars,
|
||||
setCurrentDir,
|
||||
removeDir,
|
||||
clearDirs,
|
||||
resetSettings,
|
||||
selectFolder
|
||||
setDirectory,
|
||||
selectFolder,
|
||||
removeDir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,6 @@ const titleKeyMap = {
|
||||
paintings: 'title.paintings',
|
||||
settings: 'title.settings',
|
||||
translate: 'title.translate',
|
||||
openclaw: 'openclaw.title',
|
||||
agents: 'agent.sidebar_title'
|
||||
} as const
|
||||
|
||||
@@ -193,8 +192,7 @@ const sidebarIconKeyMap = {
|
||||
knowledge: 'knowledge.title',
|
||||
files: 'files.title',
|
||||
code_tools: 'code.title',
|
||||
notes: 'notes.title',
|
||||
openclaw: 'openclaw.title'
|
||||
notes: 'notes.title'
|
||||
} as const
|
||||
|
||||
export const getSidebarIconLabelKey = (key: string): string => {
|
||||
@@ -214,8 +212,7 @@ const sidebarFavoriteKeyMap = {
|
||||
knowledge: 'knowledge.title',
|
||||
files: 'files.title',
|
||||
code_tools: 'code.title',
|
||||
notes: 'notes.title',
|
||||
openclaw: 'openclaw.title'
|
||||
notes: 'notes.title'
|
||||
} as const
|
||||
export const getSidebarFavoriteLabelKey = (key: string): string => {
|
||||
return getLabelKey(sidebarFavoriteKeyMap, key)
|
||||
|
||||
@@ -1933,23 +1933,86 @@
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"add_config": "Add",
|
||||
"add_provider_hint": "Add provider in Settings → Model Service",
|
||||
"adv": {
|
||||
"claude": {
|
||||
"auto_compact_window": "Auto Compact Window",
|
||||
"effort_level": "Reasoning Effort",
|
||||
"enable_tool_search": "Enable Tool Search",
|
||||
"include_co_authored_by": "Include Co-authored-by",
|
||||
"max_output_tokens": "Max Output Tokens",
|
||||
"skip_web_fetch_preflight": "Skip Web Fetch Preflight",
|
||||
"timeout_ms": "API Timeout (ms)",
|
||||
"disable_nonessential_traffic": "Disable Nonessential Traffic",
|
||||
"disable_experimental_betas": "Disable Experimental Betas"
|
||||
},
|
||||
"codex": {
|
||||
"auto_compact_token_limit": "Auto Compact Token Limit",
|
||||
"context_window": "Context Window",
|
||||
"disable_response_storage": "Disable Response Storage",
|
||||
"personality": "Personality",
|
||||
"reasoning_effort": "Reasoning Effort",
|
||||
"review_model": "Review Model",
|
||||
"verbosity": "Verbosity"
|
||||
},
|
||||
"effort": {
|
||||
"high": "High",
|
||||
"low": "Low",
|
||||
"medium": "Medium"
|
||||
},
|
||||
"hermes": {
|
||||
"context_length": "Context Length",
|
||||
"max_tokens": "Max Tokens"
|
||||
},
|
||||
"openclaw": {
|
||||
"context_window": "Context Window",
|
||||
"max_tokens": "Max Tokens",
|
||||
"reasoning": "Enable Reasoning"
|
||||
},
|
||||
"opencode": {
|
||||
"budget_tokens": "Reasoning Budget Tokens",
|
||||
"context_limit": "Context Limit",
|
||||
"output_limit": "Output Limit"
|
||||
},
|
||||
"select_placeholder": "Select…"
|
||||
},
|
||||
"auto_update_to_latest": "Automatically update to latest version",
|
||||
"basic_info": "Basic Info",
|
||||
"bun_required_message": "Bun environment is required to run CLI tools",
|
||||
"can_upgrade": "Upgrade available",
|
||||
"cli_tool": "CLI Tool",
|
||||
"cli_tool_placeholder": "Select the CLI tool to use",
|
||||
"config_name": "Name",
|
||||
"config_name_placeholder": "e.g. Work Claude",
|
||||
"configure": "Configure",
|
||||
"configuring_provider": "Configure {{provider}}",
|
||||
"count_one": "{{count}} item",
|
||||
"count_other": "{{count}} items",
|
||||
"current_config": "Current",
|
||||
"current_config_settings": "Current Config",
|
||||
"custom_path": "Custom path",
|
||||
"custom_path_error": "Failed to set custom terminal path",
|
||||
"custom_path_required": "Custom path required for this terminal",
|
||||
"custom_path_set": "Custom terminal path set successfully",
|
||||
"description": "Quickly launch multiple code CLI tools to improve development efficiency",
|
||||
"duplicate": "Duplicate",
|
||||
"duplicate_success": "Duplicated",
|
||||
"edit_config": "Edit Config",
|
||||
"enable": "Enable",
|
||||
"enabled": "Enabled",
|
||||
"endpoint_key_in_model_service": "Endpoint / Key in Model Service",
|
||||
"env_vars_help": "Enter custom environment variables (one per line, format: KEY=value)",
|
||||
"environment_variables": "Environment Variables",
|
||||
"folder_placeholder": "Select working directory",
|
||||
"hero_tagline": "Pick a CLI tool to configure",
|
||||
"install": "Install",
|
||||
"install_bun": "Install Bun",
|
||||
"install_error": "Installation failed",
|
||||
"install_success": "Installation successful",
|
||||
"installing": "Installing…",
|
||||
"installing_bun": "Installing...",
|
||||
"latest": "Latest",
|
||||
"launch": {
|
||||
"bun_required": "Please install Bun environment first before launching CLI tools",
|
||||
"error": "Launch failed, please try again",
|
||||
@@ -1961,9 +2024,18 @@
|
||||
"launching": "Launching...",
|
||||
"model": "Model",
|
||||
"model_hint": "Choose which AI model the CLI tool should use",
|
||||
"model_hint_config": "Select the model to use",
|
||||
"model_placeholder": "Select the model to use",
|
||||
"model_required": "Please select a model",
|
||||
"no_configs_description": "Add a named config to manage multiple providers/models for this CLI tool",
|
||||
"no_configs_title": "No configs yet",
|
||||
"no_matching_tools": "No matching tools",
|
||||
"no_tools": "No tools available",
|
||||
"not_installed": "Not installed",
|
||||
"providers": "Providers",
|
||||
"search_cli_placeholder": "Search CLI…",
|
||||
"select_folder": "Select Folder",
|
||||
"select_tool_to_start": "Select a CLI tool from the left to start configuring",
|
||||
"set_custom_path": "Set custom terminal path",
|
||||
"supported_providers": "Supported Providers",
|
||||
"terminal": "Terminal",
|
||||
@@ -1972,17 +2044,18 @@
|
||||
"title": "Code Tools",
|
||||
"tool_description": {
|
||||
"claude_code": "Anthropic's official command-line AI coding assistant",
|
||||
"gemini_cli": "Google's official Gemini command-line assistant",
|
||||
"github_copilot_cli": "GitHub's official Copilot command-line companion",
|
||||
"kimi_cli": "Moonshot's Kimi command-line assistant",
|
||||
"hermes": "Nous Research agentic coding CLI",
|
||||
"openai_codex": "OpenAI's official code generation CLI",
|
||||
"opencode": "Open-source multi-model coding agent",
|
||||
"qoder_cli": "Alibaba Qoder's agentic command-line coding tool",
|
||||
"qwen_code": "Alibaba Tongyi's open-source CLI coding tool"
|
||||
"openclaw": "Multi-channel personal AI assistant with gateway service",
|
||||
"opencode": "Open-source multi-model coding agent"
|
||||
},
|
||||
"update_options": "Update Options",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_error": "Upgrade failed",
|
||||
"upgrade_success": "Upgrade successful",
|
||||
"working_directory": "Working Directory",
|
||||
"working_directory_hint": "The working directory that the CLI tool launches in"
|
||||
"working_directory_hint": "The working directory that the CLI tool launches in",
|
||||
"advanced_config": "Advanced Config"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
@@ -2496,6 +2569,9 @@
|
||||
},
|
||||
"title": "GPUStack"
|
||||
},
|
||||
"hermes": {
|
||||
"title": "Hermes"
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Continue Chatting",
|
||||
"error": {
|
||||
@@ -6647,7 +6723,6 @@
|
||||
"sync_added_metric": "{{count}} new models",
|
||||
"sync_added_section": "New models",
|
||||
"sync_apply_changes": "Apply changes",
|
||||
"sync_apply_default_in_use": "Some models are in use as the default model and cannot be removed.",
|
||||
"sync_apply_result": "Added {{added}}, deprecated {{deprecated}}, deleted {{deleted}}.",
|
||||
"sync_empty_added": "No new upstream models were found.",
|
||||
"sync_empty_missing": "No unavailable local models were found.",
|
||||
|
||||
@@ -1933,23 +1933,86 @@
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"add_config": "添加",
|
||||
"add_provider_hint": "在 设置 → 模型服务 添加服务商",
|
||||
"adv": {
|
||||
"claude": {
|
||||
"auto_compact_window": "自动压缩窗口",
|
||||
"effort_level": "推理努力程度",
|
||||
"enable_tool_search": "启用工具搜索",
|
||||
"include_co_authored_by": "包含共同作者",
|
||||
"max_output_tokens": "最大输出 Token",
|
||||
"skip_web_fetch_preflight": "跳过 Web 预检",
|
||||
"timeout_ms": "API 超时 (ms)",
|
||||
"disable_nonessential_traffic": "禁用非必要流量",
|
||||
"disable_experimental_betas": "禁用实验性 Beta"
|
||||
},
|
||||
"codex": {
|
||||
"auto_compact_token_limit": "自动压缩 Token 限制",
|
||||
"context_window": "上下文窗口",
|
||||
"disable_response_storage": "禁用响应存储",
|
||||
"personality": "个性",
|
||||
"reasoning_effort": "推理努力程度",
|
||||
"review_model": "审查模型",
|
||||
"verbosity": "详细程度"
|
||||
},
|
||||
"effort": {
|
||||
"high": "高",
|
||||
"low": "低",
|
||||
"medium": "中"
|
||||
},
|
||||
"hermes": {
|
||||
"context_length": "上下文长度",
|
||||
"max_tokens": "最大 Token"
|
||||
},
|
||||
"openclaw": {
|
||||
"context_window": "上下文窗口",
|
||||
"max_tokens": "最大 Token",
|
||||
"reasoning": "启用推理"
|
||||
},
|
||||
"opencode": {
|
||||
"budget_tokens": "推理预算 Token",
|
||||
"context_limit": "上下文限制",
|
||||
"output_limit": "输出限制"
|
||||
},
|
||||
"select_placeholder": "请选择…"
|
||||
},
|
||||
"auto_update_to_latest": "检查更新并安装最新版本",
|
||||
"basic_info": "基本信息",
|
||||
"bun_required_message": "运行 CLI 工具需要安装 Bun 环境",
|
||||
"can_upgrade": "可升级",
|
||||
"cli_tool": "CLI 工具",
|
||||
"cli_tool_placeholder": "选择要使用的 CLI 工具",
|
||||
"config_name": "名称",
|
||||
"config_name_placeholder": "例如:工作 Claude",
|
||||
"configure": "配置",
|
||||
"configuring_provider": "配置 {{provider}}",
|
||||
"count_one": "{{count}} 个",
|
||||
"count_other": "{{count}} 个",
|
||||
"current_config": "当前",
|
||||
"current_config_settings": "当前配置",
|
||||
"custom_path": "自定义路径",
|
||||
"custom_path_error": "设置自定义终端路径失败",
|
||||
"custom_path_required": "此终端需要设置自定义路径",
|
||||
"custom_path_set": "自定义终端路径设置成功",
|
||||
"description": "快速启动多个代码 CLI 工具,提高开发效率",
|
||||
"duplicate": "复制",
|
||||
"duplicate_success": "已复制",
|
||||
"edit_config": "编辑配置",
|
||||
"enable": "启用",
|
||||
"enabled": "已启用",
|
||||
"endpoint_key_in_model_service": "端点 / Key 在 模型服务",
|
||||
"env_vars_help": "输入自定义环境变量(每行一个,格式:KEY=value)",
|
||||
"environment_variables": "环境变量",
|
||||
"folder_placeholder": "选择工作目录",
|
||||
"hero_tagline": "选一个 CLI 工具开始配置",
|
||||
"install": "安装",
|
||||
"install_bun": "安装 Bun",
|
||||
"install_error": "安装失败",
|
||||
"install_success": "安装成功",
|
||||
"installing": "安装中…",
|
||||
"installing_bun": "安装中...",
|
||||
"latest": "最新",
|
||||
"launch": {
|
||||
"bun_required": "请先安装 Bun 环境再启动 CLI 工具",
|
||||
"error": "启动失败,请重试",
|
||||
@@ -1961,9 +2024,18 @@
|
||||
"launching": "启动中...",
|
||||
"model": "模型",
|
||||
"model_hint": "为 CLI 工具选择要使用的 AI 模型",
|
||||
"model_hint_config": "选择要使用的模型",
|
||||
"model_placeholder": "选择要使用的模型",
|
||||
"model_required": "请选择模型",
|
||||
"no_configs_description": "添加命名配置以管理此 CLI 工具的多个供应商/模型",
|
||||
"no_configs_title": "暂无配置",
|
||||
"no_matching_tools": "没有匹配的工具",
|
||||
"no_tools": "暂无工具",
|
||||
"not_installed": "未安装",
|
||||
"providers": "服务商",
|
||||
"search_cli_placeholder": "搜索 CLI…",
|
||||
"select_folder": "选择文件夹",
|
||||
"select_tool_to_start": "从左侧选择一个 CLI 工具开始配置",
|
||||
"set_custom_path": "设置自定义终端路径",
|
||||
"supported_providers": "支持的服务商",
|
||||
"terminal": "终端",
|
||||
@@ -1972,17 +2044,18 @@
|
||||
"title": "代码工具",
|
||||
"tool_description": {
|
||||
"claude_code": "Anthropic 官方 CLI 编程助手",
|
||||
"gemini_cli": "Google Gemini 命令行助手",
|
||||
"github_copilot_cli": "GitHub Copilot 命令行版本",
|
||||
"kimi_cli": "Moonshot Kimi 命令行助手",
|
||||
"hermes": "Nous Research 智能编程 CLI",
|
||||
"openai_codex": "OpenAI 官方代码生成 CLI",
|
||||
"opencode": "开源社区的多模型代码 Agent",
|
||||
"qoder_cli": "阿里 Qoder 的智能体命令行编程工具",
|
||||
"qwen_code": "阿里通义出品的开源 CLI 编程工具"
|
||||
"openclaw": "多通道个人 AI 助手,支持 Gateway 常驻服务",
|
||||
"opencode": "开源社区的多模型代码 Agent"
|
||||
},
|
||||
"update_options": "更新选项",
|
||||
"upgrade": "升级",
|
||||
"upgrade_error": "升级失败",
|
||||
"upgrade_success": "升级成功",
|
||||
"working_directory": "工作目录",
|
||||
"working_directory_hint": "CLI 工具启动时的工作目录"
|
||||
"working_directory_hint": "CLI 工具启动时的工作目录",
|
||||
"advanced_config": "高级配置"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "收起",
|
||||
@@ -2496,6 +2569,9 @@
|
||||
},
|
||||
"title": "GPUStack"
|
||||
},
|
||||
"hermes": {
|
||||
"title": "Hermes"
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "继续聊天",
|
||||
"error": {
|
||||
@@ -6647,7 +6723,6 @@
|
||||
"sync_added_metric": "{{count}} 个新增模型",
|
||||
"sync_added_section": "新增模型",
|
||||
"sync_apply_changes": "确认应用",
|
||||
"sync_apply_default_in_use": "部分模型正被用作默认模型,无法删除。",
|
||||
"sync_apply_result": "已新增 {{added}} 个,标记弃用 {{deprecated}} 个,删除 {{deleted}} 个。",
|
||||
"sync_empty_added": "没有发现新增的上游模型。",
|
||||
"sync_empty_missing": "没有发现已失效的本地模型。",
|
||||
|
||||
@@ -1933,23 +1933,86 @@
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"add_config": "新增",
|
||||
"add_provider_hint": "[to be translated]:Add provider in Settings → Model Service",
|
||||
"adv": {
|
||||
"claude": {
|
||||
"auto_compact_window": "自動壓縮視窗",
|
||||
"effort_level": "推理努力程度",
|
||||
"enable_tool_search": "啟用工具搜尋",
|
||||
"include_co_authored_by": "包含共同作者",
|
||||
"max_output_tokens": "最大輸出 Token",
|
||||
"skip_web_fetch_preflight": "略過 Web 預檢",
|
||||
"timeout_ms": "API 逾時 (ms)",
|
||||
"disable_nonessential_traffic": "停用非必要流量",
|
||||
"disable_experimental_betas": "停用實驗性 Beta"
|
||||
},
|
||||
"codex": {
|
||||
"auto_compact_token_limit": "自動壓縮 Token 限制",
|
||||
"context_window": "上下文視窗",
|
||||
"disable_response_storage": "停用回應儲存",
|
||||
"personality": "個性",
|
||||
"reasoning_effort": "推理努力程度",
|
||||
"review_model": "審查模型",
|
||||
"verbosity": "詳細程度"
|
||||
},
|
||||
"effort": {
|
||||
"high": "高",
|
||||
"low": "低",
|
||||
"medium": "中"
|
||||
},
|
||||
"hermes": {
|
||||
"context_length": "上下文長度",
|
||||
"max_tokens": "最大 Token"
|
||||
},
|
||||
"openclaw": {
|
||||
"context_window": "上下文視窗",
|
||||
"max_tokens": "最大 Token",
|
||||
"reasoning": "啟用推理"
|
||||
},
|
||||
"opencode": {
|
||||
"budget_tokens": "推理預算 Token",
|
||||
"context_limit": "上下文限制",
|
||||
"output_limit": "輸出限制"
|
||||
},
|
||||
"select_placeholder": "請選擇…"
|
||||
},
|
||||
"auto_update_to_latest": "檢查更新並安裝最新版本",
|
||||
"basic_info": "基本資訊",
|
||||
"bun_required_message": "運作 CLI 工具需要安裝 Bun 環境",
|
||||
"can_upgrade": "[to be translated]:Upgrade available",
|
||||
"cli_tool": "CLI 工具",
|
||||
"cli_tool_placeholder": "選擇要使用的 CLI 工具",
|
||||
"config_name": "名稱",
|
||||
"config_name_placeholder": "例如:工作 Claude",
|
||||
"configure": "[to be translated]:Configure",
|
||||
"configuring_provider": "[to be translated]:Configure {{provider}}",
|
||||
"count_one": "{{count}} 個",
|
||||
"count_other": "{{count}} 個",
|
||||
"current_config": "目前",
|
||||
"current_config_settings": "目前設定",
|
||||
"custom_path": "自訂路徑",
|
||||
"custom_path_error": "設定自訂終端機路徑失敗",
|
||||
"custom_path_required": "此終端機需要設定自訂路徑",
|
||||
"custom_path_set": "自訂終端機路徑設定成功",
|
||||
"description": "快速啟動多個程式碼 CLI 工具,提高開發效率",
|
||||
"duplicate": "複製",
|
||||
"duplicate_success": "已複製",
|
||||
"edit_config": "編輯設定",
|
||||
"enable": "[to be translated]:Enable",
|
||||
"enabled": "[to be translated]:Enabled",
|
||||
"endpoint_key_in_model_service": "[to be translated]:Endpoint / Key in Model Service",
|
||||
"env_vars_help": "輸入自訂環境變數(每行一個,格式:KEY=value)",
|
||||
"environment_variables": "環境變數",
|
||||
"folder_placeholder": "選擇工作目錄",
|
||||
"hero_tagline": "選一個 CLI 工具開始設定",
|
||||
"install": "[to be translated]:Install",
|
||||
"install_bun": "安裝 Bun",
|
||||
"install_error": "[to be translated]:Installation failed",
|
||||
"install_success": "[to be translated]:Installation successful",
|
||||
"installing": "[to be translated]:Installing…",
|
||||
"installing_bun": "安裝中...",
|
||||
"latest": "[to be translated]:Latest",
|
||||
"launch": {
|
||||
"bun_required": "請先安裝 Bun 環境再啟動 CLI 工具",
|
||||
"error": "啟動失敗,請重試",
|
||||
@@ -1961,9 +2024,18 @@
|
||||
"launching": "啟動中...",
|
||||
"model": "模型",
|
||||
"model_hint": "為 CLI 工具選擇要使用的 AI 模型",
|
||||
"model_hint_config": "[to be translated]:Select the model to use",
|
||||
"model_placeholder": "選擇要使用的模型",
|
||||
"model_required": "請選擇模型",
|
||||
"no_configs_description": "新增命名設定以管理此 CLI 工具的多個供應商/模型",
|
||||
"no_configs_title": "尚無設定",
|
||||
"no_matching_tools": "[to be translated]:No matching tools",
|
||||
"no_tools": "[to be translated]:No tools available",
|
||||
"not_installed": "[to be translated]:Not installed",
|
||||
"providers": "[to be translated]:Providers",
|
||||
"search_cli_placeholder": "[to be translated]:Search CLI…",
|
||||
"select_folder": "選擇資料夾",
|
||||
"select_tool_to_start": "[to be translated]:Select a CLI tool from the left to start configuring",
|
||||
"set_custom_path": "設定自訂終端機路徑",
|
||||
"supported_providers": "支援的供應商",
|
||||
"terminal": "終端機",
|
||||
@@ -1972,17 +2044,18 @@
|
||||
"title": "程式碼工具",
|
||||
"tool_description": {
|
||||
"claude_code": "Anthropic 官方 CLI 程式助理",
|
||||
"gemini_cli": "Google Gemini 命令列助理",
|
||||
"github_copilot_cli": "GitHub Copilot 命令列版本",
|
||||
"kimi_cli": "Moonshot Kimi 命令列助理",
|
||||
"hermes": "Nous Research 智能編程 CLI",
|
||||
"openai_codex": "OpenAI 官方程式碼產生 CLI",
|
||||
"opencode": "開源社群的多模型程式碼 Agent",
|
||||
"qoder_cli": "阿里 Qoder 的智慧體命令列程式設計工具",
|
||||
"qwen_code": "阿里通義出品的開源 CLI 程式設計工具"
|
||||
"openclaw": "多通道個人 AI 助手,支援 Gateway 常駐服務",
|
||||
"opencode": "開源社群的多模型程式碼 Agent"
|
||||
},
|
||||
"update_options": "更新選項",
|
||||
"upgrade": "[to be translated]:Upgrade",
|
||||
"upgrade_error": "[to be translated]:Upgrade failed",
|
||||
"upgrade_success": "[to be translated]:Upgrade successful",
|
||||
"working_directory": "工作目錄",
|
||||
"working_directory_hint": "CLI 工具啟動時的工作目錄"
|
||||
"working_directory_hint": "CLI 工具啟動時的工作目錄",
|
||||
"advanced_config": "進階設定"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "收合",
|
||||
@@ -2496,6 +2569,9 @@
|
||||
},
|
||||
"title": "GPUStack"
|
||||
},
|
||||
"hermes": {
|
||||
"title": "Hermes"
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "繼續聊天",
|
||||
"error": {
|
||||
@@ -3708,11 +3784,11 @@
|
||||
"deleting": "刪除",
|
||||
"documentFiles": "文件",
|
||||
"download": "下載",
|
||||
"downloading": "下載中",
|
||||
"executeCommand": "執行命令",
|
||||
"executingCommand": "執行命令",
|
||||
"downloading": "[to be translated]:Downloading",
|
||||
"executeCommand": "[to be translated]:Run command",
|
||||
"executingCommand": "[to be translated]:Running command",
|
||||
"extract": "擷取",
|
||||
"extracting": "提取",
|
||||
"extracting": "[to be translated]:Extracting",
|
||||
"file": "檔案",
|
||||
"fileList": "檔案清單",
|
||||
"folder": "資料夾",
|
||||
@@ -6647,7 +6723,6 @@
|
||||
"sync_added_metric": "{{count}} 個新增模型",
|
||||
"sync_added_section": "新增模型",
|
||||
"sync_apply_changes": "確認套用",
|
||||
"sync_apply_default_in_use": "部分模型正被用作預設模型,無法刪除。",
|
||||
"sync_apply_result": "已新增 {{added}} 個,標記棄用 {{deprecated}} 個,刪除 {{deleted}} 個。",
|
||||
"sync_empty_added": "沒有發現新增的上游模型。",
|
||||
"sync_empty_missing": "沒有發現已失效的本機模型。",
|
||||
@@ -6767,11 +6842,11 @@
|
||||
"addToolDescription": "使用 mise 工具鍵新增工具(例如 github:sharkdp/fd、uv、bun)。",
|
||||
"coreDepsMissing": "核心依賴未安裝",
|
||||
"customTools": "自訂工具",
|
||||
"customToolsEmpty": "尚無自訂工具。請新增一個以開始使用。",
|
||||
"customToolsEmpty": "[to be translated]:No custom tools yet. Add one to get started.",
|
||||
"description": "管理應用程式執行所需的二進位工具和執行階段依賴。",
|
||||
"duplicateName": "已存在相同名稱的工具",
|
||||
"fieldVersion": "版本(選填,預設為最新)",
|
||||
"install": "安裝",
|
||||
"install": "[to be translated]:Install",
|
||||
"installError": "工具安裝失敗",
|
||||
"installing": "安裝中...",
|
||||
"invalidTool": "工具名稱或識別碼無效",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,433 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import type { EndpointType, Model } from '@shared/data/types/model'
|
||||
import { ENDPOINT_TYPE, MODEL_CAPABILITY } from '@shared/data/types/model'
|
||||
import type { Provider } from '@shared/data/types/provider'
|
||||
import { CodeCli, TerminalApp } from '@shared/types/codeCli'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
isBunInstalled: true,
|
||||
selectedCliTool: 'github-copilot-cli',
|
||||
selectedModel: null as string | null,
|
||||
canLaunch: true,
|
||||
codeCliRun: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
setTimeoutTimer: vi.fn(),
|
||||
providers: [] as Provider[],
|
||||
models: [] as Model[],
|
||||
modelSelectorProps: [] as any[]
|
||||
}))
|
||||
|
||||
import CodeCliPage from '../CodeCliPage'
|
||||
|
||||
vi.mock('@cherrystudio/ui', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
return {
|
||||
Button: ({ children, loading, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) =>
|
||||
React.createElement('button', { type: 'button', ...props, disabled: props.disabled || loading }, children),
|
||||
Checkbox: ({
|
||||
className,
|
||||
id,
|
||||
onCheckedChange
|
||||
}: {
|
||||
className?: string
|
||||
id?: string
|
||||
onCheckedChange?: (v: boolean) => void
|
||||
}) =>
|
||||
React.createElement('button', {
|
||||
id,
|
||||
type: 'button',
|
||||
role: 'checkbox',
|
||||
className,
|
||||
onClick: () => onCheckedChange?.(true)
|
||||
}),
|
||||
Label: ({ children, htmlFor, className }: { children: React.ReactNode; htmlFor?: string; className?: string }) =>
|
||||
React.createElement('label', { htmlFor, className }, children),
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? React.createElement('div', { role: 'dialog' }, children) : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
DialogClose: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
SelectDropdown: () => React.createElement('div', null),
|
||||
Textarea: {
|
||||
Input: ({ value, onValueChange }: { value?: string; onValueChange?: (value: string) => void }) =>
|
||||
React.createElement('textarea', {
|
||||
value,
|
||||
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => onValueChange?.(event.currentTarget.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@renderer/components/app/Navbar', () => ({
|
||||
Navbar: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
NavbarCenter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Avatar/ModelAvatar', () => ({
|
||||
default: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Selector/model', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
return {
|
||||
ModelSelector: (props: any) => {
|
||||
testState.modelSelectorProps.push(props)
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'code-model-selector' },
|
||||
props.trigger,
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () => props.onSelect('openai::gpt-4o')
|
||||
},
|
||||
'select mock model'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@renderer/utils/platform', () => ({
|
||||
isMac: false,
|
||||
isWin: false
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/ipc', () => ({
|
||||
ipcApi: {
|
||||
request: vi.fn().mockResolvedValue({ version: '1.0.0' })
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/data/hooks/useCache', () => ({
|
||||
usePersistCache: () => [testState.isBunInstalled, vi.fn()]
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useCodeCli', () => ({
|
||||
useCodeCli: () => ({
|
||||
selectedCliTool: testState.selectedCliTool as CodeCli,
|
||||
selectedModel: testState.selectedModel,
|
||||
selectedTerminal: TerminalApp.SYSTEM_DEFAULT,
|
||||
environmentVariables: '',
|
||||
directories: [],
|
||||
currentDirectory: '',
|
||||
canLaunch: testState.canLaunch,
|
||||
setCliTool: vi.fn().mockResolvedValue(undefined),
|
||||
setModel: testState.setModel,
|
||||
setTerminal: vi.fn(),
|
||||
setEnvVars: vi.fn(),
|
||||
setCurrentDir: vi.fn().mockResolvedValue(undefined),
|
||||
removeDir: vi.fn().mockResolvedValue(undefined),
|
||||
selectFolder: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useProvider', () => ({
|
||||
useProviders: () => ({ providers: testState.providers }),
|
||||
getProviderDisplayName: (provider: { name?: string; id?: string }) => provider?.name ?? provider?.id ?? ''
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useModel', () => ({
|
||||
useModels: () => ({ models: testState.models })
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useTimer', () => ({
|
||||
useTimer: () => ({ setTimeoutTimer: testState.setTimeoutTimer })
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/services/LoggerService', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@shared/config/providers', () => ({
|
||||
CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS: [],
|
||||
isSiliconAnthropicCompatibleModel: () => false
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn()
|
||||
},
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('../components/CodeToolGallery', () => ({
|
||||
CodeToolGallery: ({
|
||||
tools,
|
||||
handleSelectTool
|
||||
}: {
|
||||
tools: Array<{ value: CodeCli; label: string }>
|
||||
handleSelectTool: (value: CodeCli) => void
|
||||
}) => (
|
||||
<button type="button" onClick={() => handleSelectTool(tools[0].value)}>
|
||||
open tool
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../components/FieldLabel', () => ({
|
||||
FieldLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
testState.isBunInstalled = true
|
||||
testState.selectedCliTool = CodeCli.GITHUB_COPILOT_CLI
|
||||
testState.selectedModel = null
|
||||
testState.canLaunch = true
|
||||
testState.codeCliRun.mockResolvedValue({ success: true })
|
||||
testState.setModel.mockResolvedValue(undefined)
|
||||
testState.providers = []
|
||||
testState.models = []
|
||||
testState.modelSelectorProps = []
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
isBinaryExist: vi.fn().mockResolvedValue(true),
|
||||
codeCli: {
|
||||
getAvailableTerminals: vi.fn().mockResolvedValue([]),
|
||||
run: testState.codeCliRun
|
||||
}
|
||||
},
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function openCodeToolDialog() {
|
||||
render(<CodeCliPage />)
|
||||
await waitFor(() => expect(window.api.isBinaryExist).toHaveBeenCalledWith('bun'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open tool' }))
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
}
|
||||
|
||||
function makeProvider(
|
||||
id: string,
|
||||
defaultChatEndpoint: EndpointType | undefined = ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS,
|
||||
overrides: Partial<Provider> = {}
|
||||
): Provider {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
apiKeys: [],
|
||||
authType: 'api-key',
|
||||
defaultChatEndpoint,
|
||||
endpointConfigs: {},
|
||||
apiFeatures: {},
|
||||
settings: {},
|
||||
isEnabled: true,
|
||||
...overrides
|
||||
} as Provider
|
||||
}
|
||||
|
||||
function makeModel(id: string, providerId: string, overrides: Partial<Model> = {}): Model {
|
||||
return {
|
||||
id,
|
||||
providerId,
|
||||
name: id.split('::')[1] ?? id,
|
||||
capabilities: [],
|
||||
supportsStreaming: true,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
...overrides
|
||||
} as Model
|
||||
}
|
||||
|
||||
function latestModelSelectorProps() {
|
||||
return testState.modelSelectorProps.at(-1)
|
||||
}
|
||||
|
||||
describe('CodeCliPage', () => {
|
||||
it('uses the shared model selector for non-copilot tools and writes selected ids back', async () => {
|
||||
testState.selectedCliTool = CodeCli.QWEN_CODE
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
expect(screen.getByTestId('code-model-selector')).toBeInTheDocument()
|
||||
expect(latestModelSelectorProps()).toMatchObject({
|
||||
multiple: false,
|
||||
selectionType: 'id',
|
||||
showTagFilter: false
|
||||
})
|
||||
await waitFor(() => expect(latestModelSelectorProps().portalContainer).toBeInstanceOf(HTMLElement))
|
||||
expect(latestModelSelectorProps().portalContainer.closest('[role="dialog"]')).toBe(screen.getByRole('dialog'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'select mock model' }))
|
||||
|
||||
await waitFor(() => expect(testState.setModel).toHaveBeenCalledWith('openai::gpt-4o'))
|
||||
})
|
||||
|
||||
it('does not pass malformed stored model ids to the shared model selector', async () => {
|
||||
testState.selectedCliTool = CodeCli.QWEN_CODE
|
||||
testState.selectedModel = 'legacy-model-id'
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
expect(latestModelSelectorProps().value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the code-cli provider and model filter when using the shared model selector', async () => {
|
||||
testState.selectedCliTool = CodeCli.QWEN_CODE
|
||||
testState.providers = [
|
||||
makeProvider('openai'),
|
||||
makeProvider('anthropic', ENDPOINT_TYPE.ANTHROPIC_MESSAGES),
|
||||
makeProvider('cherryai')
|
||||
]
|
||||
const chatModel = makeModel('openai::gpt-4o', 'openai', {
|
||||
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
|
||||
})
|
||||
const anthropicModel = makeModel('anthropic::claude-3-5-sonnet', 'anthropic', {
|
||||
endpointTypes: [ENDPOINT_TYPE.ANTHROPIC_MESSAGES]
|
||||
})
|
||||
const embeddingModel = makeModel('openai::text-embedding-3-small', 'openai', {
|
||||
capabilities: [MODEL_CAPABILITY.EMBEDDING],
|
||||
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
|
||||
})
|
||||
const rerankModel = makeModel('openai::rerank', 'openai', {
|
||||
capabilities: [MODEL_CAPABILITY.RERANK],
|
||||
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
|
||||
})
|
||||
const imageModel = makeModel('openai::image', 'openai', {
|
||||
capabilities: [MODEL_CAPABILITY.IMAGE_GENERATION],
|
||||
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
|
||||
})
|
||||
const cherryAiModel = makeModel('cherryai::gpt-4o', 'cherryai', {
|
||||
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
|
||||
})
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
const filter = latestModelSelectorProps().filter as (model: Model) => boolean
|
||||
expect(filter(chatModel)).toBe(true)
|
||||
expect(filter(anthropicModel)).toBe(false)
|
||||
expect(filter(embeddingModel)).toBe(false)
|
||||
expect(filter(rerankModel)).toBe(false)
|
||||
expect(filter(imageModel)).toBe(false)
|
||||
expect(filter(cherryAiModel)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the OpenCode provider fallback equivalent to the pre-v2 frontend filter', async () => {
|
||||
testState.selectedCliTool = CodeCli.OPEN_CODE
|
||||
testState.providers = [
|
||||
makeProvider('openai-chat', ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS),
|
||||
makeProvider('new-api', ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS, {
|
||||
presetProviderId: 'new-api',
|
||||
defaultChatEndpoint: undefined
|
||||
}),
|
||||
makeProvider('anthropic', ENDPOINT_TYPE.ANTHROPIC_MESSAGES),
|
||||
makeProvider('gateway', ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS, {
|
||||
presetProviderId: 'gateway',
|
||||
defaultChatEndpoint: undefined
|
||||
})
|
||||
]
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
const filter = latestModelSelectorProps().filter as (model: Model) => boolean
|
||||
expect(filter(makeModel('openai-chat::gpt-4o', 'openai-chat'))).toBe(true)
|
||||
expect(filter(makeModel('new-api::claude-3-5-sonnet', 'new-api'))).toBe(true)
|
||||
expect(filter(makeModel('anthropic::claude-3-5-sonnet', 'anthropic'))).toBe(true)
|
||||
expect(
|
||||
filter(
|
||||
makeModel('gateway::gpt-4o', 'gateway', {
|
||||
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render the model selector for GitHub Copilot CLI', async () => {
|
||||
testState.selectedCliTool = CodeCli.GITHUB_COPILOT_CLI
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
expect(screen.queryByTestId('code-model-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the auto-update checkbox neutral instead of primary themed', async () => {
|
||||
await openCodeToolDialog()
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox')
|
||||
|
||||
// Behavioral guard: page must not theme the auto-update checkbox with the global primary token.
|
||||
expect(checkbox.className).not.toMatch(/primary/)
|
||||
expect(screen.getByText('code.auto_update_to_latest')).toHaveClass('font-normal')
|
||||
})
|
||||
|
||||
it('disables launch when the tool cannot launch', async () => {
|
||||
testState.canLaunch = false
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'code.launch.label' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables launch when bun is not installed', async () => {
|
||||
testState.isBunInstalled = false
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'code.launch.label' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows launching state and prevents duplicate launch submissions', async () => {
|
||||
let resolveRun!: (value: { success: boolean }) => void
|
||||
testState.codeCliRun.mockReturnValue(
|
||||
new Promise<{ success: boolean }>((resolve) => {
|
||||
resolveRun = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
const launchButton = screen.getByRole('button', { name: 'code.launch.label' })
|
||||
fireEvent.click(launchButton)
|
||||
|
||||
const launchingButton = await screen.findByRole('button', { name: 'code.launching' })
|
||||
expect(launchingButton).toBeDisabled()
|
||||
fireEvent.click(launchingButton)
|
||||
expect(testState.codeCliRun).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRun({ success: true })
|
||||
await waitFor(() => expect(window.toast.success).toHaveBeenCalledWith('code.launch.success'))
|
||||
})
|
||||
|
||||
it('shows launched state after a successful launch and schedules reset', async () => {
|
||||
await openCodeToolDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'code.launch.label' }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: /code.launch.launched/ })).toBeEnabled()
|
||||
expect(testState.setTimeoutTimer).toHaveBeenCalledWith('launchSuccess', expect.any(Function), 2500)
|
||||
})
|
||||
|
||||
it('returns to idle and shows an error when launch fails', async () => {
|
||||
testState.codeCliRun.mockResolvedValue({ success: false, message: 'launch failed' })
|
||||
|
||||
await openCodeToolDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'code.launch.label' }))
|
||||
|
||||
await waitFor(() => expect(window.toast.error).toHaveBeenCalledWith('launch failed'))
|
||||
expect(screen.getByRole('button', { name: 'code.launch.label' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CodeCli } from '@shared/types/codeCli'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CLI_TOOLS, generateToolEnvironment, type ToolEnvironmentConfig } from '../index'
|
||||
import { CLI_TOOLS, generateProviderConfig, type ToolEnvironmentConfig } from '../index'
|
||||
|
||||
// Mock CodeCliPage which is default export
|
||||
vi.mock('../CodeCliPage', () => ({ default: () => null }))
|
||||
@@ -9,14 +9,14 @@ vi.mock('../CodeCliPage', () => ({ default: () => null }))
|
||||
// Mock dependencies needed by CodeCliPage
|
||||
vi.mock('@renderer/hooks/useCodeCli', () => ({
|
||||
useCodeCli: () => ({
|
||||
selectedCliTool: CodeCli.QWEN_CODE,
|
||||
selectedCliTool: codeCLI.claudeCode,
|
||||
selectedModel: null,
|
||||
selectedTerminal: 'systemDefault',
|
||||
environmentVariables: '',
|
||||
directories: [],
|
||||
currentDirectory: '',
|
||||
canLaunch: true,
|
||||
setCliTool: vi.fn(),
|
||||
selectTool: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
setTerminal: vi.fn(),
|
||||
setEnvVars: vi.fn(),
|
||||
@@ -59,7 +59,7 @@ vi.mock('react-i18next', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('generateToolEnvironment', () => {
|
||||
describe('generateProviderConfig', () => {
|
||||
const baseConfig = (
|
||||
overrides: Partial<ToolEnvironmentConfig> & Pick<ToolEnvironmentConfig, 'tool' | 'baseUrl'>
|
||||
): ToolEnvironmentConfig => ({
|
||||
@@ -76,66 +76,58 @@ describe('generateToolEnvironment', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should format baseUrl with /v1 for qwenCode when missing', () => {
|
||||
const { env } = generateToolEnvironment(
|
||||
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode' })
|
||||
it('should format baseUrl with /v1 for claudeCode when missing', () => {
|
||||
const config = generateProviderConfig(
|
||||
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode' })
|
||||
)
|
||||
|
||||
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
})
|
||||
|
||||
it('should not duplicate /v1 when already present for qwenCode', () => {
|
||||
const { env } = generateToolEnvironment(
|
||||
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' })
|
||||
it('should not duplicate /v1 when already present for claudeCode', () => {
|
||||
const config = generateProviderConfig(
|
||||
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' })
|
||||
)
|
||||
|
||||
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
})
|
||||
|
||||
it('should handle empty baseUrl gracefully', () => {
|
||||
const { env } = generateToolEnvironment(baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: '' }))
|
||||
const config = generateProviderConfig(baseConfig({ tool: codeCLI.claudeCode, baseUrl: '' }))
|
||||
|
||||
expect(env.OPENAI_BASE_URL).toBe('')
|
||||
expect(config.baseUrl).toBe('')
|
||||
})
|
||||
|
||||
it('should preserve other API versions when present', () => {
|
||||
const { env } = generateToolEnvironment(
|
||||
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/v2' })
|
||||
const config = generateProviderConfig(
|
||||
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/v2' })
|
||||
)
|
||||
|
||||
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2')
|
||||
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/v2')
|
||||
})
|
||||
|
||||
it('should format baseUrl with /v1 for openaiCodex when missing', () => {
|
||||
const { env } = generateToolEnvironment(
|
||||
baseConfig({ tool: CodeCli.OPENAI_CODEX, providerId: 'openai', baseUrl: 'https://api.openai.com' })
|
||||
const config = generateProviderConfig(
|
||||
baseConfig({ tool: codeCLI.openaiCodex, providerId: 'openai', baseUrl: 'https://api.openai.com' })
|
||||
)
|
||||
|
||||
expect(env.CHERRY_CODEX_BASE_URL).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
it('should inject QODERCN_PERSONAL_ACCESS_TOKEN for qoderCli', () => {
|
||||
const { env } = generateToolEnvironment(
|
||||
baseConfig({ tool: CodeCli.QODER_CLI, providerId: 'qoder', apiKey: 'test-key', baseUrl: 'https://api.qoder.com' })
|
||||
)
|
||||
|
||||
expect(env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe('test-key')
|
||||
expect(config.baseUrl).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
it('should handle trailing slash correctly', () => {
|
||||
const { env } = generateToolEnvironment(
|
||||
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/' })
|
||||
const config = generateProviderConfig(
|
||||
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/' })
|
||||
)
|
||||
|
||||
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
})
|
||||
|
||||
it('should handle v2beta version correctly', () => {
|
||||
const { env } = generateToolEnvironment(
|
||||
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/v2beta' })
|
||||
const config = generateProviderConfig(
|
||||
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/v2beta' })
|
||||
)
|
||||
|
||||
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2beta')
|
||||
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/v2beta')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
31
src/renderer/pages/code/components/CLIIcon.tsx
Normal file
31
src/renderer/pages/code/components/CLIIcon.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ClaudeCode, Nousresearch, OpenaiCodex, Openclaw, OpenCode } from '@cherrystudio/ui/icons'
|
||||
import { codeCLI } from '@shared/types/codeCli'
|
||||
import type { FC } from 'react'
|
||||
|
||||
const CLI_ICONS: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
[codeCLI.claudeCode]: ClaudeCode,
|
||||
[codeCLI.openaiCodex]: OpenaiCodex,
|
||||
[codeCLI.openCode]: OpenCode,
|
||||
[codeCLI.openclaw]: Openclaw,
|
||||
[codeCLI.hermes]: Nousresearch
|
||||
}
|
||||
|
||||
interface CLIIconProps {
|
||||
id: string
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const CLIIcon: FC<CLIIconProps> = ({ id, size = 28, className }) => {
|
||||
const Icon = CLI_ICONS[id]
|
||||
if (!Icon) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-md bg-accent/50 text-foreground/70 font-medium ${className}`}
|
||||
style={{ width: size, height: size, fontSize: size * 0.4 }}>
|
||||
{id.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <Icon size={size} className={className} />
|
||||
}
|
||||
84
src/renderer/pages/code/components/CodeCliSidebar.tsx
Normal file
84
src/renderer/pages/code/components/CodeCliSidebar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Scrollbar, SearchInput } from '@cherrystudio/ui'
|
||||
import type { codeCLI } from '@shared/types/codeCli'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { CLI_TOOLS } from '..'
|
||||
import { CLIIcon } from './CLIIcon'
|
||||
import type { CodeToolMeta } from './types'
|
||||
|
||||
type CliToolOption = (typeof CLI_TOOLS)[number]
|
||||
|
||||
export interface CodeCliSidebarProps {
|
||||
tools: readonly CliToolOption[]
|
||||
selectedCliTool: codeCLI
|
||||
onSelectTool: (tool: codeCLI) => void
|
||||
toMeta: (tool: CliToolOption) => CodeToolMeta
|
||||
}
|
||||
|
||||
export const CodeCliSidebar: FC<CodeCliSidebarProps> = ({ tools, selectedCliTool, onSelectTool, toMeta }) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const displayedTools = useMemo(
|
||||
() =>
|
||||
tools.filter(
|
||||
(tool) =>
|
||||
tool.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
tool.value.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
),
|
||||
[tools, searchTerm]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 shrink-0 w-60">
|
||||
<aside className="flex size-full min-h-0 flex-col border-border-muted border-r">
|
||||
<div className="flex shrink-0 items-center gap-2 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
onClear={() => setSearchTerm('')}
|
||||
clearLabel={t('common.clear')}
|
||||
placeholder={t('code.search_cli_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Scrollbar className="min-h-0 flex-1 overflow-x-hidden px-3 pb-3">
|
||||
{displayedTools.length === 0 ? (
|
||||
<div className="text-center text-xs text-muted-foreground/50 py-8">
|
||||
{searchTerm ? t('code.no_matching_tools') : t('code.no_tools')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{displayedTools.map((tool) => {
|
||||
const meta = toMeta(tool)
|
||||
const isSelected = selectedCliTool === tool.value
|
||||
return (
|
||||
<button
|
||||
key={tool.value}
|
||||
type="button"
|
||||
onClick={() => onSelectTool(tool.value)}
|
||||
className={`w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-left transition-colors ${
|
||||
isSelected ? 'bg-accent/55' : 'hover:bg-accent/30'
|
||||
}`}>
|
||||
<CLIIcon id={tool.value} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] text-foreground truncate">{meta.label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground/50 truncate">
|
||||
{t('code.tool_description.' + tool.value.replace(/-/g, '_'))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Scrollbar>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/renderer/pages/code/components/ConfigCard.tsx
Normal file
94
src/renderer/pages/code/components/ConfigCard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Badge, Button, Switch, Tooltip } from '@cherrystudio/ui'
|
||||
import type { CliNamedConfig } from '@shared/data/preference/preferenceTypes'
|
||||
import { Copy, Pencil, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface ConfigCardProps {
|
||||
config: CliNamedConfig
|
||||
providerName?: string
|
||||
modelName?: string
|
||||
isCurrent: boolean
|
||||
onEdit: (config: CliNamedConfig) => void
|
||||
onDuplicate: (config: CliNamedConfig) => void
|
||||
onDelete: (config: CliNamedConfig) => void
|
||||
onToggleCurrent: (config: CliNamedConfig) => void
|
||||
}
|
||||
|
||||
/** A single named-config row, modeled on the ChannelsPage instance row. */
|
||||
export const ConfigCard: FC<ConfigCardProps> = ({
|
||||
config,
|
||||
providerName,
|
||||
modelName,
|
||||
isCurrent,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onToggleCurrent
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-accent/40">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${isCurrent ? 'bg-primary' : 'bg-muted-foreground/30'}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-foreground text-sm">{config.name}</span>
|
||||
{isCurrent && (
|
||||
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[11px] leading-4">
|
||||
{t('code.current_config')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground/50">
|
||||
{providerName && <span className="truncate">{providerName}</span>}
|
||||
{modelName && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="truncate font-mono">{modelName}</span>
|
||||
</>
|
||||
)}
|
||||
{config.directory && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="truncate">{config.directory}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip content={t('common.edit')} side="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => onEdit(config)}
|
||||
className="size-6 text-muted-foreground/40 hover:text-foreground">
|
||||
<Pencil size={10} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('code.duplicate')} side="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => onDuplicate(config)}
|
||||
className="size-6 text-muted-foreground/40 hover:text-foreground">
|
||||
<Copy size={10} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('common.delete')} side="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => onDelete(config)}
|
||||
className="size-6 text-muted-foreground/40 hover:text-destructive">
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Switch size="sm" checked={isCurrent} onCheckedChange={() => onToggleCurrent(config)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
411
src/renderer/pages/code/components/ConfigEditPanel.tsx
Normal file
411
src/renderer/pages/code/components/ConfigEditPanel.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@cherrystudio/ui'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { ModelSelector } from '@renderer/components/Selector/model'
|
||||
import { getProviderDisplayName, useProviders } from '@renderer/hooks/useProvider'
|
||||
import { cn } from '@renderer/utils/style'
|
||||
import type { CliNamedConfig } from '@shared/data/preference/preferenceTypes'
|
||||
import { isUniqueModelId, type Model, parseUniqueModelId, type UniqueModelId } from '@shared/data/types/model'
|
||||
import type { codeCLI } from '@shared/types/codeCli'
|
||||
import { ChevronDown, ChevronDown as ChevronDownIcon } from 'lucide-react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface ConfigEditPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
cliTool: codeCLI
|
||||
/** When editing an existing config; null when adding a new one. */
|
||||
config: CliNamedConfig | null
|
||||
/** Model filter (provider/model compatibility) for this CLI tool. */
|
||||
modelFilter: (model: Model) => boolean
|
||||
onSubmit: (values: {
|
||||
name: string
|
||||
providerId: string
|
||||
modelId: UniqueModelId
|
||||
advanced?: Record<string, unknown>
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
export const ConfigEditPanel: FC<ConfigEditPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
cliTool,
|
||||
config,
|
||||
modelFilter,
|
||||
onSubmit
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
const providerMap = useMemo(() => new Map(providers.map((p) => [p.id, p])), [providers])
|
||||
const advancedFields = useMemo(() => ADVANCED_FIELDS[cliTool] ?? [], [cliTool])
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [modelId, setModelId] = useState<UniqueModelId | undefined>(undefined)
|
||||
const [advanced, setAdvanced] = useState<Record<string, unknown>>({})
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Initialize form fields on open / config change.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (config) {
|
||||
setName(config.name)
|
||||
setModelId(isUniqueModelId(config.modelId) ? config.modelId : undefined)
|
||||
setAdvanced(config.advanced ?? {})
|
||||
} else {
|
||||
setName('')
|
||||
setModelId(undefined)
|
||||
setAdvanced({})
|
||||
}
|
||||
setShowAdvanced(false)
|
||||
}, [open, config])
|
||||
|
||||
const selectedModelRecord = useMemo(() => {
|
||||
if (!modelId) return undefined
|
||||
const { providerId, modelId: rawId } = parseUniqueModelId(modelId)
|
||||
const provider = providerMap.get(providerId)
|
||||
return provider?.models?.find((m) => m.id === rawId)
|
||||
}, [modelId, providerMap])
|
||||
|
||||
const selectedProvider = selectedModelRecord ? providerMap.get(selectedModelRecord.providerId) : undefined
|
||||
|
||||
const canSubmit = name.trim().length > 0 && !!modelId
|
||||
|
||||
const renderModelTrigger = () => (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-9 w-full items-center justify-between rounded-lg border border-border bg-muted/30 px-3 text-sm transition-colors hover:bg-muted/50">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||
{selectedModelRecord ? (
|
||||
<>
|
||||
<ModelAvatar model={selectedModelRecord} size={18} />
|
||||
<span className="truncate text-foreground">{selectedModelRecord.name || selectedModelRecord.id}</span>
|
||||
{selectedProvider && (
|
||||
<span className="shrink-0 text-muted-foreground text-xs">{getProviderDisplayName(selectedProvider)}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate text-muted-foreground/50">{t('code.model_placeholder')}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="ml-2 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit || !modelId) return
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const { providerId } = parseUniqueModelId(modelId)
|
||||
await onSubmit({ name: name.trim(), providerId, modelId, advanced })
|
||||
onClose()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [canSubmit, modelId, name, advanced, onSubmit, onClose])
|
||||
|
||||
const hasAdvanced = advancedFields.length > 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (!o ? onClose() : undefined)}>
|
||||
<DialogContent size="lg" closeOnOverlayClick className="flex max-h-[85vh] flex-col overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 border-border border-b px-5 py-3.5">
|
||||
<DialogTitle className="text-base">{config ? t('code.edit_config') : t('code.add_config')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="scrollbar-thin min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||
{/* Basic info */}
|
||||
<FormField label={t('code.config_name')}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('code.config_name_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Model */}
|
||||
<FormField label={t('code.model')}>
|
||||
<ModelSelector
|
||||
multiple={false}
|
||||
selectionType="id"
|
||||
value={modelId}
|
||||
onSelect={(id) => setModelId(id)}
|
||||
filter={modelFilter}
|
||||
showTagFilter
|
||||
trigger={renderModelTrigger()}
|
||||
portalContainer={typeof document !== 'undefined' ? document.body : null}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Advanced config */}
|
||||
{hasAdvanced && (
|
||||
<>
|
||||
<AdvancedSettingsButton onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={cn('transition-transform duration-200', showAdvanced && 'rotate-180')}
|
||||
/>
|
||||
{t('common.advanced_settings')}
|
||||
</AdvancedSettingsButton>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-4 xl:grid-cols-2">
|
||||
{advancedFields.map((field) => (
|
||||
<AdvancedField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={advanced[field.key]}
|
||||
onChange={(v) => setAdvanced((prev) => ({ ...prev, [field.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-border border-t px-5 py-3.5">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleSubmit} disabled={!canSubmit} loading={submitting}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Layout primitives ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const FormField: FC<{ label: string; children: ReactNode }> = ({ label, children }) => (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="font-medium text-muted-foreground text-xs">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
/** "Advanced Settings" toggle (ghost button with a leading icon). */
|
||||
const AdvancedSettingsButton: FC<React.ComponentPropsWithoutRef<typeof Button>> = ({
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<Button
|
||||
type={type}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('h-8 w-fit gap-1.5 px-2 text-primary hover:text-primary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
// ── Advanced field definitions (model-agnostic params, persisted per config) ──
|
||||
|
||||
interface AdvancedFieldDef {
|
||||
key: string
|
||||
labelKey: string
|
||||
type: 'text' | 'number' | 'boolean' | 'select'
|
||||
placeholder?: string
|
||||
options?: { value: string; labelKey: string }[]
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
const ADVANCED_FIELDS: Record<string, AdvancedFieldDef[]> = {
|
||||
'claude-code': [
|
||||
{ key: 'timeoutMs', labelKey: 'code.adv.claude.timeout_ms', type: 'text', placeholder: '30000' },
|
||||
{ key: 'maxOutputTokens', labelKey: 'code.adv.claude.max_output_tokens', type: 'text', placeholder: '16384' },
|
||||
{
|
||||
key: 'effortLevel',
|
||||
labelKey: 'code.adv.claude.effort_level',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'low', labelKey: 'code.adv.effort.low' },
|
||||
{ value: 'medium', labelKey: 'code.adv.effort.medium' },
|
||||
{ value: 'high', labelKey: 'code.adv.effort.high' }
|
||||
]
|
||||
},
|
||||
{ key: 'autoCompactWindow', labelKey: 'code.adv.claude.auto_compact_window', type: 'text', placeholder: '100000' },
|
||||
{ key: 'enableToolSearch', labelKey: 'code.adv.claude.enable_tool_search', type: 'boolean' },
|
||||
{ key: 'skipWebFetchPreflight', labelKey: 'code.adv.claude.skip_web_fetch_preflight', type: 'boolean' },
|
||||
{ key: 'includeCoAuthoredBy', labelKey: 'code.adv.claude.include_co_authored_by', type: 'boolean' },
|
||||
{ key: 'disableNonessentialTraffic', labelKey: 'code.adv.claude.disable_nonessential_traffic', type: 'boolean' },
|
||||
{ key: 'disableExperimentalBetas', labelKey: 'code.adv.claude.disable_experimental_betas', type: 'boolean' }
|
||||
],
|
||||
'openai-codex': [
|
||||
{
|
||||
key: 'reasoningEffort',
|
||||
labelKey: 'code.adv.codex.reasoning_effort',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'low', labelKey: 'code.adv.effort.low' },
|
||||
{ value: 'medium', labelKey: 'code.adv.effort.medium' },
|
||||
{ value: 'high', labelKey: 'code.adv.effort.high' }
|
||||
]
|
||||
},
|
||||
{ key: 'personality', labelKey: 'code.adv.codex.personality', type: 'text', placeholder: 'pragmatic' },
|
||||
{ key: 'verbosity', labelKey: 'code.adv.codex.verbosity', type: 'text', placeholder: 'concise' },
|
||||
{
|
||||
key: 'contextWindow',
|
||||
labelKey: 'code.adv.codex.context_window',
|
||||
type: 'number',
|
||||
placeholder: '128000',
|
||||
min: 1000,
|
||||
max: 1000000
|
||||
},
|
||||
{
|
||||
key: 'autoCompactTokenLimit',
|
||||
labelKey: 'code.adv.codex.auto_compact_token_limit',
|
||||
type: 'number',
|
||||
placeholder: '100000',
|
||||
min: 1000,
|
||||
max: 1000000
|
||||
},
|
||||
{ key: 'reviewModel', labelKey: 'code.adv.codex.review_model', type: 'text', placeholder: 'gpt-4o' },
|
||||
{ key: 'disableResponseStorage', labelKey: 'code.adv.codex.disable_response_storage', type: 'boolean' }
|
||||
],
|
||||
opencode: [
|
||||
{
|
||||
key: 'budgetTokens',
|
||||
labelKey: 'code.adv.opencode.budget_tokens',
|
||||
type: 'number',
|
||||
placeholder: '10000',
|
||||
min: 1000,
|
||||
max: 100000
|
||||
},
|
||||
{
|
||||
key: 'contextLimit',
|
||||
labelKey: 'code.adv.opencode.context_limit',
|
||||
type: 'number',
|
||||
placeholder: '128000',
|
||||
min: 1000,
|
||||
max: 1000000
|
||||
},
|
||||
{
|
||||
key: 'outputLimit',
|
||||
labelKey: 'code.adv.opencode.output_limit',
|
||||
type: 'number',
|
||||
placeholder: '16384',
|
||||
min: 1000,
|
||||
max: 100000
|
||||
}
|
||||
],
|
||||
openclaw: [
|
||||
{ key: 'reasoning', labelKey: 'code.adv.openclaw.reasoning', type: 'boolean' },
|
||||
{
|
||||
key: 'contextWindow',
|
||||
labelKey: 'code.adv.openclaw.context_window',
|
||||
type: 'number',
|
||||
placeholder: '128000',
|
||||
min: 1000,
|
||||
max: 1000000
|
||||
},
|
||||
{
|
||||
key: 'maxTokens',
|
||||
labelKey: 'code.adv.openclaw.max_tokens',
|
||||
type: 'number',
|
||||
placeholder: '16384',
|
||||
min: 1000,
|
||||
max: 100000
|
||||
}
|
||||
],
|
||||
hermes: [
|
||||
{
|
||||
key: 'contextLength',
|
||||
labelKey: 'code.adv.hermes.context_length',
|
||||
type: 'number',
|
||||
placeholder: '128000',
|
||||
min: 1000,
|
||||
max: 1000000
|
||||
},
|
||||
{
|
||||
key: 'maxTokens',
|
||||
labelKey: 'code.adv.hermes.max_tokens',
|
||||
type: 'number',
|
||||
placeholder: '16384',
|
||||
min: 1000,
|
||||
max: 100000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ── Field renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
const AdvancedField: FC<{ field: AdvancedFieldDef; value: unknown; onChange: (v: unknown) => void }> = ({
|
||||
field,
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
// Boolean → inline row (label + checkbox).
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex h-14 min-w-0 flex-row items-center justify-between gap-4 rounded-md border border-border/70 px-3 xl:col-span-2'
|
||||
)}>
|
||||
<span className="text-foreground text-sm">{t(field.labelKey)}</span>
|
||||
<Checkbox size="sm" checked={Boolean(value)} onCheckedChange={(c) => onChange(c === true)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<FormField label={t(field.labelKey)}>
|
||||
<Select value={(value as string) ?? ''} onValueChange={(v) => onChange(v)}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder={t('code.adv.select_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField label={t(field.labelKey)}>
|
||||
<Input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={(value as string | number) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(field.type === 'number' ? (e.target.value ? Number(e.target.value) : undefined) : e.target.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
className="font-mono"
|
||||
/>
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
112
src/renderer/pages/code/components/ProviderCard.tsx
Normal file
112
src/renderer/pages/code/components/ProviderCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Button } from '@cherrystudio/ui'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type ProviderHealth = 'ok' | 'degraded' | 'down'
|
||||
|
||||
export interface ProviderCardData {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
apiHost: string
|
||||
health: ProviderHealth
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ProviderCardData
|
||||
onConfigure: (providerId: string) => void
|
||||
onEnable: (providerId: string) => void
|
||||
}
|
||||
|
||||
const healthMeta: Record<ProviderHealth, { dot: string; labelKey: string; defaultLabel: string }> = {
|
||||
ok: { dot: 'bg-success', labelKey: 'code.health_ok', defaultLabel: '正常' },
|
||||
degraded: { dot: 'bg-warning', labelKey: 'code.health_degraded', defaultLabel: '波动' },
|
||||
down: { dot: 'bg-destructive', labelKey: 'code.health_down', defaultLabel: '不可用' }
|
||||
}
|
||||
|
||||
export const ProviderCard: FC<ProviderCardProps> = ({ provider, onConfigure, onEnable }) => {
|
||||
const { t } = useTranslation()
|
||||
const meta = healthMeta[provider.health]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-3.5 transition-colors ${
|
||||
provider.isActive ? 'border-success/50 bg-success/[0.04]' : 'border-border/40 hover:border-border'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${meta.dot}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground truncate">{provider.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent/60 text-muted-foreground/60 flex-shrink-0">
|
||||
{provider.type}
|
||||
</span>
|
||||
{provider.isActive && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-success/15 text-success flex-shrink-0">
|
||||
{t('code.enabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground/50 font-mono truncate mt-0.5">{provider.apiHost}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onConfigure(provider.id)}
|
||||
className="gap-1 px-2.5 py-1 text-xs rounded-md border border-border/50">
|
||||
{t('code.configure')}
|
||||
</Button>
|
||||
{!provider.isActive && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEnable(provider.id)}
|
||||
className="gap-1 px-2.5 py-1 text-xs rounded-md">
|
||||
{t('code.enable')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: ProviderCardData[]
|
||||
activeProviderId: string | null
|
||||
onConfigure: (providerId: string) => void
|
||||
onEnable: (providerId: string) => void
|
||||
onAddProvider?: () => void
|
||||
}
|
||||
|
||||
export const ProviderList: FC<ProviderListProps> = ({
|
||||
providers,
|
||||
activeProviderId,
|
||||
onConfigure,
|
||||
onEnable,
|
||||
onAddProvider
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={{ ...provider, isActive: provider.id === activeProviderId }}
|
||||
onConfigure={onConfigure}
|
||||
onEnable={onEnable}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddProvider}
|
||||
className="w-full flex items-center justify-center gap-1 py-2 rounded-xl border border-dashed border-border/50 text-xs text-muted-foreground/55 hover:text-foreground hover:border-border transition-colors">
|
||||
{t('code.add_provider_hint')} <ExternalLink size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
src/renderer/pages/code/components/VersionStatusCard.tsx
Normal file
155
src/renderer/pages/code/components/VersionStatusCard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Badge, Button } from '@cherrystudio/ui'
|
||||
import { Download, ExternalLink, Loader2, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CLIIcon } from './CLIIcon'
|
||||
|
||||
export interface VersionStatus {
|
||||
installed: boolean
|
||||
current?: string
|
||||
latest?: string
|
||||
canUpgrade: boolean
|
||||
}
|
||||
|
||||
interface VersionStatusCardProps {
|
||||
toolId: string
|
||||
toolName: string
|
||||
toolDescription?: string
|
||||
repoUrl?: string
|
||||
homepage?: string
|
||||
status: VersionStatus
|
||||
onInstall?: () => void
|
||||
onUpgrade?: () => void
|
||||
onRemove?: () => void
|
||||
isInstalling?: boolean
|
||||
isUpgrading?: boolean
|
||||
}
|
||||
|
||||
export const VersionStatusCard: FC<VersionStatusCardProps> = ({
|
||||
toolId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
repoUrl,
|
||||
homepage,
|
||||
status,
|
||||
onInstall,
|
||||
onUpgrade,
|
||||
onRemove,
|
||||
isInstalling,
|
||||
isUpgrading
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-card p-4 transition-colors duration-200 ease-in-out hover:border-border-hover">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<CLIIcon id={toolId} size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground text-sm leading-5">{toolName}</span>
|
||||
</div>
|
||||
{status.installed && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1">
|
||||
{status.current && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0 text-[11px] leading-4">
|
||||
v{status.current}
|
||||
</Badge>
|
||||
)}
|
||||
{status.canUpgrade && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 px-1.5 py-0 text-[11px] leading-4 text-warning border-warning/50">
|
||||
{t('code.can_upgrade')} → v{status.latest}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!status.installed && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline" className="gap-1 px-1.5 py-0 text-[11px] leading-4 text-muted-foreground">
|
||||
{t('code.not_installed')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.installed && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{status.canUpgrade && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-foreground/40 hover:text-foreground"
|
||||
onClick={onUpgrade}
|
||||
disabled={isUpgrading}
|
||||
title={t('code.upgrade')}>
|
||||
{isUpgrading ? (
|
||||
<Loader2 className="size-3.5 motion-safe:animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-foreground/40 hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isInstalling || isUpgrading}
|
||||
aria-label={t('settings.plugins.remove')}
|
||||
title={t('settings.plugins.remove')}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toolDescription && (
|
||||
<p className="mt-2.5 line-clamp-2 text-muted-foreground text-xs leading-4" title={toolDescription}>
|
||||
{toolDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
{repoUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={() => void window.api.openWebsite(repoUrl)}>
|
||||
<ExternalLink className="size-3" />
|
||||
{repoUrl.replace('https://github.com/', '')}
|
||||
</button>
|
||||
)}
|
||||
{homepage && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={() => void window.api.openWebsite(homepage)}>
|
||||
<ExternalLink className="size-3" />
|
||||
{homepage.replace(/^https?:\/\//, '')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!status.installed && (
|
||||
<div className="mt-3 border-border border-t pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full gap-1 font-medium text-xs"
|
||||
onClick={onInstall}
|
||||
disabled={isInstalling}
|
||||
loading={isInstalling}>
|
||||
{!isInstalling && <Download className="size-3.5" />}
|
||||
{isInstalling ? t('code.installing') : t('code.install')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
import type { IconComponent } from '@cherrystudio/ui/icons'
|
||||
import {
|
||||
ClaudeCode,
|
||||
GeminiCli,
|
||||
GithubCopilotCli,
|
||||
KimiCli,
|
||||
OpenaiCodex,
|
||||
OpenCode,
|
||||
QoderCli,
|
||||
QwenCode
|
||||
} from '@cherrystudio/ui/icons'
|
||||
import { CLAUDE_SUPPORTED_PROVIDERS } from '@renderer/pages/code/codeProviders'
|
||||
import { ClaudeCode, Nousresearch, OpenaiCodex, Openclaw, OpenCode } from '@cherrystudio/ui/icons'
|
||||
import { CLAUDE_SUPPORTED_PROVIDERS } from '@renderer/config/codeProviders'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { sanitizeProviderName } from '@renderer/utils/naming'
|
||||
import type { EndpointType } from '@shared/data/types/model'
|
||||
import type { Provider } from '@shared/data/types/provider'
|
||||
import { CodeCli } from '@shared/types/codeCli'
|
||||
import { type CliProviderConfig, codeCLI } from '@shared/types/codeCli'
|
||||
import {
|
||||
isAnthropicProvider,
|
||||
isGeminiProvider,
|
||||
isNewApiProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
isOpenAIProvider
|
||||
@@ -56,21 +46,58 @@ export interface ToolEnvironmentConfig {
|
||||
supportsReasoningEffort: boolean
|
||||
budgetTokens?: number
|
||||
}
|
||||
claude?: {
|
||||
haikuModel?: string
|
||||
sonnetModel?: string
|
||||
opusModel?: string
|
||||
timeoutMs?: string
|
||||
maxOutputTokens?: string
|
||||
disableNonessentialTraffic?: number
|
||||
autoCompactWindow?: string
|
||||
disableExperimentalBetas?: string
|
||||
enableToolSearch?: boolean
|
||||
skipWebFetchPreflight?: boolean
|
||||
includeCoAuthoredBy?: boolean
|
||||
effortLevel?: string
|
||||
enabledPlugins?: Record<string, boolean>
|
||||
}
|
||||
codex?: {
|
||||
reasoningEffort?: string
|
||||
disableResponseStorage?: boolean
|
||||
personality?: string
|
||||
verbosity?: string
|
||||
contextWindow?: number
|
||||
autoCompactTokenLimit?: number
|
||||
reviewModel?: string
|
||||
}
|
||||
opencode?: {
|
||||
contextLimit?: number
|
||||
outputLimit?: number
|
||||
}
|
||||
openclaw?: {
|
||||
reasoning?: boolean
|
||||
contextWindow?: number
|
||||
maxTokens?: number
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
hermes?: {
|
||||
contextLength?: number
|
||||
maxTokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
// CLI 工具选项
|
||||
// @legacy — removed in v2: qwenCode, geminiCli, qoderCli, kimiCli, githubCopilotCli
|
||||
export const CLI_TOOLS = [
|
||||
{ value: CodeCli.CLAUDE_CODE, label: 'Claude Code', icon: ClaudeCode },
|
||||
{ value: CodeCli.QWEN_CODE, label: 'Qwen Code', icon: QwenCode },
|
||||
{ value: CodeCli.GEMINI_CLI, label: 'Gemini CLI', icon: GeminiCli },
|
||||
{ value: CodeCli.OPENAI_CODEX, label: 'OpenAI Codex', icon: OpenaiCodex },
|
||||
{ value: CodeCli.QODER_CLI, label: 'Qoder CLI', icon: QoderCli },
|
||||
{ value: CodeCli.GITHUB_COPILOT_CLI, label: 'GitHub Copilot CLI', icon: GithubCopilotCli },
|
||||
{ value: CodeCli.KIMI_CLI, label: 'Kimi Code', icon: KimiCli },
|
||||
{ value: CodeCli.OPEN_CODE, label: 'OpenCode', icon: OpenCode }
|
||||
] as const satisfies ReadonlyArray<{ value: CodeCli; label: string; icon: IconComponent }>
|
||||
{ value: codeCLI.claudeCode, label: 'Claude Code', icon: ClaudeCode },
|
||||
{ value: codeCLI.openaiCodex, label: 'OpenAI Codex', icon: OpenaiCodex },
|
||||
{ value: codeCLI.openCode, label: 'OpenCode', icon: OpenCode },
|
||||
{ value: codeCLI.openclaw, label: 'OpenClaw', icon: Openclaw },
|
||||
{ value: codeCLI.hermes, label: 'Hermes', icon: Nousresearch }
|
||||
] as const satisfies ReadonlyArray<{ value: codeCLI; label: string; icon: IconComponent }>
|
||||
|
||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
||||
// @legacy — removed in v2
|
||||
// export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
||||
|
||||
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
|
||||
|
||||
@@ -87,24 +114,19 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
|
||||
providers.filter(
|
||||
(p) => isAnthropicProvider(p) || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id) || hasAnthropicEndpoint(p)
|
||||
),
|
||||
[CodeCli.GEMINI_CLI]: (providers) =>
|
||||
providers.filter((p) => isGeminiProvider(p) || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)),
|
||||
[CodeCli.QWEN_CODE]: (providers) => providers.filter(isOpenAILikeProvider),
|
||||
[CodeCli.OPENAI_CODEX]: (providers) =>
|
||||
// @legacy — removed in v2: geminiCli, qwenCode, qoderCli, githubCopilotCli, kimiCli
|
||||
[codeCLI.openaiCodex]: (providers) =>
|
||||
providers.filter((p) => isOpenAIProvider(p) || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
|
||||
[CodeCli.QODER_CLI]: () => [],
|
||||
[CodeCli.GITHUB_COPILOT_CLI]: () => [],
|
||||
[CodeCli.KIMI_CLI]: (providers) => providers.filter(isOpenAILikeProvider),
|
||||
[CodeCli.OPEN_CODE]: (providers) => providers.filter(isOpenCodeProvider)
|
||||
|
||||
[codeCLI.openCode]: (providers) => providers.filter(isOpenCodeProvider),
|
||||
[codeCLI.openclaw]: (providers) => providers.filter(isOpenCodeProvider),
|
||||
[codeCLI.hermes]: (providers) => providers.filter(isOpenAILikeProvider)
|
||||
}
|
||||
|
||||
export const getCodeCliApiBaseUrl = (providerId: string, type: 'anthropic' | 'gemini') => {
|
||||
const CODE_CLI_API_ENDPOINTS = {
|
||||
aihubmix: {
|
||||
gemini: {
|
||||
api_base_url: 'https://aihubmix.com/gemini'
|
||||
}
|
||||
},
|
||||
// @legacy — removed in v2: gemini endpoint type
|
||||
// export const getCodeCliApiBaseUrl = (providerId, type) => { ... }
|
||||
export const getCodeCliApiBaseUrl = (providerId: string, type: 'anthropic') => {
|
||||
const CODE_CLI_API_ENDPOINTS: Record<string, { anthropic?: { api_base_url: string } }> = {
|
||||
deepseek: {
|
||||
anthropic: {
|
||||
api_base_url: 'https://api.deepseek.com/anthropic'
|
||||
@@ -165,14 +187,8 @@ export const parseEnvironmentVariables = (envVars: string): Record<string, strin
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Opencode expects a wire-format string in OPENCODE_PROVIDER_TYPE. v2 has no
|
||||
* `provider.type`; the caller derives this from v2 predicates.
|
||||
*/
|
||||
export type ProviderWireType = 'anthropic' | 'openai-response' | 'openai'
|
||||
|
||||
// 为不同 CLI 工具生成环境变量配置
|
||||
export const generateToolEnvironment = ({
|
||||
// Resolve the selected provider/model into the typed config the matching CLI writer persists in main
|
||||
export const generateProviderConfig = ({
|
||||
tool,
|
||||
rawModelId,
|
||||
modelName,
|
||||
@@ -183,100 +199,110 @@ export const generateToolEnvironment = ({
|
||||
anthropicBaseUrl,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
reasoning
|
||||
}: ToolEnvironmentConfig & { providerWireType?: ProviderWireType }): { env: Record<string, string> } => {
|
||||
const env: Record<string, string> = {}
|
||||
reasoning,
|
||||
claude,
|
||||
codex,
|
||||
opencode,
|
||||
openclaw,
|
||||
hermes
|
||||
}: ToolEnvironmentConfig): CliProviderConfig => {
|
||||
const formattedBaseUrl = formatApiHost(baseUrl)
|
||||
|
||||
switch (tool) {
|
||||
case CodeCli.CLAUDE_CODE: {
|
||||
// https://code.claude.com/docs/en/env-vars — mark provider env as
|
||||
// host-managed so Claude Code ignores ANTHROPIC_* from the user's
|
||||
// ~/.claude/settings.json (avoids auth-token/api-key conflict). #15089
|
||||
env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1'
|
||||
env.ANTHROPIC_BASE_URL = getCodeCliApiBaseUrl(providerId, 'anthropic') || anthropicBaseUrl || baseUrl
|
||||
env.ANTHROPIC_MODEL = rawModelId
|
||||
if (isAnthropic) {
|
||||
env.ANTHROPIC_API_KEY = apiKey
|
||||
} else {
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey
|
||||
case codeCLI.claudeCode:
|
||||
return {
|
||||
baseUrl: getCodeCliApiBaseUrl(providerId, 'anthropic') || anthropicBaseUrl || baseUrl,
|
||||
model: rawModelId,
|
||||
...(isAnthropic ? { apiKey } : { authToken: apiKey }),
|
||||
...(claude?.haikuModel !== undefined ? { haikuModel: claude.haikuModel } : {}),
|
||||
...(claude?.sonnetModel !== undefined ? { sonnetModel: claude.sonnetModel } : {}),
|
||||
...(claude?.opusModel !== undefined ? { opusModel: claude.opusModel } : {}),
|
||||
...(claude?.timeoutMs !== undefined ? { timeoutMs: claude.timeoutMs } : {}),
|
||||
...(claude?.maxOutputTokens !== undefined ? { maxOutputTokens: claude.maxOutputTokens } : {}),
|
||||
...(claude?.disableNonessentialTraffic !== undefined
|
||||
? { disableNonessentialTraffic: claude.disableNonessentialTraffic }
|
||||
: {}),
|
||||
...(claude?.autoCompactWindow !== undefined ? { autoCompactWindow: claude.autoCompactWindow } : {}),
|
||||
...(claude?.disableExperimentalBetas !== undefined
|
||||
? { disableExperimentalBetas: claude.disableExperimentalBetas }
|
||||
: {}),
|
||||
...(claude?.enableToolSearch !== undefined ? { enableToolSearch: claude.enableToolSearch } : {}),
|
||||
...(claude?.skipWebFetchPreflight !== undefined ? { skipWebFetchPreflight: claude.skipWebFetchPreflight } : {}),
|
||||
...(claude?.includeCoAuthoredBy !== undefined ? { includeCoAuthoredBy: claude.includeCoAuthoredBy } : {}),
|
||||
...(claude?.effortLevel !== undefined ? { effortLevel: claude.effortLevel } : {}),
|
||||
...(claude?.enabledPlugins !== undefined ? { enabledPlugins: claude.enabledPlugins } : {})
|
||||
}
|
||||
|
||||
// @legacy — removed in v2: geminiCli, qwenCode
|
||||
|
||||
case codeCLI.openaiCodex:
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: formattedBaseUrl,
|
||||
providerName: sanitizeProviderName(fancyProviderName),
|
||||
model: rawModelId,
|
||||
...(codex?.reasoningEffort !== undefined ? { reasoningEffort: codex.reasoningEffort } : {}),
|
||||
...(codex?.disableResponseStorage !== undefined
|
||||
? { disableResponseStorage: codex.disableResponseStorage }
|
||||
: {}),
|
||||
...(codex?.personality !== undefined ? { personality: codex.personality } : {}),
|
||||
...(codex?.verbosity !== undefined ? { verbosity: codex.verbosity } : {}),
|
||||
...(codex?.contextWindow !== undefined ? { contextWindow: codex.contextWindow } : {}),
|
||||
...(codex?.autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: codex.autoCompactTokenLimit } : {}),
|
||||
...(codex?.reviewModel !== undefined ? { reviewModel: codex.reviewModel } : {})
|
||||
}
|
||||
|
||||
// @legacy — removed in v2: kimiCli
|
||||
|
||||
case codeCLI.openCode: {
|
||||
// @ai-sdk/anthropic appends /messages to the baseURL, so preserve any existing /v1 (formatApiHost
|
||||
// with appendV1=false); other endpoints get the standard /v1.
|
||||
const isAnthropicEndpoint = endpointType === 'anthropic-messages' || (!endpointType && isAnthropic)
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: isAnthropicEndpoint ? formatApiHost(baseUrl, false) : formattedBaseUrl,
|
||||
providerName: sanitizeProviderName(fancyProviderName),
|
||||
providerType: isAnthropic ? 'anthropic' : 'openai',
|
||||
endpointType: endpointType ?? '',
|
||||
model: rawModelId,
|
||||
modelName,
|
||||
isReasoning: reasoning?.isReasoning ?? false,
|
||||
supportsReasoningEffort: reasoning?.supportsReasoningEffort ?? false,
|
||||
...(reasoning?.budgetTokens !== undefined ? { budgetTokens: reasoning.budgetTokens } : {}),
|
||||
...(opencode?.contextLimit !== undefined ? { contextLimit: opencode.contextLimit } : {}),
|
||||
...(opencode?.outputLimit !== undefined ? { outputLimit: opencode.outputLimit } : {})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case CodeCli.GEMINI_CLI: {
|
||||
const apiBaseUrl = getCodeCliApiBaseUrl(providerId, 'gemini') || baseUrl
|
||||
env.GEMINI_API_KEY = apiKey
|
||||
env.GEMINI_BASE_URL = apiBaseUrl
|
||||
env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl
|
||||
env.GEMINI_MODEL = rawModelId
|
||||
break
|
||||
}
|
||||
|
||||
case CodeCli.QWEN_CODE:
|
||||
env.OPENAI_API_KEY = apiKey
|
||||
env.OPENAI_BASE_URL = formattedBaseUrl
|
||||
env.OPENAI_MODEL = rawModelId
|
||||
break
|
||||
case CodeCli.OPENAI_CODEX:
|
||||
// Codex CLI rejects model_providers keys colliding with its reserved
|
||||
// built-in IDs (openai/ollama/lmstudio). Hand the provider through
|
||||
// Cherry-namespaced vars; CodeToolsService maps them to a sanitized
|
||||
// Cherry- prefixed config key (or openai_base_url for reserved). #15068
|
||||
env.CHERRY_CODEX_API_KEY = apiKey
|
||||
env.CHERRY_CODEX_BASE_URL = formattedBaseUrl
|
||||
env.CHERRY_CODEX_PROVIDER_ID = providerId
|
||||
env.CHERRY_CODEX_PROVIDER_NAME = sanitizeProviderName(fancyProviderName)
|
||||
break
|
||||
|
||||
case CodeCli.QODER_CLI:
|
||||
env.QODERCN_PERSONAL_ACCESS_TOKEN = apiKey || ''
|
||||
break
|
||||
|
||||
case CodeCli.GITHUB_COPILOT_CLI:
|
||||
env.GITHUB_TOKEN = apiKey || ''
|
||||
break
|
||||
|
||||
case CodeCli.KIMI_CLI:
|
||||
env.KIMI_MODEL_NAME = rawModelId
|
||||
env.KIMI_MODEL_API_KEY = apiKey
|
||||
env.KIMI_MODEL_BASE_URL = formattedBaseUrl
|
||||
env.KIMI_MODEL_PROVIDER_TYPE = 'openai'
|
||||
break
|
||||
|
||||
case CodeCli.OPEN_CODE:
|
||||
// Set environment variable with provider-specific suffix for security
|
||||
{
|
||||
// Determine base URL format based on model's endpoint type and provider type
|
||||
// anthropic: use formatApiHost(url, false) to preserve existing /v1 from provider config
|
||||
// @ai-sdk/anthropic appends /messages to the baseURL (not /v1/messages)
|
||||
// others: append /v1 (standard OpenAI-compatible endpoint)
|
||||
const isAnthropicEndpoint = endpointType === 'anthropic-messages' || (!endpointType && isAnthropic)
|
||||
const openCodeBaseUrl = isAnthropicEndpoint ? formatApiHost(baseUrl, false) : formattedBaseUrl
|
||||
|
||||
env.OPENCODE_BASE_URL = openCodeBaseUrl
|
||||
env.OPENCODE_MODEL_NAME = modelName
|
||||
env.OPENCODE_MODEL_ENDPOINT_TYPE = endpointType ?? ''
|
||||
// Reasoning flags are precomputed by the caller (v2 @shared/utils/model).
|
||||
const providerName = sanitizeProviderName(fancyProviderName)
|
||||
env.OPENCODE_MODEL_IS_REASONING = String(reasoning?.isReasoning ?? false)
|
||||
env.OPENCODE_MODEL_SUPPORTS_REASONING_EFFORT = String(reasoning?.supportsReasoningEffort ?? false)
|
||||
if (reasoning?.budgetTokens !== undefined) {
|
||||
env.OPENCODE_MODEL_BUDGET_TOKENS = String(reasoning.budgetTokens)
|
||||
}
|
||||
env.OPENCODE_PROVIDER_TYPE = isAnthropic ? 'anthropic' : 'openai'
|
||||
env.OPENCODE_PROVIDER_NAME = providerName
|
||||
const envVarKey = `OPENCODE_API_KEY_${providerName.toUpperCase().replace(/[-.]/g, '_')}`
|
||||
env[envVarKey] = apiKey
|
||||
// opencode's auto-update check can't detect Cherry Studio's bun install,
|
||||
// causing a confusing "Update Available" dialog that always fails.
|
||||
// Cherry Studio manages opencode updates via its own autoUpdateToLatest.
|
||||
env.OPENCODE_DISABLE_AUTOUPDATE = 'true'
|
||||
case codeCLI.openclaw:
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: formattedBaseUrl,
|
||||
api: isAnthropic ? 'anthropic-messages' : 'openai-completions',
|
||||
model: rawModelId,
|
||||
modelName,
|
||||
providerName: sanitizeProviderName(fancyProviderName),
|
||||
...(openclaw?.reasoning !== undefined ? { reasoning: openclaw.reasoning } : {}),
|
||||
...(openclaw?.contextWindow !== undefined ? { contextWindow: openclaw.contextWindow } : {}),
|
||||
...(openclaw?.maxTokens !== undefined ? { maxTokens: openclaw.maxTokens } : {}),
|
||||
...(openclaw?.headers !== undefined ? { headers: openclaw.headers } : {})
|
||||
}
|
||||
break
|
||||
|
||||
case codeCLI.hermes:
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: formattedBaseUrl,
|
||||
apiMode: isAnthropic ? 'anthropic_messages' : 'chat_completions',
|
||||
model: rawModelId,
|
||||
modelName,
|
||||
providerName: sanitizeProviderName(fancyProviderName),
|
||||
...(hermes?.contextLength !== undefined ? { contextLength: hermes.contextLength } : {}),
|
||||
...(hermes?.maxTokens !== undefined ? { maxTokens: hermes.maxTokens } : {})
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool for provider config: ${tool}`)
|
||||
}
|
||||
|
||||
return { env }
|
||||
}
|
||||
|
||||
export { default } from './CodeCliPage'
|
||||
|
||||
@@ -1,628 +0,0 @@
|
||||
import { Alert, Button } from '@cherrystudio/ui'
|
||||
import { Openclaw } from '@cherrystudio/ui/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { CopyIcon } from '@renderer/components/Icons'
|
||||
import { ModelSelector } from '@renderer/components/Selector'
|
||||
import { useSharedCache } from '@renderer/data/hooks/useCache'
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { useMiniAppPopup } from '@renderer/hooks/useMiniAppPopup'
|
||||
import { useModelById } from '@renderer/hooks/useModel'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { type Model as SharedModel } from '@shared/data/types/model'
|
||||
import { isUniqueModelId, type UniqueModelId } from '@shared/data/types/model'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { isNonChatModel } from '@shared/utils/model'
|
||||
import { ChevronDown, Download, ExternalLink, Loader2, Play, Square, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
|
||||
import UpdateButton from './components/UpdateButton'
|
||||
|
||||
const logger = loggerService.withContext('OpenClawPage')
|
||||
|
||||
const DEFAULT_DOCS_URL = 'https://docs.openclaw.ai/'
|
||||
const NO_API_KEY_PROVIDERS = new Set(['ollama', 'lmstudio', 'gpustack'])
|
||||
|
||||
interface TitleSectionProps {
|
||||
title: string
|
||||
description: string
|
||||
clickable?: boolean
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
const TitleSection: FC<TitleSectionProps> = ({ title, description, clickable = false, docsUrl }) => (
|
||||
<div className="-mt-20 mb-8 flex flex-col items-center text-center">
|
||||
<div
|
||||
className={clickable ? 'cursor-pointer' : undefined}
|
||||
onClick={clickable ? () => window.open(docsUrl ?? DEFAULT_DOCS_URL, '_blank') : undefined}>
|
||||
<Openclaw.Avatar size={64} shape="rounded" />
|
||||
</div>
|
||||
<h1
|
||||
className={`mt-3 font-semibold text-2xl ${clickable ? 'cursor-pointer hover:text-(--color-primary)' : ''}`}
|
||||
style={{ color: 'var(--color-text-1)' }}
|
||||
onClick={clickable ? () => window.open(docsUrl ?? DEFAULT_DOCS_URL, '_blank') : undefined}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed" style={{ color: 'var(--color-text-2)' }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const OpenClawPage: FC = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
const { openSmartMiniApp } = useMiniAppPopup()
|
||||
|
||||
const docsUrl = useMemo(() => {
|
||||
const lang = i18n.language?.toLowerCase() ?? ''
|
||||
if (lang.startsWith('zh-cn')) {
|
||||
return 'https://docs.openclaw.ai/zh-CN'
|
||||
}
|
||||
return DEFAULT_DOCS_URL
|
||||
}, [i18n.language])
|
||||
|
||||
const [gatewayPort] = usePreference('feature.openclaw.gateway_port')
|
||||
const [selectedModelId, setSelectedModelId] = usePreference('feature.openclaw.selected_model_id')
|
||||
const [gatewayStatus, setGatewayStatus] = useSharedCache('feature.openclaw.gateway_status')
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isInstalled, setIsInstalled] = useState<boolean | null>(null) // null = unknown, checking in background
|
||||
const [needsMigration, setNeedsMigration] = useState(false)
|
||||
const [installPath, setInstallPath] = useState<string | null>(null)
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
|
||||
// Separate loading states for each action
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
const [isUninstalling, setIsUninstalling] = useState(false)
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
// Install progress logs
|
||||
const [installLogs, setInstallLogs] = useState<Array<{ message: string; type: 'info' | 'warn' | 'error' }>>([])
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [uninstallSuccess, setUninstallSuccess] = useState(false)
|
||||
const [isOpenClawUpdating, setIsOpenClawUpdating] = useState(false)
|
||||
|
||||
const selectedUniqueModelId = useMemo<UniqueModelId | undefined>(() => {
|
||||
return selectedModelId && isUniqueModelId(selectedModelId) ? selectedModelId : undefined
|
||||
}, [selectedModelId])
|
||||
|
||||
/** v2 read-only lookup for the currently-selected model — drives the trigger label.
|
||||
* The hook short-circuits on falsy ids via `enabled: !!uniqueModelId`, so the
|
||||
* empty-string fallback is safe; the cast satisfies its template-literal arg type. */
|
||||
const { model: selectedModel } = useModelById((selectedUniqueModelId ?? '') as UniqueModelId)
|
||||
|
||||
/**
|
||||
* Drop models whose owning provider has no usable credentials. Local
|
||||
* runtimes (ollama / lmstudio / gpustack) bypass the check since they
|
||||
* accept any placeholder. The new ModelSelector already filters out
|
||||
* disabled providers, so we only need the credential check here.
|
||||
*/
|
||||
const modelFilter = useCallback(
|
||||
(model: SharedModel) => {
|
||||
if (isNonChatModel(model)) return false
|
||||
const provider = providers.find((p) => p.id === model.providerId)
|
||||
if (!provider) return false
|
||||
if (NO_API_KEY_PROVIDERS.has(provider.id)) return true
|
||||
return provider.apiKeys.some((k) => k.isEnabled)
|
||||
},
|
||||
[providers]
|
||||
)
|
||||
|
||||
type PageState = 'checking' | 'not_installed' | 'installed' | 'installing' | 'uninstalling'
|
||||
const pageState: PageState = useMemo(() => {
|
||||
if (isUninstalling) return 'uninstalling'
|
||||
if (isInstalling) return 'installing'
|
||||
if (isInstalled === null) return 'checking'
|
||||
if (isInstalled) return 'installed'
|
||||
return 'not_installed'
|
||||
}, [isInstalled, isInstalling, isUninstalling])
|
||||
|
||||
const checkInstallation = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.api.openclaw.checkInstalled()
|
||||
setIsInstalled(result.installed)
|
||||
setNeedsMigration(result.needsMigration)
|
||||
setShowLogs(false)
|
||||
setInstallPath(result.path)
|
||||
} catch (err) {
|
||||
logger.debug('Failed to check installation', err as Error)
|
||||
setIsInstalled(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInstall = useCallback(async () => {
|
||||
setIsInstalling(true)
|
||||
setInstallError(null)
|
||||
setInstallLogs([])
|
||||
setShowLogs(true)
|
||||
try {
|
||||
const result = await window.api.openclaw.install()
|
||||
if (result.success) {
|
||||
await checkInstallation()
|
||||
} else {
|
||||
setInstallError(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to install OpenClaw', err as Error)
|
||||
setInstallError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setIsInstalling(false)
|
||||
}
|
||||
}, [checkInstallation])
|
||||
|
||||
const handleUninstall = useCallback(async () => {
|
||||
// Use window.confirm for confirmation
|
||||
const confirmed = window.confirm(t('openclaw.uninstall_confirm'))
|
||||
if (!confirmed) {
|
||||
return // User cancelled
|
||||
}
|
||||
|
||||
setIsUninstalling(true)
|
||||
setUninstallSuccess(false)
|
||||
setInstallError(null)
|
||||
setInstallLogs([])
|
||||
setShowLogs(true)
|
||||
try {
|
||||
const result = await window.api.openclaw.uninstall()
|
||||
if (result.success) {
|
||||
setUninstallSuccess(true)
|
||||
} else {
|
||||
setInstallError(result.message)
|
||||
setIsUninstalling(false)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to uninstall OpenClaw', err as Error)
|
||||
setInstallError(err instanceof Error ? err.message : String(err))
|
||||
setIsUninstalling(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleUninstallComplete = useCallback(() => {
|
||||
setShowLogs(false)
|
||||
setIsUninstalling(false)
|
||||
if (uninstallSuccess) {
|
||||
setIsInstalled(false)
|
||||
setUninstallSuccess(false)
|
||||
}
|
||||
}, [uninstallSuccess])
|
||||
|
||||
// Poll gateway status every 5s (only when installed).
|
||||
// useSWR handles deduplication and stable polling without the infinite-loop
|
||||
// pitfall of setInterval + dispatch in useEffect deps.
|
||||
const isInstallPage = pageState === 'installed'
|
||||
|
||||
useSWR(
|
||||
isInstallPage ? 'openclaw/status' : null,
|
||||
async () => {
|
||||
const [status] = await Promise.all([window.api.openclaw.getStatus(), checkInstallation()])
|
||||
setGatewayStatus(status.status)
|
||||
return status
|
||||
},
|
||||
{ refreshInterval: 5000, revalidateOnFocus: false }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void checkInstallation()
|
||||
}, [checkInstallation])
|
||||
|
||||
// Listen for install progress events
|
||||
useEffect(() => {
|
||||
const cleanup = window.electron.ipcRenderer.on(
|
||||
IpcChannel.OpenClaw_InstallProgress,
|
||||
(_, data: { message: string; type: 'info' | 'warn' | 'error' }) => {
|
||||
setInstallLogs((prev) => [...prev, data])
|
||||
}
|
||||
)
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const handleModelSelect = (next: UniqueModelId | undefined) => {
|
||||
if (!next) return
|
||||
void setSelectedModelId(next)
|
||||
}
|
||||
|
||||
const handleStartGateway = async () => {
|
||||
if (!selectedUniqueModelId) {
|
||||
setError(t('openclaw.error.select_provider_model'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsStarting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// First sync the configuration (auth token will be auto-generated in main process)
|
||||
const syncResult = await window.api.openclaw.syncConfig(selectedUniqueModelId)
|
||||
if (!syncResult.success) {
|
||||
setError(syncResult.message)
|
||||
setIsStarting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Then start the gateway
|
||||
const startResult = await window.api.openclaw.startGateway(gatewayPort)
|
||||
if (!startResult.success) {
|
||||
setError(startResult.message)
|
||||
setIsStarting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto open dashboard first
|
||||
const dashboardUrl = await window.api.openclaw.getDashboardUrl()
|
||||
|
||||
openSmartMiniApp({
|
||||
appId: 'openclaw-dashboard',
|
||||
name: 'OpenClaw',
|
||||
url: dashboardUrl,
|
||||
logo: 'openclaw'
|
||||
})
|
||||
|
||||
void mutate('openclaw/status', { status: 'running' }, { revalidate: false })
|
||||
setGatewayStatus('running')
|
||||
setIsStarting(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setIsStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopGateway = async () => {
|
||||
setIsStopping(true)
|
||||
try {
|
||||
const result = await window.api.openclaw.stopGateway()
|
||||
if (result.success) {
|
||||
setGatewayStatus('stopped')
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setIsStopping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDashboard = async () => {
|
||||
try {
|
||||
const dashboardUrl = await window.api.openclaw.getDashboardUrl()
|
||||
openSmartMiniApp({
|
||||
appId: 'openclaw-dashboard',
|
||||
name: 'OpenClaw',
|
||||
url: dashboardUrl,
|
||||
logo: 'openclaw'
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to open dashboard', err as Error)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const renderLogContainer = (expanded = false) => (
|
||||
<div className="mb-6 overflow-hidden rounded-lg" style={{ background: 'var(--color-background-soft)' }}>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 font-medium text-[13px]"
|
||||
style={{ background: 'var(--color-background-mute)' }}>
|
||||
<span>{t(expanded ? 'openclaw.uninstall_progress' : 'openclaw.install_progress')}</span>
|
||||
{!expanded && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowLogs(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed ${expanded ? 'h-75' : 'h-37.5'}`}>
|
||||
{installLogs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="whitespace-pre-wrap break-all"
|
||||
style={{
|
||||
color:
|
||||
log.type === 'error'
|
||||
? 'var(--color-error)'
|
||||
: log.type === 'warn'
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-text-2)'
|
||||
}}>
|
||||
{log.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderNotInstalledContent = () => (
|
||||
<div id="content-container" className="flex flex-1 flex-col overflow-y-auto py-5">
|
||||
<div className="flex-1" />
|
||||
<div className="mx-auto min-h-fit w-130 shrink-0">
|
||||
<div className="mb-6 flex flex-col items-center text-center">
|
||||
<Openclaw.Avatar size={64} shape="rounded" />
|
||||
<h2 className="mt-5 font-semibold text-foreground text-lg">
|
||||
{t(needsMigration ? 'openclaw.migration.title' : 'openclaw.not_installed.title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm leading-relaxed">
|
||||
{t(needsMigration ? 'openclaw.migration.description' : 'openclaw.not_installed.description')}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<Button disabled={isInstalling} onClick={handleInstall} loading={isInstalling}>
|
||||
{!isInstalling && <Download size={16} />}
|
||||
{t(needsMigration ? 'openclaw.migration.install_button' : 'openclaw.not_installed.install_button')}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={isInstalling} onClick={() => window.open(docsUrl, '_blank')}>
|
||||
<ExternalLink size={16} />
|
||||
{t('openclaw.quick_actions.view_docs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{installError && (
|
||||
<Alert
|
||||
message={installError}
|
||||
type="error"
|
||||
className="mb-4"
|
||||
action={
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setInstallError(null)}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLogs && installLogs.length > 0 && renderLogContainer()}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderInstalledContent = () => (
|
||||
<div id="content-container" className="flex flex-1 overflow-y-auto py-5">
|
||||
<div className="m-auto min-h-fit w-130">
|
||||
<TitleSection title={t('openclaw.title')} description={t('openclaw.description')} clickable docsUrl={docsUrl} />
|
||||
|
||||
{/* Install Path - hide when gateway is running */}
|
||||
{installPath && gatewayStatus !== 'running' && (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
style={{ background: 'var(--color-background-soft)', color: 'var(--color-text-3)' }}>
|
||||
<div className="min-w-0 shrink overflow-hidden">
|
||||
<div className="mb-1">{t('openclaw.installed_at')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xs" title={installPath}>
|
||||
{installPath}
|
||||
</div>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-4! p-0! shadow-none"
|
||||
aria-label={t('common.copy')}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installPath)
|
||||
window.toast.success(t('common.copied'))
|
||||
} catch (error) {
|
||||
window.toast.error(t('common.copy_failed'))
|
||||
logger.error('Failed to copy install path:', error as Error)
|
||||
}
|
||||
}}>
|
||||
<CopyIcon className="size-3!" />
|
||||
</Button>
|
||||
<UpdateButton onUpdateComplete={checkInstallation} onUpdatingChange={setIsOpenClawUpdating} />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="cursor-pointer whitespace-nowrap text-xs transition-colors hover:text-(--color-error)!"
|
||||
style={{ color: 'var(--color-text-3)' }}
|
||||
onClick={handleUninstall}>
|
||||
{t('openclaw.quick_actions.uninstall')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gateway Status Card - show when running */}
|
||||
{gatewayStatus === 'running' && (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between rounded-lg p-3"
|
||||
style={{ background: 'var(--color-background-soft)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="font-medium text-sm" style={{ color: 'var(--color-text-1)' }}>
|
||||
{t('openclaw.status.running')}
|
||||
</span>
|
||||
<span className="font-mono text-[13px]" style={{ color: 'var(--color-text-3)' }}>
|
||||
:{gatewayPort}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleStopGateway}
|
||||
loading={isStopping}
|
||||
disabled={isStopping}
|
||||
className="text-destructive hover:text-destructive">
|
||||
{!isStopping && <Square size={14} />}
|
||||
{t('openclaw.gateway.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="mb-6">
|
||||
<Alert
|
||||
message={
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="max-h-25 flex-1 overflow-y-auto whitespace-pre-wrap break-all">{error}</span>
|
||||
</div>
|
||||
}
|
||||
type="error"
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="shadow-none"
|
||||
aria-label={t('common.copy')}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(error)
|
||||
window.toast.success(t('common.copied'))
|
||||
} catch (err) {
|
||||
window.toast.error(t('common.copy_failed'))
|
||||
logger.error('Failed to copy error message:', err as Error)
|
||||
}
|
||||
}}>
|
||||
<CopyIcon className="size-3!" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="shadow-none"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setError(null)}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selector - only show when not running */}
|
||||
{gatewayStatus !== 'running' && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium text-sm" style={{ color: 'var(--color-text-1)' }}>
|
||||
{t('openclaw.model_config.model')}
|
||||
</div>
|
||||
<ModelSelector
|
||||
multiple={false}
|
||||
selectionType="id"
|
||||
value={selectedUniqueModelId}
|
||||
filter={modelFilter}
|
||||
onSelect={handleModelSelect}
|
||||
trigger={
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
{selectedModel ? <ModelAvatar model={selectedModel} size={18} /> : null}
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedModel ? selectedModel.name : t('openclaw.model_config.select_model')}
|
||||
</span>
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="mt-1 text-xs" style={{ color: 'var(--color-text-3)' }}>
|
||||
{t('openclaw.model_config.sync_hint')}
|
||||
</div>
|
||||
|
||||
{/* Tips about OpenClaw */}
|
||||
<div
|
||||
className="mt-4 rounded-lg p-3 text-xs leading-relaxed"
|
||||
style={{ background: 'var(--color-background-mute)', color: 'var(--color-text-3)' }}>
|
||||
<div className="mb-1">💡 {t('openclaw.tips.title')}</div>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>{t('openclaw.tips.permissions')}</li>
|
||||
<li>{t('openclaw.tips.token_usage')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLogs && installLogs.length > 0 && renderLogContainer()}
|
||||
|
||||
{gatewayStatus !== 'running' && (
|
||||
<Button
|
||||
onClick={handleStartGateway}
|
||||
loading={isStarting || gatewayStatus === 'starting'}
|
||||
disabled={!selectedUniqueModelId || isStarting || gatewayStatus === 'starting' || isOpenClawUpdating}
|
||||
size="lg"
|
||||
className="w-full">
|
||||
{!isStarting && gatewayStatus !== 'starting' && <Play size={16} />}
|
||||
{t('openclaw.gateway.start')}
|
||||
</Button>
|
||||
)}
|
||||
{gatewayStatus === 'running' && (
|
||||
<Button onClick={handleOpenDashboard} size="lg" className="w-full">
|
||||
{t('openclaw.quick_actions.open_dashboard')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderCheckingContent = () => (
|
||||
<div id="content-container" className="flex flex-1 flex-col items-center justify-center">
|
||||
<Loader2 className="size-7 animate-spin" style={{ color: 'var(--color-primary)' }} />
|
||||
<div className="mt-4" style={{ color: 'var(--color-text-3)' }}>
|
||||
{t('openclaw.checking_installation')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render uninstalling page - only show logs
|
||||
const renderUninstallingContent = () => (
|
||||
<div id="content-container" className="flex flex-1 overflow-y-auto py-5">
|
||||
<div className="m-auto min-h-fit w-130">
|
||||
<TitleSection
|
||||
title={t(uninstallSuccess ? 'openclaw.uninstalled.title' : 'openclaw.uninstalling.title')}
|
||||
description={t(uninstallSuccess ? 'openclaw.uninstalled.description' : 'openclaw.uninstalling.description')}
|
||||
/>
|
||||
|
||||
{installError && (
|
||||
<div className="mb-6">
|
||||
<Alert
|
||||
message={installError}
|
||||
type="error"
|
||||
action={
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setInstallError(null)}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderLogContainer(true)}
|
||||
|
||||
<Button disabled={!uninstallSuccess} onClick={handleUninstallComplete} className="w-full" size="lg">
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderContent = () => {
|
||||
switch (pageState) {
|
||||
case 'uninstalling':
|
||||
return renderUninstallingContent()
|
||||
case 'checking':
|
||||
return renderCheckingContent()
|
||||
case 'installed':
|
||||
return renderInstalledContent()
|
||||
case 'not_installed':
|
||||
case 'installing':
|
||||
return renderNotInstalledContent()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('openclaw.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
<div className="flex flex-1 flex-col">{renderContent()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenClawPage
|
||||
@@ -1,103 +0,0 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { ArrowUpCircle, Loader2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('UpdateButton')
|
||||
|
||||
export interface UpdateInfo {
|
||||
hasUpdate: boolean
|
||||
currentVersion: string | null
|
||||
latestVersion: string | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface UpdateButtonProps {
|
||||
onUpdateComplete?: () => void
|
||||
onUpdatingChange?: (isUpdating: boolean) => void
|
||||
}
|
||||
|
||||
const UpdateButton: FC<UpdateButtonProps> = ({ onUpdateComplete, onUpdatingChange }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
|
||||
// Notify parent when updating state changes
|
||||
useEffect(() => {
|
||||
onUpdatingChange?.(isUpdating)
|
||||
}, [isUpdating, onUpdatingChange])
|
||||
|
||||
const checkUpdate = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.api.openclaw.checkUpdate()
|
||||
setUpdateInfo(result)
|
||||
} catch (err) {
|
||||
logger.error('Failed to check for updates', err as Error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const performUpdate = useCallback(async () => {
|
||||
setIsUpdating(true)
|
||||
try {
|
||||
const result = await window.api.openclaw.performUpdate()
|
||||
if (result.success) {
|
||||
setUpdateInfo(null)
|
||||
window.toast.success(t('openclaw.update.success'))
|
||||
onUpdateComplete?.()
|
||||
} else {
|
||||
window.toast.error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to update OpenClaw', err as Error)
|
||||
window.toast.error(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}, [onUpdateComplete, t])
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
void checkUpdate()
|
||||
}, [checkUpdate])
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUpdating) return
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('openclaw.update.modal_title'),
|
||||
content: t('openclaw.update.available', {
|
||||
latest: updateInfo?.latestVersion,
|
||||
current: updateInfo?.currentVersion
|
||||
}),
|
||||
okText: t('openclaw.update.confirm_button'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
// Start update without waiting, modal closes immediately
|
||||
void performUpdate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Don't render if no update available and not updating
|
||||
if (!updateInfo?.hasUpdate && !isUpdating) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex cursor-pointer items-center gap-1" onClick={handleClick}>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="size-3! animate-spin" style={{ color: 'var(--color-primary)' }} />
|
||||
) : (
|
||||
<ArrowUpCircle className="size-3!" color="var(--color-primary)" />
|
||||
)}
|
||||
<span className="text-xs" style={{ color: 'var(--color-primary)' }}>
|
||||
{isUpdating ? t('openclaw.update.updating') : `v${updateInfo?.latestVersion}`}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateButton
|
||||
@@ -29,7 +29,6 @@ import { Route as SettingsChannelsRouteImport } from './routes/settings/channels
|
||||
import { Route as SettingsApiGatewayRouteImport } from './routes/settings/api-gateway'
|
||||
import { Route as SettingsAboutRouteImport } from './routes/settings/about'
|
||||
import { Route as AppTranslateRouteImport } from './routes/app/translate'
|
||||
import { Route as AppOpenclawRouteImport } from './routes/app/openclaw'
|
||||
import { Route as AppNotesRouteImport } from './routes/app/notes'
|
||||
import { Route as AppLibraryRouteImport } from './routes/app/library'
|
||||
import { Route as AppLaunchpadRouteImport } from './routes/app/launchpad'
|
||||
@@ -152,11 +151,6 @@ const AppTranslateRoute = AppTranslateRouteImport.update({
|
||||
path: '/translate',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppOpenclawRoute = AppOpenclawRouteImport.update({
|
||||
id: '/openclaw',
|
||||
path: '/openclaw',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppNotesRoute = AppNotesRouteImport.update({
|
||||
id: '/notes',
|
||||
path: '/notes',
|
||||
@@ -270,7 +264,6 @@ export interface FileRoutesByFullPath {
|
||||
'/app/launchpad': typeof AppLaunchpadRoute
|
||||
'/app/library': typeof AppLibraryRoute
|
||||
'/app/notes': typeof AppNotesRoute
|
||||
'/app/openclaw': typeof AppOpenclawRoute
|
||||
'/app/translate': typeof AppTranslateRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/api-gateway': typeof SettingsApiGatewayRoute
|
||||
@@ -312,7 +305,6 @@ export interface FileRoutesByTo {
|
||||
'/app/launchpad': typeof AppLaunchpadRoute
|
||||
'/app/library': typeof AppLibraryRoute
|
||||
'/app/notes': typeof AppNotesRoute
|
||||
'/app/openclaw': typeof AppOpenclawRoute
|
||||
'/app/translate': typeof AppTranslateRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/api-gateway': typeof SettingsApiGatewayRoute
|
||||
@@ -355,7 +347,6 @@ export interface FileRoutesById {
|
||||
'/app/launchpad': typeof AppLaunchpadRoute
|
||||
'/app/library': typeof AppLibraryRoute
|
||||
'/app/notes': typeof AppNotesRoute
|
||||
'/app/openclaw': typeof AppOpenclawRoute
|
||||
'/app/translate': typeof AppTranslateRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/api-gateway': typeof SettingsApiGatewayRoute
|
||||
@@ -400,7 +391,6 @@ export interface FileRouteTypes {
|
||||
| '/app/launchpad'
|
||||
| '/app/library'
|
||||
| '/app/notes'
|
||||
| '/app/openclaw'
|
||||
| '/app/translate'
|
||||
| '/settings/about'
|
||||
| '/settings/api-gateway'
|
||||
@@ -442,7 +432,6 @@ export interface FileRouteTypes {
|
||||
| '/app/launchpad'
|
||||
| '/app/library'
|
||||
| '/app/notes'
|
||||
| '/app/openclaw'
|
||||
| '/app/translate'
|
||||
| '/settings/about'
|
||||
| '/settings/api-gateway'
|
||||
@@ -484,7 +473,6 @@ export interface FileRouteTypes {
|
||||
| '/app/launchpad'
|
||||
| '/app/library'
|
||||
| '/app/notes'
|
||||
| '/app/openclaw'
|
||||
| '/app/translate'
|
||||
| '/settings/about'
|
||||
| '/settings/api-gateway'
|
||||
@@ -664,13 +652,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppTranslateRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/app/openclaw': {
|
||||
id: '/app/openclaw'
|
||||
path: '/openclaw'
|
||||
fullPath: '/app/openclaw'
|
||||
preLoaderRoute: typeof AppOpenclawRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/app/notes': {
|
||||
id: '/app/notes'
|
||||
path: '/notes'
|
||||
@@ -823,7 +804,6 @@ interface AppRouteChildren {
|
||||
AppLaunchpadRoute: typeof AppLaunchpadRoute
|
||||
AppLibraryRoute: typeof AppLibraryRoute
|
||||
AppNotesRoute: typeof AppNotesRoute
|
||||
AppOpenclawRoute: typeof AppOpenclawRoute
|
||||
AppTranslateRoute: typeof AppTranslateRoute
|
||||
AppMiniAppAppIdRoute: typeof AppMiniAppAppIdRoute
|
||||
AppPaintingsSplatRoute: typeof AppPaintingsSplatRoute
|
||||
@@ -840,7 +820,6 @@ const AppRouteChildren: AppRouteChildren = {
|
||||
AppLaunchpadRoute: AppLaunchpadRoute,
|
||||
AppLibraryRoute: AppLibraryRoute,
|
||||
AppNotesRoute: AppNotesRoute,
|
||||
AppOpenclawRoute: AppOpenclawRoute,
|
||||
AppTranslateRoute: AppTranslateRoute,
|
||||
AppMiniAppAppIdRoute: AppMiniAppAppIdRoute,
|
||||
AppPaintingsSplatRoute: AppPaintingsSplatRoute,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import OpenClawPage from '@renderer/pages/openclaw/OpenClawPage'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/app/openclaw')({
|
||||
component: OpenClawPage
|
||||
})
|
||||
@@ -18,7 +18,6 @@ const routeTitleKeys: Record<string, string> = {
|
||||
'/app/files': 'title.files',
|
||||
'/app/code': 'title.code',
|
||||
'/app/notes': 'title.notes',
|
||||
'/app/openclaw': 'title.openclaw',
|
||||
'/settings': 'title.settings'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SidebarMenuItem } from '@renderer/components/Sidebar/types'
|
||||
import {
|
||||
buildTabInstanceMetadata,
|
||||
getTabInstanceAppId,
|
||||
@@ -118,10 +119,6 @@ export const SIDEBAR_APPS: readonly SidebarApp[] = [
|
||||
{
|
||||
id: 'notes',
|
||||
routePrefix: '/app/notes'
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
routePrefix: '/app/openclaw'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user