diff --git a/README.md b/README.md index 97b930cd..eceaf511 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Claude Code Haha

- Claude Code Haha + Claude Code Haha

diff --git a/desktop/public/app-icon.jpg b/desktop/public/app-icon.jpg deleted file mode 100644 index 03098747..00000000 Binary files a/desktop/public/app-icon.jpg and /dev/null differ diff --git a/desktop/public/app-icon.png b/desktop/public/app-icon.png index 4f415f61..02f20c4c 100644 Binary files a/desktop/public/app-icon.png and b/desktop/public/app-icon.png differ diff --git a/desktop/public/app-icon.svg b/desktop/public/app-icon.svg new file mode 100644 index 00000000..582d3c54 --- /dev/null +++ b/desktop/public/app-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/desktop/scripts/build-sidecars.ts b/desktop/scripts/build-sidecars.ts index 5c01096a..a46293dc 100644 --- a/desktop/scripts/build-sidecars.ts +++ b/desktop/scripts/build-sidecars.ts @@ -162,21 +162,25 @@ async function compileExecutable({ // that causes "load code signature error 4" and SIGKILL at launch. // Fix: strip the broken signature, then ad-hoc sign. if (process.platform === 'darwin') { - console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`) - const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], { - stdout: 'inherit', - stderr: 'inherit', - }) - await strip.exited - - const sign = Bun.spawn( - ['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath], - { stdout: 'inherit', stderr: 'inherit' }, - ) - const signExit = await sign.exited - if (signExit !== 0) { - throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`) - } - console.log(`[build-sidecars] ad-hoc signed ${outputPath}`) + await adHocSignMacBinary(outputPath) } } + +async function adHocSignMacBinary(outputPath: string) { + console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`) + const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], { + stdout: 'inherit', + stderr: 'inherit', + }) + await strip.exited + + const sign = Bun.spawn( + ['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath], + { stdout: 'inherit', stderr: 'inherit' }, + ) + const signExit = await sign.exited + if (signExit !== 0) { + throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`) + } + console.log(`[build-sidecars] ad-hoc signed ${outputPath}`) +} diff --git a/desktop/src-tauri/app-icon-macos.svg b/desktop/src-tauri/app-icon-macos.svg new file mode 100644 index 00000000..af11a9e2 --- /dev/null +++ b/desktop/src-tauri/app-icon-macos.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/app-icon.svg b/desktop/src-tauri/app-icon.svg index 2ddf7d13..f4f73e67 100644 --- a/desktop/src-tauri/app-icon.svg +++ b/desktop/src-tauri/app-icon.svg @@ -1,67 +1,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - + + + + + + - - - - - - - + + + + + + diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index 4385cafb..9c55c9b0 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index 23031353..3993a862 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index bd182b75..9e7fee4e 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index 2d11cacd..1af7f418 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 75743661..93827f1b 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index e1cdbdea..2cb58edb 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index e9236bac..296ecbfb 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index e82297e3..983a93a2 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 8c78c988..330ee213 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index 281538d2..c371943a 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 510dbf79..266ea978 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 90ac2049..4e5e1a81 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index c266dc85..cac6b571 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index cd0c0266..dc2cc91d 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 533478c5..c37e2cda 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index a6b36bac..274bb8df 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 9332b6a1..c5a1d511 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index fb28c4fc..806a5227 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 092b10a8..ffa26a92 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 3b7fa755..671e6925 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index afb60a52..2b73cacd 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 9fe160e6..6f8a6591 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 9ecb360d..d14ea701 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 8f4a2816..ec497f3f 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 2dbd7801..4cd2cce0 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 1cc8ed6d..686cba35 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index e4fd63d6..8b79823a 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index e19625b1..cc36933d 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 13d5f894..d991e6f6 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index c005481d..398851de 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index c914299a..f951ee90 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 4f415f61..bf3255e0 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png index ea97dfb1..a166f1f6 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 04ff6a6e..f01debfa 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png index 04ff6a6e..f01debfa 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png index 98bc7501..6d48bc00 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png index d222072e..437dce50 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 2b1b3a85..9bfa4244 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png index 2b1b3a85..9bfa4244 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png index fa40e74c..a43989b0 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png index 04ff6a6e..f01debfa 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 880a13d8..f63dac44 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png index 880a13d8..f63dac44 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png index 271a343e..8f04e2de 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png index 62bbd5f6..aaf24f03 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png index 271a343e..8f04e2de 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png index 4fffc80d..8a2c4614 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png index 866cd10a..caf57fd2 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png index bcfc5631..633238a4 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 3780ce30..3c01a7b1 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 50c0eb65..c0add799 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -232,7 +232,7 @@ export function Sidebar() { data-testid="sidebar-session-list-section" className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col" > -
+
{error && (
{t('sidebar.sessionListFailed')}
diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 84104838..0cc18ec4 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -141,7 +141,7 @@ export function ActiveSession() { ) : ( <> - Claude Code Haha + Claude Code Haha

{t('empty.title')}

diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index a8bf7eac..e7a5d77b 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -457,7 +457,7 @@ export function EmptySession() {
- Claude Code Haha + Claude Code Haha

{t('empty.title')}

diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts new file mode 100644 index 00000000..02a57ada --- /dev/null +++ b/desktop/src/stores/settingsStore.test.ts @@ -0,0 +1,22 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('settingsStore locale defaults', () => { + beforeEach(() => { + vi.resetModules() + window.localStorage.clear() + }) + + it('defaults to Chinese when no locale is stored', async () => { + const { useSettingsStore } = await import('./settingsStore') + + expect(useSettingsStore.getState().locale).toBe('zh') + }) + + it('keeps a stored locale override', async () => { + window.localStorage.setItem('cc-haha-locale', 'en') + + const { useSettingsStore } = await import('./settingsStore') + + expect(useSettingsStore.getState().locale).toBe('en') + }) +}) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 2fa8f56e..3613287d 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -12,7 +12,7 @@ function getStoredLocale(): Locale { const stored = localStorage.getItem(LOCALE_STORAGE_KEY) if (stored === 'en' || stored === 'zh') return stored } catch { /* localStorage unavailable */ } - return 'en' + return 'zh' } type SettingsStore = { diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 2ca0d01e..a9c4c085 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -636,6 +636,28 @@ button, input, textarea, select, a, [role="button"] { ::-webkit-scrollbar-thumb { background: var(--color-outline); border-radius: 9999px; opacity: 0.3; } ::-webkit-scrollbar-thumb:hover { opacity: 0.6; } +.sidebar-scroll-area { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--color-outline) 72%, transparent) transparent; +} + +.sidebar-scroll-area::-webkit-scrollbar { + width: 4px; +} + +.sidebar-scroll-area::-webkit-scrollbar-track { + background: transparent; +} + +.sidebar-scroll-area::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--color-outline) 78%, transparent); + border-radius: 9999px; +} + +.sidebar-scroll-area::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--color-outline) 92%, transparent); +} + /* Animations */ @keyframes shimmer { 0%, 100% { opacity: 0.4; } diff --git a/docs/en/index.md b/docs/en/index.md index 155fd246..05acb4e1 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -6,7 +6,7 @@ hero: text: Locally Runnable Claude Code tagline: Repaired from leaked source, supports any Anthropic-compatible API endpoint (MiniMax, OpenRouter, etc.) image: - src: /images/banner.jpg + src: /images/logo-horizontal.png alt: Claude Code Haha actions: - theme: brand diff --git a/docs/images/app-icon-dark.png b/docs/images/app-icon-dark.png new file mode 100644 index 00000000..19dc2785 Binary files /dev/null and b/docs/images/app-icon-dark.png differ diff --git a/docs/images/app-icon-dark.svg b/docs/images/app-icon-dark.svg new file mode 100644 index 00000000..e77d3d7a --- /dev/null +++ b/docs/images/app-icon-dark.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/app-icon-light.png b/docs/images/app-icon-light.png new file mode 100644 index 00000000..02f20c4c Binary files /dev/null and b/docs/images/app-icon-light.png differ diff --git a/docs/images/app-icon-light.svg b/docs/images/app-icon-light.svg new file mode 100644 index 00000000..582d3c54 --- /dev/null +++ b/docs/images/app-icon-light.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/images/app-icon.jpg b/docs/images/app-icon.jpg deleted file mode 100644 index 03098747..00000000 Binary files a/docs/images/app-icon.jpg and /dev/null differ diff --git a/docs/images/app-icon.png b/docs/images/app-icon.png index 289f77e2..02f20c4c 100644 Binary files a/docs/images/app-icon.png and b/docs/images/app-icon.png differ diff --git a/docs/images/app-icon.svg b/docs/images/app-icon.svg new file mode 100644 index 00000000..582d3c54 --- /dev/null +++ b/docs/images/app-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/images/banner.jpg b/docs/images/banner.jpg deleted file mode 100644 index f2bc6f6e..00000000 Binary files a/docs/images/banner.jpg and /dev/null differ diff --git a/docs/images/banner.png b/docs/images/banner.png new file mode 100644 index 00000000..8a188b96 Binary files /dev/null and b/docs/images/banner.png differ diff --git a/docs/images/banner.svg b/docs/images/banner.svg new file mode 100644 index 00000000..7805ed12 --- /dev/null +++ b/docs/images/banner.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Claude Code + + + Haha + + + + NEXT-GEN PERSONAL AI AGENT + + + + + DESKTOP EDITION + + + + + diff --git a/docs/images/logo-horizontal.jpg b/docs/images/logo-horizontal.jpg deleted file mode 100644 index 859b0eac..00000000 Binary files a/docs/images/logo-horizontal.jpg and /dev/null differ diff --git a/docs/images/logo-horizontal.png b/docs/images/logo-horizontal.png new file mode 100644 index 00000000..e1cc11e1 Binary files /dev/null and b/docs/images/logo-horizontal.png differ diff --git a/docs/images/logo-horizontal.svg b/docs/images/logo-horizontal.svg new file mode 100644 index 00000000..11cfd35e --- /dev/null +++ b/docs/images/logo-horizontal.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Claude Code + + + Haha + + + + NEXT-GEN PERSONAL AI AGENT + + + + diff --git a/docs/index.md b/docs/index.md index 4612c970..db49f622 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ hero: text: 本地可运行的 Claude Code tagline: 基于泄露源码修复,支持接入任意 Anthropic 兼容 API(MiniMax、OpenRouter 等) image: - src: /images/app-icon.png + src: /images/logo-horizontal.png alt: Claude Code Haha actions: - theme: brand diff --git a/docs/public/images/banner.jpg b/docs/public/images/banner.jpg deleted file mode 100644 index f2bc6f6e..00000000 Binary files a/docs/public/images/banner.jpg and /dev/null differ diff --git a/docs/public/images/banner.png b/docs/public/images/banner.png new file mode 100644 index 00000000..8a188b96 Binary files /dev/null and b/docs/public/images/banner.png differ diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index c47943e8..5a5bea6a 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -13,6 +13,7 @@ describe('ConversationService', () => { let originalModel: string | undefined let originalEntrypoint: string | undefined let originalOAuthToken: string | undefined + let originalProviderManagedByHost: string | undefined beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-')) @@ -22,6 +23,7 @@ describe('ConversationService', () => { originalModel = process.env.ANTHROPIC_MODEL originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN + originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.ANTHROPIC_AUTH_TOKEN = 'test-token' @@ -31,6 +33,7 @@ describe('ConversationService', () => { // Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether // buildChildEnv injects it or not without interference from the shell env. delete process.env.CLAUDE_CODE_ENTRYPOINT + delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST }) afterEach(async () => { @@ -52,6 +55,9 @@ describe('ConversationService', () => { if (originalOAuthToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken + if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost + await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -153,8 +159,9 @@ describe('ConversationService', () => { })) as Record expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`) - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('proxy-managed') + expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed') expect(env.ANTHROPIC_MODEL).toBe('kimi-k2.6') + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() }) @@ -181,7 +188,7 @@ describe('ConversationService', () => { providerId: null, })) as Record - expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env.ANTHROPIC_API_KEY).toBeUndefined() expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop') expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('forced-official-token') }) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index cc818870..2dae22ce 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -718,4 +718,139 @@ describe('WebSocket Chat Integration', () => { conversationService.stopSession(sessionId) } }, 20_000) + + it('should wait for an in-flight runtime restart before sending the next user turn', async () => { + const providerService = new ProviderService() + const providerA = await providerService.addProvider({ + presetId: 'custom', + name: 'Provider Restart A', + apiKey: 'key-a', + baseUrl: 'http://127.0.0.1:1/anthropic', + apiFormat: 'anthropic', + models: { + main: 'restart-a-main', + haiku: 'restart-a-haiku', + sonnet: 'restart-a-sonnet', + opus: 'restart-a-opus', + }, + }) + const providerB = await providerService.addProvider({ + presetId: 'custom', + name: 'Provider Restart B', + apiKey: 'key-b', + baseUrl: 'http://127.0.0.1:1/anthropic', + apiFormat: 'anthropic', + models: { + main: 'restart-b-main', + haiku: 'restart-b-haiku', + sonnet: 'restart-b-sonnet', + opus: 'restart-b-opus', + }, + }) + + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined + }> = [] + + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + ) { + startCalls.push({ sessionId: sid, options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + try { + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + let phase: 'boot' | 'turn1' | 'turn2' | 'turn3' | 'done' = 'boot' + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error(`Timed out waiting for runtime restart synchronization flow for session ${sessionId}`)) + }, 15_000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + + if (msg.type === 'connected' && phase === 'boot') { + ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: providerA.id, + modelId: 'restart-a-sonnet', + })) + ws.send(JSON.stringify({ type: 'user_message', content: 'first turn' })) + phase = 'turn1' + return + } + + if (msg.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(msg.message)) + return + } + + if (msg.type === 'message_complete' && phase === 'turn1') { + ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: providerB.id, + modelId: 'restart-b-opus', + })) + ws.send(JSON.stringify({ type: 'user_message', content: 'second turn immediately after switch' })) + phase = 'turn2' + return + } + + if (msg.type === 'message_complete' && phase === 'turn2') { + ws.send(JSON.stringify({ type: 'user_message', content: 'third turn should reuse restarted runtime' })) + phase = 'turn3' + return + } + + if (msg.type === 'message_complete' && phase === 'turn3') { + clearTimeout(timeout) + phase = 'done' + ws.close() + resolve() + } + } + + ws.onerror = () => { + reject(new Error(`WebSocket error for runtime restart synchronization session ${sessionId}`)) + } + }) + + expect(startCalls).toHaveLength(2) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + providerId: providerA.id, + model: 'restart-a-sonnet', + }, + }) + expect(startCalls[1]).toMatchObject({ + sessionId, + options: { + providerId: providerB.id, + model: 'restart-b-opus', + }, + }) + } finally { + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + } + }, 20_000) }) diff --git a/src/server/__tests__/providers-real.test.ts b/src/server/__tests__/providers-real.test.ts index a5f48783..955867d4 100644 --- a/src/server/__tests__/providers-real.test.ts +++ b/src/server/__tests__/providers-real.test.ts @@ -66,7 +66,7 @@ describe('Real Provider Configs', () => { // 验证写入 cc-haha/settings.json const settings = await readCcHahaSettings() expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic') - expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only') + expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only') expect((settings.env as Record).ANTHROPIC_MODEL).toBe('MiniMax-M2.7-highspeed') // 验证原版 settings.json 没有被创建 @@ -106,7 +106,7 @@ describe('Real Provider Configs', () => { await service.activateProvider(jiekou.id) settings = await readCcHahaSettings() expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic') - expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only') + expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only') expect((settings.env as Record).ANTHROPIC_MODEL).toBe('claude-opus-4-7') // 验证 activeId 正确 @@ -151,7 +151,7 @@ describe('Real Provider Configs', () => { // 验证新字段写入 expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic') - expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk_test') + expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('sk_test') // 验证已有字段保留 expect(settings.customField).toBe('should_be_preserved') @@ -181,7 +181,7 @@ describe('Real Provider Configs', () => { settings = await readCcHahaSettings() const env = settings.env as Record | undefined expect(env?.ANTHROPIC_BASE_URL).toBeUndefined() - expect(env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env?.ANTHROPIC_API_KEY).toBeUndefined() expect(env?.ANTHROPIC_MODEL).toBeUndefined() console.log('✅ activateOfficial 正确清除了 provider env') @@ -213,7 +213,7 @@ describe('Real Provider Configs', () => { effortLevel: 'high', env: { ANTHROPIC_BASE_URL: 'https://original-claude-code.api.com', - ANTHROPIC_AUTH_TOKEN: 'original-key', + ANTHROPIC_API_KEY: 'original-key', }, }, null, 2), ) @@ -231,13 +231,13 @@ describe('Real Provider Configs', () => { // 验证原版 settings.json 没被修改 const original = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')) expect((original.env as Record).ANTHROPIC_BASE_URL).toBe('https://original-claude-code.api.com') - expect((original.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('original-key') + expect((original.env as Record).ANTHROPIC_API_KEY).toBe('original-key') expect(original.effortLevel).toBe('high') // 验证 cc-haha/settings.json 是 Haha 自己的 const haha = await readCcHahaSettings() expect((haha.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic') - expect((haha.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key') + expect((haha.env as Record).ANTHROPIC_API_KEY).toBe('sk-haha-key') console.log('✅ 原版 settings.json 完好无损,Haha 配置独立存储') }) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index fef8bf8e..426f8a3c 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -50,26 +50,30 @@ function makeRequest( /** A sample provider input for reuse across tests */ function sampleInput(overrides?: Partial): CreateProviderInput { return { + presetId: 'custom', name: 'Test Provider', baseUrl: 'https://api.example.com', apiKey: 'sk-test-key-123', - models: [ - { id: 'model-a', name: 'Model A' }, - { id: 'model-b', name: 'Model B' }, - ], + apiFormat: 'anthropic', + models: { + main: 'model-main', + haiku: 'model-haiku', + sonnet: 'model-sonnet', + opus: 'model-opus', + }, ...overrides, } } /** Read the settings.json written to the temp config dir */ async function readSettings(): Promise> { - const raw = await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8') + const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8') return JSON.parse(raw) as Record } /** Read the providers.json written to the temp config dir */ async function readProvidersConfig(): Promise> { - const raw = await fs.readFile(path.join(tmpDir, 'providers.json'), 'utf-8') + const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'providers.json'), 'utf-8') return JSON.parse(raw) as Record } @@ -86,8 +90,8 @@ describe('ProviderService', () => { describe('listProviders', () => { test('should return empty array when no providers exist', async () => { const svc = new ProviderService() - const providers = await svc.listProviders() - expect(providers).toEqual([]) + const result = await svc.listProviders() + expect(result).toEqual({ providers: [], activeId: null }) }) test('should return all added providers', async () => { @@ -95,10 +99,11 @@ describe('ProviderService', () => { await svc.addProvider(sampleInput({ name: 'Provider A' })) await svc.addProvider(sampleInput({ name: 'Provider B' })) - const providers = await svc.listProviders() + const { providers, activeId } = await svc.listProviders() expect(providers).toHaveLength(2) expect(providers[0].name).toBe('Provider A') expect(providers[1].name).toBe('Provider B') + expect(activeId).toBeNull() }) }) @@ -113,35 +118,33 @@ describe('ProviderService', () => { expect(provider.name).toBe('Test Provider') expect(provider.baseUrl).toBe('https://api.example.com') expect(provider.apiKey).toBe('sk-test-key-123') - expect(provider.models).toHaveLength(2) - expect(provider.createdAt).toBeGreaterThan(0) - expect(provider.updatedAt).toBeGreaterThan(0) + expect(provider.models.main).toBe('model-main') }) - test('first provider should be auto-activated', async () => { + test('new providers should not be auto-activated', async () => { const svc = new ProviderService() const provider = await svc.addProvider(sampleInput()) - expect(provider.isActive).toBe(true) + expect(provider.id).toBeDefined() + const { activeId } = await svc.listProviders() + expect(activeId).toBeNull() }) - test('first provider auto-activation should sync to settings.json', async () => { + test('adding a provider should not sync settings until activated', async () => { const svc = new ProviderService() await svc.addProvider(sampleInput()) - const settings = await readSettings() - const env = settings.env as Record - expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com') - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-test-key-123') - expect(settings.model).toBe('model-a') + await expect(fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')).rejects.toThrow() }) - test('second provider should not be auto-activated', async () => { + test('adding additional providers should keep activeId unchanged', async () => { const svc = new ProviderService() await svc.addProvider(sampleInput({ name: 'First' })) const second = await svc.addProvider(sampleInput({ name: 'Second' })) - expect(second.isActive).toBe(false) + expect(second.id).toBeDefined() + const { activeId } = await svc.listProviders() + expect(activeId).toBeNull() }) test('should preserve optional notes field', async () => { @@ -193,7 +196,6 @@ describe('ProviderService', () => { expect(updated.baseUrl).toBe('https://new-api.example.com') // unchanged fields preserved expect(updated.apiKey).toBe('sk-test-key-123') - expect(updated.updatedAt).toBeGreaterThanOrEqual(added.updatedAt) }) test('should throw 404 for non-existent provider', async () => { @@ -211,8 +213,8 @@ describe('ProviderService', () => { test('updating active provider should re-sync settings.json', async () => { const svc = new ProviderService() const added = await svc.addProvider(sampleInput()) + await svc.activateProvider(added.id) - // First provider is auto-activated, so updating it should re-sync await svc.updateProvider(added.id, { baseUrl: 'https://new-api.example.com', apiKey: 'sk-new-key', @@ -221,7 +223,8 @@ describe('ProviderService', () => { const settings = await readSettings() const env = settings.env as Record expect(env.ANTHROPIC_BASE_URL).toBe('https://new-api.example.com') - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-new-key') + expect(env.ANTHROPIC_API_KEY).toBe('sk-new-key') + expect(env.ANTHROPIC_MODEL).toBe('model-main') }) }) @@ -236,7 +239,7 @@ describe('ProviderService', () => { // Second is inactive, so deletion should succeed await svc.deleteProvider(second.id) - const providers = await svc.listProviders() + const { providers } = await svc.listProviders() expect(providers).toHaveLength(1) expect(providers[0].name).toBe('First') }) @@ -244,6 +247,7 @@ describe('ProviderService', () => { test('should throw 409 when deleting an active provider', async () => { const svc = new ProviderService() const active = await svc.addProvider(sampleInput()) + await svc.activateProvider(active.id) try { await svc.deleteProvider(active.id) @@ -281,14 +285,13 @@ describe('ProviderService', () => { }), ) - await svc.activateProvider(second.id, 'model-a') + await svc.activateProvider(second.id) // Second should now be active - const providers = await svc.listProviders() - const activeFirst = providers.find((p) => p.id === first.id) - const activeSecond = providers.find((p) => p.id === second.id) - expect(activeFirst!.isActive).toBe(false) - expect(activeSecond!.isActive).toBe(true) + const { activeId, providers } = await svc.listProviders() + expect(activeId).toBe(second.id) + expect(providers.find((p) => p.id === first.id)).toBeDefined() + expect(providers.find((p) => p.id === second.id)).toBeDefined() }) test('should write correct settings.json on activation', async () => { @@ -302,19 +305,23 @@ describe('ProviderService', () => { }), ) - await svc.activateProvider(second.id, 'model-b') + await svc.activateProvider(second.id) const settings = await readSettings() const env = settings.env as Record expect(env.ANTHROPIC_BASE_URL).toBe('https://second-api.example.com') - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second-key') - expect(settings.model).toBe('model-b') + expect(env.ANTHROPIC_API_KEY).toBe('sk-second-key') + expect(env.ANTHROPIC_MODEL).toBe('model-main') + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('model-haiku') + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('model-sonnet') + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('model-opus') }) test('should preserve existing settings.json fields on activation', async () => { // Pre-seed settings with an extra field + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) await fs.writeFile( - path.join(tmpDir, 'settings.json'), + path.join(tmpDir, 'cc-haha', 'settings.json'), JSON.stringify({ theme: 'dark', env: { CUSTOM_VAR: 'keep-me' } }), ) @@ -322,7 +329,7 @@ describe('ProviderService', () => { const provider = await svc.addProvider(sampleInput()) // Re-activate to verify merge behavior - await svc.activateProvider(provider.id, 'model-a') + await svc.activateProvider(provider.id) const settings = await readSettings() expect(settings.theme).toBe('dark') @@ -331,24 +338,11 @@ describe('ProviderService', () => { expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com') }) - test('should throw 400 for non-existent model id', async () => { - const svc = new ProviderService() - const provider = await svc.addProvider(sampleInput()) - - try { - await svc.activateProvider(provider.id, 'non-existent-model') - expect(true).toBe(false) - } catch (err: unknown) { - const apiErr = err as { statusCode: number } - expect(apiErr.statusCode).toBe(400) - } - }) - test('should throw 404 for non-existent provider id', async () => { const svc = new ProviderService() try { - await svc.activateProvider('non-existent-id', 'model-a') + await svc.activateProvider('non-existent-id') expect(true).toBe(false) } catch (err: unknown) { const apiErr = err as { statusCode: number } @@ -356,33 +350,36 @@ describe('ProviderService', () => { } }) - test('activeModel should be persisted in providers.json', async () => { + test('activeId should be persisted in providers.json', async () => { const svc = new ProviderService() const provider = await svc.addProvider(sampleInput()) - await svc.activateProvider(provider.id, 'model-b') + await svc.activateProvider(provider.id) const config = await readProvidersConfig() - expect(config.activeModel).toBe('model-b') + expect(config.activeId).toBe(provider.id) }) }) - // ─── getActiveProvider ─────────────────────────────────────────────────── + // ─── getProviderForProxy ───────────────────────────────────────────────── - describe('getActiveProvider', () => { - test('should return null when no providers exist', async () => { + describe('getProviderForProxy', () => { + test('should return null when no provider is active', async () => { const svc = new ProviderService() - const active = await svc.getActiveProvider() + const active = await svc.getProviderForProxy() expect(active).toBeNull() }) - test('should return the active provider', async () => { + test('should return the active provider proxy config', async () => { const svc = new ProviderService() const provider = await svc.addProvider(sampleInput()) + await svc.activateProvider(provider.id) - const active = await svc.getActiveProvider() + const active = await svc.getProviderForProxy() expect(active).not.toBeNull() - expect(active!.id).toBe(provider.id) + expect(active!.baseUrl).toBe(provider.baseUrl) + expect(active!.apiKey).toBe(provider.apiKey) + expect(active!.apiFormat).toBe('anthropic') }) }) }) @@ -424,17 +421,24 @@ describe('Providers API', () => { test('POST /api/providers should create a provider', async () => { const { req, url, segments } = makeRequest('POST', '/api/providers', { + presetId: 'custom', name: 'New Provider', baseUrl: 'https://api.example.com', apiKey: 'sk-test', - models: [{ id: 'gpt-4', name: 'GPT-4' }], + apiFormat: 'anthropic', + models: { + main: 'gpt-4', + haiku: 'gpt-4-haiku', + sonnet: 'gpt-4-sonnet', + opus: 'gpt-4-opus', + }, }) const res = await handleProvidersApi(req, url, segments) expect(res.status).toBe(201) - const body = (await res.json()) as { provider: { name: string; isActive: boolean } } + const body = (await res.json()) as { provider: { name: string; models: { main: string } } } expect(body.provider.name).toBe('New Provider') - expect(body.provider.isActive).toBe(true) // first provider auto-activated + expect(body.provider.models.main).toBe('gpt-4') }) test('POST /api/providers should return 400 for invalid input', async () => { @@ -501,6 +505,7 @@ describe('Providers API', () => { test('DELETE /api/providers/:id should return 409 for active provider', async () => { const svc = new ProviderService() const active = await svc.addProvider(sampleInput()) + await svc.activateProvider(active.id) const { req, url, segments } = makeRequest('DELETE', `/api/providers/${active.id}`) const res = await handleProvidersApi(req, url, segments) @@ -524,7 +529,6 @@ describe('Providers API', () => { const { req, url, segments } = makeRequest( 'POST', `/api/providers/${second.id}/activate`, - { modelId: 'model-a' }, ) const res = await handleProvidersApi(req, url, segments) @@ -536,11 +540,11 @@ describe('Providers API', () => { const settings = await readSettings() const env = settings.env as Record expect(env.ANTHROPIC_BASE_URL).toBe('https://second.example.com') - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second') - expect(settings.model).toBe('model-a') + expect(env.ANTHROPIC_API_KEY).toBe('sk-second') + expect(env.ANTHROPIC_MODEL).toBe('model-main') }) - test('POST /api/providers/:id/activate should return 400 for missing modelId', async () => { + test('POST /api/providers/:id/activate should not require modelId', async () => { const svc = new ProviderService() const provider = await svc.addProvider(sampleInput()) @@ -551,10 +555,10 @@ describe('Providers API', () => { ) const res = await handleProvidersApi(req, url, segments) - expect(res.status).toBe(400) + expect(res.status).toBe(200) }) - test('POST /api/providers/:id/activate should return 400 for invalid model', async () => { + test('POST /api/providers/:id/activate should ignore modelId because session runtime selects the model', async () => { const svc = new ProviderService() const provider = await svc.addProvider(sampleInput()) @@ -565,7 +569,7 @@ describe('Providers API', () => { ) const res = await handleProvidersApi(req, url, segments) - expect(res.status).toBe(400) + expect(res.status).toBe(200) }) // ─── Method not allowed ────────────────────────────────────────────────── diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index cbce0b43..2577139f 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -569,6 +569,9 @@ export class ConversationService { // should come from Desktop-managed config or inherited launch env, not // be reintroduced from the repo's .env file. CC_HAHA_SKIP_DOTENV: '1', + ...(explicitProviderEnv + ? { CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1' } + : {}), // "官方" 模式 (cc-haha/settings.json 没 provider env) 下,把 CLI 标记为 // managed-OAuth,让它忽略外部 ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN // 残留、只走用户 /login 的 OAuth token。自定义 provider 模式绝不能设, diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index f5f92532..c06d5815 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -28,6 +28,7 @@ import type { const MANAGED_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', @@ -234,7 +235,7 @@ export class ProviderService { return { ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : provider.apiKey, + ANTHROPIC_API_KEY: needsProxy ? 'proxy-managed' : provider.apiKey, ANTHROPIC_MODEL: provider.models.main, ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, @@ -252,9 +253,14 @@ export class ProviderService { private async syncToSettings(provider: SavedProvider): Promise { const settings = await this.readSettings() const existingEnv = (settings.env as Record) || {} + const cleanedEnv = { ...existingEnv } + + for (const key of MANAGED_ENV_KEYS) { + delete cleanedEnv[key] + } settings.env = { - ...existingEnv, + ...cleanedEnv, ...this.buildManagedEnv(provider), } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 4fe4c894..5d34193a 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -54,6 +54,8 @@ const runtimeOverrides = new Map() +const runtimeTransitionPromises = new Map>() + export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> { return sessionSlashCommands.get(sessionId) || [] } @@ -202,6 +204,23 @@ async function handleUserMessage( // Send thinking status sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' }) + const pendingRuntimeTransition = runtimeTransitionPromises.get(sessionId) + if (pendingRuntimeTransition) { + try { + await pendingRuntimeTransition + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`) + sendMessage(ws, { + type: 'error', + message: `Failed to switch provider/model: ${errMsg}`, + code: 'CLI_RESTART_FAILED', + }) + sendMessage(ws, { type: 'status', state: 'idle' }) + return + } + } + // 启动 CLI 子进程(如果还没有) if (!conversationService.hasSession(sessionId)) { try { @@ -374,7 +393,9 @@ async function handleSetRuntimeConfig( return } - await restartSessionWithRuntimeConfig(ws, sessionId) + await enqueueRuntimeTransition(sessionId, () => + restartSessionWithRuntimeConfig(ws, sessionId), + ) } async function restartSessionWithPermissionMode( @@ -553,6 +574,7 @@ function cleanupSessionRuntimeState(sessionId: string) { sessionSlashCommands.delete(sessionId) sessionTitleState.delete(sessionId) runtimeOverrides.delete(sessionId) + runtimeTransitionPromises.delete(sessionId) } function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { @@ -993,6 +1015,23 @@ async function getRuntimeSettings(sessionId?: string): Promise<{ } } +function enqueueRuntimeTransition( + sessionId: string, + transition: () => Promise, +): Promise { + const previous = runtimeTransitionPromises.get(sessionId) ?? Promise.resolve() + const next = previous + .catch(() => {}) + .then(transition) + .finally(() => { + if (runtimeTransitionPromises.get(sessionId) === next) { + runtimeTransitionPromises.delete(sessionId) + } + }) + runtimeTransitionPromises.set(sessionId, next) + return next +} + /** * Send a message to a specific session's WebSocket (for use by services) */ diff --git a/src/utils/betas.ts b/src/utils/betas.ts index 7a1747ca..da429117 100644 --- a/src/utils/betas.ts +++ b/src/utils/betas.ts @@ -27,7 +27,7 @@ import { has1mContext } from './context.js' import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' import { getCanonicalName } from './model/model.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' -import { getAPIProvider } from './model/providers.js' +import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js' import { getInitialSettings } from './settings/settings.js' /** @@ -215,6 +215,7 @@ export function getToolSearchBetaHeader(): string { export function shouldIncludeFirstPartyOnlyBetas(): boolean { return ( (getAPIProvider() === 'firstParty' || getAPIProvider() === 'foundry') && + (getAPIProvider() !== 'firstParty' || isFirstPartyAnthropicBaseUrl()) && !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) ) } @@ -398,6 +399,10 @@ export function getMergedBetas( model: string, options?: { isAgenticQuery?: boolean }, ): string[] { + if (getAPIProvider() === 'firstParty' && !isFirstPartyAnthropicBaseUrl()) { + return [] + } + const baseBetas = [...getModelBetas(model)] // Agentic queries always need claude-code and cli-internal beta headers.