fix: Keep desktop chats on selected provider runtimes
Desktop sessions can switch provider and model while a CLI subprocess is already alive, so the server now serializes runtime restarts and marks provider-managed launches to prevent stale settings env from overriding the selected provider. Provider settings also write API key env consistently and clear stale managed keys before syncing. This includes the related desktop/docs brand asset refresh and keeps the desktop locale default in Chinese, with tests updated to match the current provider semantics. Constraint: Session-scoped model selection must win over cc-haha/settings.json and inherited ANTHROPIC_* values. Rejected: Store the selected model as a global provider activeModel | chat runtime selection is per session. Confidence: high Scope-risk: moderate Directive: Do not remove CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST without validating Desktop provider switching against stale settings env. Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/conversations.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/providers-real.test.ts Tested: cd desktop && bun run test src/stores/settingsStore.test.ts Tested: cd desktop && bun run lint Tested: git diff --check Not-tested: Full desktop production package/signing.
@ -1,7 +1,7 @@
|
||||
# Claude Code Haha
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
|
||||
<img src="docs/images/app-icon.png" alt="Claude Code Haha" width="240">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
Before Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 26 KiB |
17
desktop/public/app-icon.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
@ -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}`)
|
||||
}
|
||||
|
||||
42
desktop/src-tauri/app-icon-macos.svg
Normal file
@ -0,0 +1,42 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="panel" x1="190" y1="168" x2="842" y2="860" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#231916"/>
|
||||
<stop offset="1" stop-color="#18110F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panelGlow" x1="256" y1="172" x2="744" y2="852" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7A3B20"/>
|
||||
<stop offset="1" stop-color="#2B1813"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="spark" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(592 402) rotate(90) scale(130)">
|
||||
<stop stop-color="#FFD56A"/>
|
||||
<stop offset="1" stop-color="#F6B23E"/>
|
||||
</radialGradient>
|
||||
<filter id="sparkGlow" x="470" y="280" width="244" height="244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="18" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0.95 0 1 0 0 0.63 0 0 1 0 0.14 0 0 0 0.7 0"/>
|
||||
<feBlend in="SourceGraphic"/>
|
||||
</filter>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="244" y1="224" x2="448" y2="430" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
|
||||
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
|
||||
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M214 760C366 824 566 824 718 754" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.4"/>
|
||||
<ellipse cx="370" cy="292" rx="136" ry="92" fill="url(#sheen)"/>
|
||||
|
||||
<path d="M310 350L510 512L310 674" stroke="#F5F0E6" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#F5F0E6"/>
|
||||
<path d="M684 298H834V726H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g filter="url(#sparkGlow)">
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@ -1,67 +1,16 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="cardSurface" x1="108" y1="82" x2="387" y2="430" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFEFC"/>
|
||||
<stop offset="0.54" stop-color="#F5F2ED"/>
|
||||
<stop offset="1" stop-color="#DDD8D2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cardGloss" x1="256" y1="84" x2="256" y2="247" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.9"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandStroke" x1="156" y1="144" x2="372" y2="386" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9A72"/>
|
||||
<stop offset="0.52" stop-color="#F18258"/>
|
||||
<stop offset="1" stop-color="#C86439"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandSpark" x1="256" y1="145" x2="256" y2="263" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFB08B"/>
|
||||
<stop offset="0.58" stop-color="#FF8A62"/>
|
||||
<stop offset="1" stop-color="#E26C45"/>
|
||||
</linearGradient>
|
||||
<filter id="cardShadow" x="64" y="76" width="384" height="392" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#534330" flood-opacity="0.22"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="96" y="92" width="320" height="320" rx="82" fill="url(#cardSurface)"/>
|
||||
<rect x="96.75" y="92.75" width="318.5" height="318.5" rx="81.25" stroke="#F5F1EB" stroke-width="1.5"/>
|
||||
<path d="M121 146C168 117 231 107 302 118C344 124 379 138 405 159V239C370 222 324 211 273 208C210 203 158 188 121 163V146Z" fill="url(#cardGloss)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(-22 -18) scale(1.06)">
|
||||
<path
|
||||
d="M168 193L230 255L168 317"
|
||||
stroke="url(#brandStroke)"
|
||||
stroke-width="33"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M344 178L391 189L341 348L303 338"
|
||||
stroke="url(#brandStroke)"
|
||||
stroke-width="30"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M225 332C245 349 272 350 295 333"
|
||||
stroke="url(#brandStroke)"
|
||||
stroke-width="17"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M301 253C310 242 323 243 331 251"
|
||||
stroke="url(#brandStroke)"
|
||||
stroke-width="15"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M256 145L265 176L296 167L272 191L295 213L264 207L256 238L248 207L217 213L240 191L216 167L247 176L256 145Z"
|
||||
fill="url(#brandSpark)"
|
||||
/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 783 B After Width: | Height: | Size: 809 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 8.2 KiB |
@ -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"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3">
|
||||
<div className="sidebar-scroll-area min-h-0 flex-1 overflow-y-auto px-3">
|
||||
{error && (
|
||||
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
|
||||
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
|
||||
|
||||
@ -141,7 +141,7 @@ export function ActiveSession() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px]" style={{ boxShadow: 'var(--shadow-dropdown)' }} />
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||
{t('empty.title')}
|
||||
</h1>
|
||||
|
||||
@ -457,7 +457,7 @@ export function EmptySession() {
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px]" style={{ boxShadow: 'var(--shadow-dropdown)' }} />
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||
{t('empty.title')}
|
||||
</h1>
|
||||
|
||||
22
desktop/src/stores/settingsStore.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 = {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
docs/images/app-icon-dark.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
26
docs/images/app-icon-dark.svg
Normal file
@ -0,0 +1,26 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<radialGradient id="g1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(592 402) rotate(90) scale(130)">
|
||||
<stop stop-color="#FFD56A"/>
|
||||
<stop offset="1" stop-color="#F6B23E"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="470" y="280" width="244" height="244" filterUnits="userSpaceOnUse">
|
||||
<feGaussianBlur stdDeviation="18" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0.95 0 1 0 0 0.63 0 0 1 0 0.14 0 0 0 0.7 0"/>
|
||||
<feBlend in="SourceGraphic"/>
|
||||
</filter>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#F5F0E6" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#F5F0E6"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g filter="url(#glow)"><path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#g1)"/></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
docs/images/app-icon-light.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
17
docs/images/app-icon-light.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
|
Before Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 26 KiB |
17
docs/images/app-icon.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
|
Before Width: | Height: | Size: 337 KiB |
BIN
docs/images/banner.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
83
docs/images/banner.svg
Normal file
@ -0,0 +1,83 @@
|
||||
<svg width="2752" height="921" viewBox="0 0 2752 921" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bannerBg" x1="166" y1="84" x2="2550" y2="902" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2C1814"/>
|
||||
<stop offset="0.48" stop-color="#1D110F"/>
|
||||
<stop offset="1" stop-color="#120B0A"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="bannerGlowLeft" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(726 322) rotate(28) scale(734 480)">
|
||||
<stop stop-color="#FF7A37" stop-opacity="0.4"/>
|
||||
<stop offset="0.54" stop-color="#FF5B2E" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#FF5B2E" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="bannerGlowRight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2296 222) rotate(180) scale(560 320)">
|
||||
<stop stop-color="#FFD15C" stop-opacity="0.18"/>
|
||||
<stop offset="1" stop-color="#FFD15C" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="iconAccent" x1="256" y1="180" x2="796" y2="606" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5B2E"/>
|
||||
<stop offset="0.5" stop-color="#FF8134"/>
|
||||
<stop offset="1" stop-color="#FFD15C"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="promptStroke" x1="542" y1="236" x2="650" y2="236" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFF8F1"/>
|
||||
<stop offset="1" stop-color="#FFE7B3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hahaText" x1="2080" y1="262" x2="2648" y2="494" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5B2E"/>
|
||||
<stop offset="0.6" stop-color="#FF7C38"/>
|
||||
<stop offset="1" stop-color="#FFB340"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="chipBg" x1="1154" y1="568" x2="1662" y2="568" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4A231B"/>
|
||||
<stop offset="1" stop-color="#2B1712"/>
|
||||
</linearGradient>
|
||||
<filter id="tileShadow" x="152" y="124" width="714" height="714" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="26" stdDeviation="28" flood-color="#B54216" flood-opacity="0.28"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="2752" height="921" rx="56" fill="url(#bannerBg)"/>
|
||||
<rect x="24" y="24" width="2704" height="873" rx="36" stroke="#3B231D" stroke-width="2"/>
|
||||
<ellipse cx="742" cy="338" rx="762" ry="486" fill="url(#bannerGlowLeft)"/>
|
||||
<ellipse cx="2260" cy="216" rx="562" ry="328" fill="url(#bannerGlowRight)"/>
|
||||
|
||||
<path d="M132 176C330 70 630 76 842 180" stroke="#7A3926" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<path d="M98 748C332 824 688 812 914 712" stroke="#7A3926" stroke-width="8" stroke-linecap="round" opacity="0.42"/>
|
||||
<path d="M1972 150H2640" stroke="#4B2B24" stroke-width="6" stroke-linecap="round"/>
|
||||
<path d="M2128 764H2644" stroke="#4B2B24" stroke-width="6" stroke-linecap="round"/>
|
||||
<circle cx="2112" cy="230" r="14" fill="#FFD15C"/>
|
||||
<circle cx="2368" cy="142" r="10" fill="#FF7C38"/>
|
||||
<circle cx="2514" cy="238" r="8" fill="#FF5B2E"/>
|
||||
<path d="M2380 254L2394 290L2430 304L2394 318L2380 354L2366 318L2330 304L2366 290L2380 254Z" fill="#FFD15C"/>
|
||||
<path d="M2560 118L2572 146L2600 158L2572 170L2560 198L2548 170L2520 158L2548 146L2560 118Z" fill="#FFB340"/>
|
||||
|
||||
<g filter="url(#tileShadow)">
|
||||
<rect x="216" y="166" width="586" height="586" rx="170" fill="#211514"/>
|
||||
<rect x="219" y="169" width="580" height="580" rx="167" stroke="#422621" stroke-width="6"/>
|
||||
</g>
|
||||
|
||||
<path d="M364 282L500 418L364 554" stroke="url(#iconAccent)" stroke-width="58" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M572 246H650V574H572" stroke="url(#promptStroke)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M464 514H544" stroke="url(#iconAccent)" stroke-width="28" stroke-linecap="round"/>
|
||||
<path d="M506 236L524 288L576 270L540 310L576 350L524 334L506 386L488 334L436 350L472 310L436 270L488 288L506 236Z" fill="#FFD15C"/>
|
||||
|
||||
<text x="962" y="440" fill="#FFF5EC" font-family="Avenir Next, SF Pro Display, Inter, Helvetica Neue, Arial, sans-serif" font-size="222" font-weight="800" letter-spacing="-7">
|
||||
Claude Code
|
||||
</text>
|
||||
<text x="2200" y="440" fill="url(#hahaText)" font-family="Avenir Next, SF Pro Display, Inter, Helvetica Neue, Arial, sans-serif" font-size="222" font-weight="800" letter-spacing="-7">
|
||||
Haha
|
||||
</text>
|
||||
|
||||
<text x="968" y="560" fill="#D2B9AC" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="44" font-weight="700" letter-spacing="8">
|
||||
NEXT-GEN PERSONAL AI AGENT
|
||||
</text>
|
||||
|
||||
<rect x="1148" y="604" width="520" height="70" rx="35" fill="url(#chipBg)" stroke="#6C382A" stroke-width="2"/>
|
||||
<text x="1210" y="650" fill="#FFE8B8" font-family="Avenir Next, SF Pro Display, Inter, Helvetica Neue, Arial, sans-serif" font-size="34" font-weight="700" letter-spacing="3">
|
||||
DESKTOP EDITION
|
||||
</text>
|
||||
|
||||
<path d="M970 714H1830" stroke="url(#iconAccent)" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M1866 714H1984" stroke="#FFD15C" stroke-width="10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 256 KiB |
BIN
docs/images/logo-horizontal.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
68
docs/images/logo-horizontal.svg
Normal file
@ -0,0 +1,68 @@
|
||||
<svg width="2200" height="640" viewBox="0 0 2200 640" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="iconAura" x1="48" y1="70" x2="500" y2="426" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5B2E"/>
|
||||
<stop offset="0.52" stop-color="#FF8A2B"/>
|
||||
<stop offset="1" stop-color="#FFD15C"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accentRibbon" x1="86" y1="122" x2="424" y2="384" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5B2E"/>
|
||||
<stop offset="0.46" stop-color="#FF7C38"/>
|
||||
<stop offset="1" stop-color="#FFB340"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="promptStroke" x1="263" y1="152" x2="345" y2="152" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFF7EF"/>
|
||||
<stop offset="1" stop-color="#FFE8B8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hahaText" x1="1508" y1="174" x2="2040" y2="308" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5B2E"/>
|
||||
<stop offset="0.55" stop-color="#FF7A37"/>
|
||||
<stop offset="1" stop-color="#FFB340"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="taglineLine" x1="598" y1="434" x2="1572" y2="434" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFB340"/>
|
||||
<stop offset="0.52" stop-color="#FF7A37"/>
|
||||
<stop offset="1" stop-color="#FF5B2E"/>
|
||||
</linearGradient>
|
||||
<filter id="softBlur" x="-120" y="-120" width="760" height="760" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="46"/>
|
||||
</filter>
|
||||
<filter id="tileShadow" x="42" y="44" width="464" height="464" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#B64516" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#softBlur)" opacity="0.34">
|
||||
<ellipse cx="264" cy="250" rx="188" ry="150" fill="url(#iconAura)"/>
|
||||
</g>
|
||||
|
||||
<path d="M80 166C152 114 248 96 354 116" stroke="url(#accentRibbon)" stroke-width="26" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M98 404C194 456 318 458 414 410" stroke="url(#accentRibbon)" stroke-width="22" stroke-linecap="round" opacity="0.82"/>
|
||||
<circle cx="110" cy="286" r="10" fill="#FFD15C"/>
|
||||
<circle cx="418" cy="166" r="11" fill="#FF7A37"/>
|
||||
<circle cx="450" cy="338" r="8" fill="#FF5B2E"/>
|
||||
<path d="M390 96L400 122L426 132L400 142L390 168L380 142L354 132L380 122L390 96Z" fill="#FFD15C"/>
|
||||
|
||||
<g filter="url(#tileShadow)">
|
||||
<rect x="96" y="102" width="356" height="356" rx="108" fill="#211514"/>
|
||||
<rect x="98" y="104" width="352" height="352" rx="106" stroke="#3A2522" stroke-width="4"/>
|
||||
</g>
|
||||
|
||||
<path d="M184 186L268 270L184 354" stroke="url(#accentRibbon)" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M300 168H364V372H300" stroke="url(#promptStroke)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M234 324H270" stroke="url(#accentRibbon)" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M262 162L272 191L302 181L281 204L301 227L272 218L262 247L252 218L223 227L243 204L222 181L252 191L262 162Z" fill="#FFD15C"/>
|
||||
|
||||
<text x="598" y="292" fill="#271714" font-family="Avenir Next, SF Pro Display, Inter, Helvetica Neue, Arial, sans-serif" font-size="188" font-weight="800" letter-spacing="-7">
|
||||
Claude Code
|
||||
</text>
|
||||
<text x="1558" y="292" fill="url(#hahaText)" font-family="Avenir Next, SF Pro Display, Inter, Helvetica Neue, Arial, sans-serif" font-size="188" font-weight="800" letter-spacing="-7">
|
||||
Haha
|
||||
</text>
|
||||
|
||||
<text x="602" y="394" fill="#7A5C51" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="36" font-weight="700" letter-spacing="7">
|
||||
NEXT-GEN PERSONAL AI AGENT
|
||||
</text>
|
||||
<path d="M602 434H1548" stroke="url(#taglineLine)" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M1578 434H1682" stroke="#FFD15C" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 337 KiB |
BIN
docs/public/images/banner.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
@ -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<string, string>
|
||||
|
||||
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<string, string>
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
@ -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<void>((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)
|
||||
})
|
||||
|
||||
@ -66,7 +66,7 @@ describe('Real Provider Configs', () => {
|
||||
// 验证写入 cc-haha/settings.json
|
||||
const settings = await readCcHahaSettings()
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect((settings.env as Record<string, string>).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<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('claude-opus-4-7')
|
||||
|
||||
// 验证 activeId 正确
|
||||
@ -151,7 +151,7 @@ describe('Real Provider Configs', () => {
|
||||
|
||||
// 验证新字段写入
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk_test')
|
||||
expect((settings.env as Record<string, string>).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<string, string> | 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<string, string>).ANTHROPIC_BASE_URL).toBe('https://original-claude-code.api.com')
|
||||
expect((original.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('original-key')
|
||||
expect((original.env as Record<string, string>).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<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect((haha.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key')
|
||||
expect((haha.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-haha-key')
|
||||
|
||||
console.log('✅ 原版 settings.json 完好无损,Haha 配置独立存储')
|
||||
})
|
||||
|
||||
@ -50,26 +50,30 @@ function makeRequest(
|
||||
/** A sample provider input for reuse across tests */
|
||||
function sampleInput(overrides?: Partial<CreateProviderInput>): 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<Record<string, unknown>> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
/** Read the providers.json written to the temp config dir */
|
||||
async function readProvidersConfig(): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
@ -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<string, string>
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
@ -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 模式绝不能设,
|
||||
|
||||
@ -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<void> {
|
||||
const settings = await this.readSettings()
|
||||
const existingEnv = (settings.env as Record<string, string>) || {}
|
||||
const cleanedEnv = { ...existingEnv }
|
||||
|
||||
for (const key of MANAGED_ENV_KEYS) {
|
||||
delete cleanedEnv[key]
|
||||
}
|
||||
|
||||
settings.env = {
|
||||
...existingEnv,
|
||||
...cleanedEnv,
|
||||
...this.buildManagedEnv(provider),
|
||||
}
|
||||
|
||||
|
||||
@ -54,6 +54,8 @@ const runtimeOverrides = new Map<string, {
|
||||
modelId: string
|
||||
}>()
|
||||
|
||||
const runtimeTransitionPromises = new Map<string, Promise<void>>()
|
||||
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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)
|
||||
*/
|
||||
|
||||
@ -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.
|
||||
|
||||