diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index f3185747..aab9b571 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.128 +0.1.129 diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 94010b62..45dd5d4f 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -275,23 +275,27 @@ const clientTabs = computed((): TabConfig[] => { tabs.push({ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }) } tabs.push({ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }) + tabs.push({ id: 'openclaw', label: t('keys.useKeyModal.cliTabs.openclaw'), icon: TerminalIcon }) return tabs } case 'gemini': return [ { id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon }, - { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } + { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }, + { id: 'openclaw', label: t('keys.useKeyModal.cliTabs.openclaw'), icon: TerminalIcon } ] case 'antigravity': return [ { id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }, { id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon }, - { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } + { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }, + { id: 'openclaw', label: t('keys.useKeyModal.cliTabs.openclaw'), icon: TerminalIcon } ] default: return [ { id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }, - { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } + { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }, + { id: 'openclaw', label: t('keys.useKeyModal.cliTabs.openclaw'), icon: TerminalIcon } ] } }) @@ -309,7 +313,7 @@ const openaiTabs: TabConfig[] = [ { id: 'windows', label: 'Windows', icon: WindowsIcon } ] -const showShellTabs = computed(() => activeClientTab.value !== 'opencode') +const showShellTabs = computed(() => activeClientTab.value !== 'opencode' && activeClientTab.value !== 'openclaw') const currentTabs = computed(() => { if (!showShellTabs.value) return [] @@ -412,6 +416,21 @@ const currentFiles = computed((): FileConfig[] => { } } + if (activeClientTab.value === 'openclaw') { + switch (props.platform) { + case 'anthropic': + return [generateOpenClawConfig('anthropic', apiBase, apiKey)] + case 'openai': + return [generateOpenClawConfig('openai', apiBase, apiKey)] + case 'gemini': + return [generateOpenClawConfig('gemini', geminiBase, apiKey)] + case 'antigravity': + return [generateOpenClawConfig('antigravity', antigravityBase, apiKey, antigravityGeminiBase)] + default: + return [generateOpenClawConfig('anthropic', apiBase, apiKey)] + } + } + switch (props.platform) { case 'openai': if (activeClientTab.value === 'claude') { @@ -1041,6 +1060,102 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin } } +function generateOpenClawConfig(platform: GroupPlatform, baseUrl: string, apiKey: string, geminiBaseUrl?: string): FileConfig { + const providerId = `sub2api-${platform}` + const env = { + SUB2API_API_KEY: apiKey + } + let primaryModel = '' + const providers: Record = {} + + if (platform === 'openai') { + primaryModel = `${providerId}/gpt-5.4` + providers[providerId] = { + baseUrl, + apiKey: '${SUB2API_API_KEY}', + api: 'openai-responses', + models: [ + { id: 'gpt-5.4', name: 'GPT-5.4' }, + { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' }, + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' }, + { id: 'gpt-5.3-codex-spark', name: 'GPT-5.3 Codex Spark' } + ] + } + } else if (platform === 'gemini') { + primaryModel = `${providerId}/gemini-2.5-pro` + providers[providerId] = { + baseUrl, + apiKey: '${SUB2API_API_KEY}', + api: 'google-generative-ai', + models: [ + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, + { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview' }, + { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview' } + ] + } + } else if (platform === 'antigravity') { + const claudeProviderId = 'sub2api-antigravity-claude' + const geminiProviderId = 'sub2api-antigravity-gemini' + primaryModel = `${claudeProviderId}/claude-sonnet-4-6` + providers[claudeProviderId] = { + baseUrl, + apiKey: '${SUB2API_API_KEY}', + api: 'anthropic-messages', + models: [ + { id: 'claude-sonnet-4-6', name: 'Claude 4.6 Sonnet' }, + { id: 'claude-opus-4-6-thinking', name: 'Claude 4.6 Opus (Thinking)' } + ] + } + providers[geminiProviderId] = { + baseUrl: geminiBaseUrl ?? baseUrl, + apiKey: '${SUB2API_API_KEY}', + api: 'google-generative-ai', + models: [ + { id: 'gemini-3.1-pro-high', name: 'Gemini 3.1 Pro High' }, + { id: 'gemini-3.1-pro-low', name: 'Gemini 3.1 Pro Low' }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' } + ] + } + } else { + primaryModel = `${providerId}/claude-sonnet-4-6` + providers[providerId] = { + baseUrl, + apiKey: '${SUB2API_API_KEY}', + api: 'anthropic-messages', + models: [ + { id: 'claude-sonnet-4-6', name: 'Claude 4.6 Sonnet' }, + { id: 'claude-opus-4-6-thinking', name: 'Claude 4.6 Opus (Thinking)' } + ] + } + } + + const content = JSON.stringify( + { + env, + agents: { + defaults: { + model: { + primary: primaryModel + } + } + }, + models: { + mode: 'merge', + providers + } + }, + null, + 2 + ) + + return { + path: '~/.openclaw/openclaw.json', + content, + hint: t('keys.useKeyModal.openclaw.hint') + } +} + const copyContent = async (content: string, index: number) => { const success = await clipboardCopy(content, t('keys.copied')) if (success) { diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index f7db586a..9b07e0da 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -50,4 +50,53 @@ describe('UseKeyModal', () => { expect(codeBlock.text()).toContain('"name": "GPT-5.4 Mini"') expect(codeBlock.text()).not.toContain('"name": "GPT-5.4 Nano"') }) + + it.each(['anthropic', 'openai', 'gemini', 'antigravity'] as const)( + 'renders OpenClaw config for %s keys', + async (platform) => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-test', + baseUrl: 'https://example.com/v1', + platform + }, + global: { + stubs: { + BaseDialog: { + template: '
' + }, + Icon: { + template: '' + } + } + } + }) + + const openclawTab = wrapper.findAll('button').find((button) => + button.text().includes('keys.useKeyModal.cliTabs.openclaw') + ) + + expect(openclawTab).toBeDefined() + await openclawTab!.trigger('click') + await nextTick() + + expect(wrapper.text()).toContain('~/.openclaw/openclaw.json') + + const codeBlock = wrapper.find('pre code') + expect(codeBlock.exists()).toBe(true) + expect(codeBlock.text()).toContain('"models":') + + if (platform === 'openai') { + expect(codeBlock.text()).toContain('"api": "openai-responses"') + } else if (platform === 'gemini') { + expect(codeBlock.text()).toContain('"api": "google-generative-ai"') + } else if (platform === 'antigravity') { + expect(codeBlock.text()).toContain('sub2api-antigravity-claude') + expect(codeBlock.text()).toContain('sub2api-antigravity-gemini') + } else { + expect(codeBlock.text()).toContain('"api": "anthropic-messages"') + } + } + ) }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 02d044ef..722ea197 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -705,6 +705,7 @@ export default { codexCli: 'Codex CLI', codexCliWs: 'Codex CLI (WebSocket)', opencode: 'OpenCode', + openclaw: 'OpenClaw', }, antigravity: { description: 'Configure API access for Antigravity group. Select the configuration method based on your client.', @@ -723,6 +724,9 @@ export default { subtitle: 'opencode.json', hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.', }, + openclaw: { + hint: 'Config path: ~/.openclaw/openclaw.json, create if it does not exist. This example is generated for the current key group platform; adjust models and options as needed.', + }, }, customKeyLabel: 'Custom Key', customKeyPlaceholder: 'Enter your custom key (min 16 chars)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 687c2df6..9917899b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -704,7 +704,8 @@ export default { geminiCli: 'Gemini CLI', codexCli: 'Codex CLI', codexCliWs: 'Codex CLI (WebSocket)', - opencode: 'OpenCode' + opencode: 'OpenCode', + openclaw: 'OpenClaw' }, antigravity: { description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。', @@ -725,6 +726,9 @@ export default { title: 'OpenCode 配置示例', subtitle: 'opencode.json', hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。' + }, + openclaw: { + hint: '配置文件路径:~/.openclaw/openclaw.json,不存在需手动创建。配置按当前密钥所属分组生成,模型与选项可按需调整。' } }, customKeyLabel: '自定义密钥', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 7c3735b6..78c89fa6 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -9035,7 +9035,7 @@ watch( } .settings-tab { - @apply relative isolate flex h-10 min-w-[6.75rem] shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-xl border border-transparent px-3 text-sm font-medium text-gray-600 outline-none transition-colors duration-200 ease-out dark:text-gray-300; + @apply relative isolate flex h-10 min-w-[6.75rem] shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-xl border border-transparent px-3 text-sm font-medium text-slate-700 outline-none transition-colors duration-200 ease-out dark:text-slate-300; } @media (min-width: 768px) { @@ -9055,7 +9055,7 @@ watch( .settings-tab::before { @apply absolute inset-0 -z-10 rounded-xl opacity-0 transition-opacity duration-200; content: ""; - background: linear-gradient(135deg, rgb(248 250 252 / 0.95), rgb(241 245 249 / 0.8)); + background: linear-gradient(135deg, rgb(241 245 249 / 0.98), rgb(226 232 240 / 0.9)); } .settings-tab:hover::before, @@ -9064,7 +9064,7 @@ watch( } :global(.dark) .settings-tab::before { - background: linear-gradient(135deg, rgb(30 41 59 / 0.9), rgb(51 65 85 / 0.62)); + background: linear-gradient(135deg, rgb(30 41 59 / 0.96), rgb(51 65 85 / 0.82)); } .settings-tab:focus-visible { @@ -9072,16 +9072,16 @@ watch( } .settings-tab-active { - @apply border-primary-200/80 bg-white text-primary-700 shadow-sm dark:border-primary-400/30 dark:bg-dark-700/95 dark:text-primary-200; + @apply border-primary-500/30 bg-primary-600 text-white shadow-sm dark:border-primary-400/30 dark:bg-primary-500 dark:text-white; box-shadow: - 0 8px 18px rgb(15 23 42 / 0.08), - 0 1px 0 rgb(255 255 255 / 0.92) inset; + 0 10px 22px rgb(2 132 199 / 0.26), + 0 1px 0 rgb(255 255 255 / 0.18) inset; } :global(.dark) .settings-tab-active { box-shadow: - 0 12px 26px rgb(0 0 0 / 0.22), - 0 1px 0 rgb(255 255 255 / 0.08) inset; + 0 12px 26px rgb(2 132 199 / 0.28), + 0 1px 0 rgb(255 255 255 / 0.1) inset; } .settings-tab-active::before { @@ -9109,7 +9109,7 @@ watch( } .settings-tab-active .settings-tab-icon { - @apply bg-primary-50 text-primary-600 dark:bg-primary-400/10 dark:text-primary-300; + @apply bg-white/15 text-white dark:bg-white/15 dark:text-white; } .settings-tab-label {