diff --git a/build/generate-icons.mjs b/build/generate-icons.mjs new file mode 100644 index 0000000..fff848c --- /dev/null +++ b/build/generate-icons.mjs @@ -0,0 +1,163 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import pngToIco from 'png-to-ico' +import { resize } from 'png-to-ico/lib/png.js' +import { PNG } from 'pngjs' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const lightSourcePath = join( + root, + 'icons', + 'ChatGPT_xQBGv24GYc.png' +) +const darkSourcePath = join( + root, + 'icons', + 'ChatGPT_qPkaIrLGsm.png' +) +const rendererAssetRoot = join(root, 'src', 'renderer', 'src', 'assets') + +function cropSquare(source, size) { + const output = new PNG({ width: size, height: size }) + PNG.bitblt(source, output, 0, 0, size, size, 0, 0) + return output +} + +function pixelOffset(image, x, y) { + return (y * image.width + x) * 4 +} + +function repairLightCursor(image) { + for (let y = 246; y <= 284; y += 1) { + const leftOffset = pixelOffset(image, 632, y) + const rightOffset = pixelOffset(image, 670, y) + for (let x = 636; x <= 664; x += 1) { + const amount = (x - 632) / (670 - 632) + const targetOffset = pixelOffset(image, x, y) + for (let channel = 0; channel < 4; channel += 1) { + image.data[targetOffset + channel] = Math.round( + image.data[leftOffset + channel] * (1 - amount) + + image.data[rightOffset + channel] * amount + ) + } + } + } +} + +function isBrandColor(red, green, blue) { + return ( + Math.max(red, green, blue) - Math.min(red, green, blue) > 36 && + (green > 90 || blue > 100) + ) +} + +function repairDarkCursor(dark, light) { + for (let y = 570; y <= 606; y += 1) { + const backgroundOffset = pixelOffset(dark, 640, y) + for (let x = 540; x <= 568; x += 1) { + const targetOffset = pixelOffset(dark, x, y) + if ( + isBrandColor( + dark.data[targetOffset], + dark.data[targetOffset + 1], + dark.data[targetOffset + 2] + ) + ) { + continue + } + const lightOffset = pixelOffset(light, x + 20, y + 17) + if ( + isBrandColor( + light.data[lightOffset], + light.data[lightOffset + 1], + light.data[lightOffset + 2] + ) + ) { + dark.data[targetOffset] = light.data[lightOffset] + dark.data[targetOffset + 1] = light.data[lightOffset + 1] + dark.data[targetOffset + 2] = light.data[lightOffset + 2] + dark.data[targetOffset + 3] = 255 + continue + } + dark.data[targetOffset] = dark.data[backgroundOffset] + dark.data[targetOffset + 1] = dark.data[backgroundOffset + 1] + dark.data[targetOffset + 2] = dark.data[backgroundOffset + 2] + dark.data[targetOffset + 3] = 255 + } + } +} + +function assertCursorRemoved(light, dark) { + for (let y = 246; y <= 284; y += 1) { + for (let x = 636; x <= 664; x += 1) { + const offset = pixelOffset(light, x, y) + if ( + Math.max( + light.data[offset], + light.data[offset + 1], + light.data[offset + 2] + ) < 190 + ) { + throw new Error('亮色图标的鼠标指针修复失败') + } + } + } + for (let y = 570; y <= 606; y += 1) { + for (let x = 540; x <= 568; x += 1) { + const offset = pixelOffset(dark, x, y) + const channels = [ + dark.data[offset], + dark.data[offset + 1], + dark.data[offset + 2] + ] + if ( + Math.max(...channels) - Math.min(...channels) < 20 && + Math.max(...channels) > 80 + ) { + throw new Error('暗色图标的鼠标指针修复失败') + } + } + } +} + +async function main() { + const lightSource = PNG.sync.read(await readFile(lightSourcePath)) + const darkSource = PNG.sync.read(await readFile(darkSourcePath)) + const lightSquare = cropSquare(lightSource, 744) + const darkSquare = cropSquare(darkSource, 718) + repairLightCursor(lightSquare) + repairDarkCursor(darkSquare, lightSquare) + assertCursorRemoved(lightSquare, darkSquare) + + const light = resize(lightSquare, 512, 512, 'bicubicInterpolation') + const dark = resize(darkSquare, 512, 512, 'bicubicInterpolation') + const lightPng = PNG.sync.write(light) + const darkPng = PNG.sync.write(dark) + const rendererLightPng = PNG.sync.write( + resize(light, 128, 128, 'bicubicInterpolation') + ) + const rendererDarkPng = PNG.sync.write( + resize(dark, 128, 128, 'bicubicInterpolation') + ) + await mkdir(rendererAssetRoot, { recursive: true }) + + const outputs = [ + [join(root, 'build', 'icon-light.png'), lightPng], + [join(root, 'build', 'icon-dark.png'), darkPng], + [join(root, 'build', 'icon.png'), lightPng], + [join(rendererAssetRoot, 'goodbuddy-light.png'), rendererLightPng], + [join(rendererAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng] + ] + await Promise.all(outputs.map(([path, contents]) => writeFile(path, contents))) + + const lightIco = await pngToIco(lightPng) + const darkIco = await pngToIco(darkPng) + await Promise.all([ + writeFile(join(root, 'build', 'icon-light.ico'), lightIco), + writeFile(join(root, 'build', 'icon-dark.ico'), darkIco), + writeFile(join(root, 'build', 'icon.ico'), lightIco) + ]) +} + +await main() diff --git a/build/icon-dark.ico b/build/icon-dark.ico new file mode 100644 index 0000000..2c4958f Binary files /dev/null and b/build/icon-dark.ico differ diff --git a/build/icon-dark.png b/build/icon-dark.png new file mode 100644 index 0000000..2940a50 Binary files /dev/null and b/build/icon-dark.png differ diff --git a/build/icon-light.ico b/build/icon-light.ico new file mode 100644 index 0000000..b92eb96 Binary files /dev/null and b/build/icon-light.ico differ diff --git a/build/icon-light.png b/build/icon-light.png new file mode 100644 index 0000000..ea4f3d5 Binary files /dev/null and b/build/icon-light.png differ diff --git a/build/icon.ico b/build/icon.ico index c333316..b92eb96 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index 708441c..ea4f3d5 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/icons/ChatGPT_qPkaIrLGsm.png b/icons/ChatGPT_qPkaIrLGsm.png new file mode 100644 index 0000000..19d3576 Binary files /dev/null and b/icons/ChatGPT_qPkaIrLGsm.png differ diff --git a/icons/ChatGPT_xQBGv24GYc.png b/icons/ChatGPT_xQBGv24GYc.png new file mode 100644 index 0000000..77481b5 Binary files /dev/null and b/icons/ChatGPT_xQBGv24GYc.png differ diff --git a/package-lock.json b/package-lock.json index e70d2cc..3fc9429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "jsdom": "^30.0.1", "opencode-ai": "1.18.9", "png-to-ico": "^3.0.2", + "pngjs": "^7.0.0", "tar": "7.5.22", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", diff --git a/package.json b/package.json index c54f9e4..59507aa 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dist:linux": "npm run dist:linux:x64 && npm run dist:linux:arm64", "dist:linux:x64": "npm run build && electron-builder --linux AppImage deb --x64", "dist:linux:arm64": "npm run build && electron-builder --linux AppImage deb --arm64", + "icons": "node build/generate-icons.mjs", "portable": "npm run build && node build/build-portable.cjs" }, "build": { @@ -153,6 +154,7 @@ "jsdom": "^30.0.1", "opencode-ai": "1.18.9", "png-to-ico": "^3.0.2", + "pngjs": "^7.0.0", "tar": "7.5.22", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index bf4b3fc..7dbdda5 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -130,6 +130,22 @@ describe('ContinueHostAdapter', () => { ) }) + it('blocks runs without an explicit model profile or config file', async () => { + const launchHost = vi.fn() + const adapter = new ContinueHostAdapter({ + binaryPath: 'C:\\unused\\cn.js', + configPath: '', + workspace: process.cwd(), + cacheRoot: 'C:\\unused\\cache', + launchHost: launchHost as unknown as ContinueHostLauncher + }) + + await expect( + adapter.run('hello', new AbortController().signal, async () => 'deny') + ).rejects.toThrow('尚未配置模型连接') + expect(launchHost).not.toHaveBeenCalled() + }) + it('launches the prepared host through the injected launcher', async () => { const distribution = await createDistribution() let launch: @@ -266,10 +282,18 @@ describe('ContinueHostAdapter', () => { CONTINUE_CLI_ENABLE_TELEMETRY: '0', CONTINUE_METRICS_ENABLED: '0', CONTINUE_GLOBAL_DIR: expect.stringContaining('isolated-global'), + DO_NOT_TRACK: '1', GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', OTEL_EXPORTER_OTLP_ENDPOINT: '', + OTEL_EXPORTER_OTLP_HEADERS: '', + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '', OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '', - OTEL_LOG_USER_PROMPTS: '0' + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: '', + OTEL_LOGS_EXPORTER: 'none', + OTEL_LOG_USER_PROMPTS: '0', + OTEL_METRICS_EXPORTER: 'none', + OTEL_SDK_DISABLED: 'true', + OTEL_TRACES_EXPORTER: 'none' }) expect(killed).toBe(true) expect(JSON.parse(generatedConfig)).toMatchObject({ @@ -391,4 +415,79 @@ describe('ContinueHostAdapter', () => { expect(launchedEnvironment).not.toHaveProperty('OPENAI_API_KEY') expect(launchedEnvironment).not.toHaveProperty('ANTHROPIC_API_KEY') }) + + it('turns a strict upstream error envelope into a failed run', async () => { + const distribution = await createDistribution() + let killed = false + const launchHost: ContinueHostLauncher = () => ({ + exitCode: null, + get killed() { + return killed + }, + stderr: null, + once: () => undefined, + kill: () => { + killed = true + return true + } + }) + let stateRequests = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith('/state')) { + stateRequests += 1 + return Response.json({ + session: { + history: + stateRequests === 1 + ? [] + : [ + { + message: { + role: 'assistant', + content: 'Partial response' + } + }, + { + message: { + role: 'system', + content: + 'Error: {"error":{"message":"Request not allowed"}}' + } + } + ] + }, + isProcessing: false, + messageQueueLength: 0, + pendingPermission: null + }) + } + return Response.json({}) + }) + ) + const adapter = new ContinueHostAdapter({ + binaryPath: distribution.entryPath, + configPath: '', + workspace: process.cwd(), + cacheRoot: distribution.cacheRoot, + trustedBundleHashes: [distribution.sourceHash], + launchHost, + modelProfile: { + id: '00000000-0000-4000-8000-000000000013', + name: 'Local model', + baseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'local-model', + protocol: 'openai-chat-completions', + authentication: 'none' + } + }) + + await expect( + adapter.run('hello', new AbortController().signal, async () => 'deny') + ).rejects.toThrow( + 'Continue 模型请求失败:Request not allowed' + ) + expect(killed).toBe(true) + }) }) diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index d395923..f8b8d75 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -29,10 +29,16 @@ import { createContinuePermissionRule } from './continue-permissions' import { getAvailableLoopbackPort } from './loopback-port' -import { buildRuntimeEnvironment } from './process-environment' +import { + buildRuntimeEnvironment, + runtimePrivacyEnvironment +} from './process-environment' import { createAnthropicApiBaseUrl } from './anthropic-endpoint' import { createOpenAIApiBaseUrl } from './openai-endpoint' -import { safeToolArgumentSummary } from './approval-summary' +import { + redactSensitiveText, + safeToolArgumentSummary +} from './approval-summary' const supportedVersion = '1.5.47' const supportedBundleHashes = new Set([ @@ -40,6 +46,8 @@ const supportedBundleHashes = new Set([ ]) const maximumBundleBytes = 32 * 1024 * 1024 const maximumStateBytes = 8 * 1024 * 1024 +export const continueConfigurationRequiredMessage = + 'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件' const utilityBootstrap = [ "import { pathToFileURL } from 'node:url'", 'const entryPath = process.argv[2]', @@ -115,6 +123,13 @@ export type ContinueHostAdapterOptions = { modelProfile?: ResolvedModelProfile } +export function hasContinueModelConfiguration( + configPath: string, + modelProfile?: ResolvedModelProfile +): boolean { + return Boolean(modelProfile || configPath.trim()) +} + export type ContinueHostChild = { exitCode: number | null killed: boolean @@ -229,6 +244,58 @@ function extractAssistantText( return '' } +function parseContinueFailure(text: string): string | undefined { + const match = /^Error:\s*(\{[\s\S]{1,16384}\})$/u.exec(text.trim()) + if (!match?.[1]) { + return undefined + } + try { + const payload = JSON.parse(match[1]) as unknown + if (!payload || typeof payload !== 'object') { + return undefined + } + const record = payload as Record + const error = record.error + const message = + typeof error === 'string' + ? error + : error && typeof error === 'object' + ? (error as Record).message + : record.message + const detail = + typeof message === 'string' && message.trim() + ? `:${redactSensitiveText(message.trim()).slice(0, 500)}` + : '' + return `Continue 模型请求失败${detail}` + } catch { + return undefined + } +} + +function extractContinueFailure( + history: unknown[], + startIndex: number +): string | undefined { + for (const item of history.slice(startIndex).reverse()) { + if (!item || typeof item !== 'object') { + continue + } + const message = (item as Record).message + if (!message || typeof message !== 'object') { + continue + } + const content = (message as Record).content + if (typeof content !== 'string') { + continue + } + const failure = parseContinueFailure(content) + if (failure) { + return failure + } + } + return undefined +} + function subtractTokenCount(completed: number, initial: number): number { return Math.max(0, completed - initial) } @@ -480,6 +547,14 @@ export class ContinueHostAdapter { authorize: RuntimeAuthorizer ): Promise { signal.throwIfAborted() + if ( + !hasContinueModelConfiguration( + this.options.configPath, + this.options.modelProfile + ) + ) { + throw new Error(continueConfigurationRequiredMessage) + } let generatedConfigPath: string | undefined if (this.options.modelProfile) { if ( @@ -547,6 +622,7 @@ export class ContinueHostAdapter { } args.push('serve', '--port', String(port), '--timeout', '300') const environment = buildRuntimeEnvironment({ + ...runtimePrivacyEnvironment, CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1', CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', @@ -554,12 +630,7 @@ export class ContinueHostAdapter { CONTINUE_GLOBAL_DIR: isolatedGlobalDirectory, FORCE_NO_TTY: '1', GOODBUDDY_CONTINUE_HOST_TOKEN: token, - GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', - OTEL_EXPORTER_OTLP_ENDPOINT: '', - OTEL_EXPORTER_OTLP_HEADERS: '', - OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '', - OTEL_METRICS_EXPORTER: '', - OTEL_LOG_USER_PROMPTS: '0' + GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1' }) if (this.options.modelProfile) { delete environment.ANTHROPIC_API_KEY @@ -699,6 +770,13 @@ export class ContinueHostAdapter { !state.pendingPermission && state.session.history.length > startIndex ) { + const failure = extractContinueFailure( + state.session.history, + startIndex + ) + if (failure) { + throw new Error(failure) + } const text = extractAssistantText( state.session.history, startIndex diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index da815e6..35b14a1 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -152,7 +152,7 @@ describe('ContinueAgentRuntime', () => { it('adds assigned Skill instructions to the Continue prompt', async () => { const runtime = new ContinueAgentRuntime({ binaryPath: '', - configPath: '', + configPath: 'C:\\safe config\\continue.yaml', mode: 'chat', defaultWorkspace: process.cwd(), hostCacheRoot: 'C:\\safe\\continue-host', @@ -172,6 +172,39 @@ describe('ContinueAgentRuntime', () => { expect(prompt).toContain('test') }) + it('blocks anonymous platform fallback without an explicit model configuration', async () => { + const runtime = new ContinueAgentRuntime({ + binaryPath: '', + configPath: '', + mode: 'chat', + defaultWorkspace: process.cwd(), + hostCacheRoot: 'C:\\safe\\continue-host', + createHostAdapter: () => ({ + getPreparedHost: mocks.prepareHost, + run: mocks.runHost, + dispose: mocks.disposeHost + }) + }) + + await expect(runtime.getStatus()).resolves.toMatchObject({ + available: false, + detail: expect.stringContaining('尚未配置模型连接') + }) + const stream = runtime.run( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + prompt: 'test' + }, + new AbortController().signal, + vi.fn(async () => 'once' as const) + ) + await expect(stream.next()).rejects.toThrow('尚未配置模型连接') + expect(mocks.detectRuntimeBinary).not.toHaveBeenCalled() + expect(mocks.prepareHost).not.toHaveBeenCalled() + expect(mocks.runHost).not.toHaveBeenCalled() + }) + it('places the current request before untrusted conversation history', async () => { const runtime = createRuntime() for await (const _event of runtime.run( diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index 6b0ebe5..b29096c 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -13,6 +13,8 @@ import { detectRuntimeBinary } from './runtime-discovery' import type { ResolvedModelProfile } from '../runtime-settings-store' import { ContinueHostAdapter, + continueConfigurationRequiredMessage, + hasContinueModelConfiguration, type ContinueHostAdapterOptions, type ContinueHostLauncher } from './continue-host-adapter' @@ -137,6 +139,20 @@ export class ContinueAgentRuntime implements AgentRuntime { 'Continue 宿主暂不支持严格 OS 沙箱,请改用自动模式或嵌入式 OpenCode' } } + if ( + !hasContinueModelConfiguration( + this.options.configPath, + this.options.modelProfile + ) + ) { + return { + id: 'continue', + label: 'Continue CLI', + available: false, + supportsToolExecution: this.supportsToolExecution, + detail: continueConfigurationRequiredMessage + } + } const detection = await this.getDetection() if (detection.available && detection.path) { try { @@ -179,6 +195,14 @@ export class ContinueAgentRuntime implements AgentRuntime { if (request.images?.length) { throw new Error('Continue Runtime 暂不支持图片上下文,请切换到视觉模型') } + if ( + !hasContinueModelConfiguration( + this.options.configPath, + this.options.modelProfile + ) + ) { + throw new Error(continueConfigurationRequiredMessage) + } const prompt = buildContinuePrompt(request) const skillPrefix = this.options.skillInstructions ? [ diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 998526f..ef506f2 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -439,6 +439,10 @@ describe('OpenCodeRuntime embedded launcher', () => { OPENCODE_DISABLE_MODELS_FETCH: '1', OPENCODE_DISABLE_SHARE: '1', OTEL_EXPORTER_OTLP_ENDPOINT: '', + OTEL_EXPORTER_OTLP_HEADERS: '', + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '', + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '', + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: '', OTEL_SDK_DISABLED: 'true' }) await runtime.dispose() diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 93cff9c..7dbe063 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -21,7 +21,10 @@ import { detectRuntimeBinary } from './runtime-discovery' import { getAvailableLoopbackPort } from './loopback-port' import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { ResolvedModelProfile } from '../runtime-settings-store' -import { buildRuntimeEnvironment } from './process-environment' +import { + buildRuntimeEnvironment, + runtimePrivacyEnvironment +} from './process-environment' import { buildBubblewrapLaunch, type RuntimeSandboxResolution @@ -366,7 +369,7 @@ export class OpenCodeRuntime implements AgentRuntime { throw new Error('OpenCode Server 启动已取消') } - const env = buildRuntimeEnvironment({}) + const env = buildRuntimeEnvironment(runtimePrivacyEnvironment) if (this.options.modelProfile && !this.options.modelProfile.apiKey) { throw new Error('OpenCode 独立模型连接尚未配置 API Key') } @@ -380,18 +383,11 @@ export class OpenCodeRuntime implements AgentRuntime { ).toString('base64')}` env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME env.OPENCODE_SERVER_PASSWORD = serverPassword - env.DO_NOT_TRACK = '1' env.OPENCODE_DISABLE_AUTOUPDATE = '1' env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1' env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1' env.OPENCODE_DISABLE_MODELS_FETCH = '1' env.OPENCODE_DISABLE_SHARE = '1' - env.OTEL_EXPORTER_OTLP_ENDPOINT = '' - env.OTEL_EXPORTER_OTLP_HEADERS = '' - env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = '' - env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = '' - env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = '' - env.OTEL_SDK_DISABLED = 'true' if (this.options.modelProfile) { env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ model: `anthropic/${this.options.modelProfile.modelName}`, diff --git a/src/main/agent/process-environment.ts b/src/main/agent/process-environment.ts index 1a044f4..0f73f40 100644 --- a/src/main/agent/process-environment.ts +++ b/src/main/agent/process-environment.ts @@ -38,6 +38,20 @@ const runtimeEnvironmentAllowlist = [ 'COHERE_API_KEY' ] as const +export const runtimePrivacyEnvironment: NodeJS.ProcessEnv = { + DO_NOT_TRACK: '1', + OTEL_EXPORTER_OTLP_ENDPOINT: '', + OTEL_EXPORTER_OTLP_HEADERS: '', + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '', + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '', + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: '', + OTEL_LOGS_EXPORTER: 'none', + OTEL_LOG_USER_PROMPTS: '0', + OTEL_METRICS_EXPORTER: 'none', + OTEL_SDK_DISABLED: 'true', + OTEL_TRACES_EXPORTER: 'none' +} + export function buildRuntimeEnvironment( overrides: NodeJS.ProcessEnv, source: NodeJS.ProcessEnv = process.env diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 95b554e..d440003 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -118,7 +118,7 @@ describe('RuntimeSettingsStore', () => { ).rejects.toThrow('请重新输入或清除') }) - it('repairs a gpt-image profile saved with chat protocol and origin-only URL', async () => { + it('does not infer image capability from the model name', async () => { const { store } = await createStore() await store.update( settings({ @@ -130,28 +130,28 @@ describe('RuntimeSettingsStore', () => { ) await expect(store.getPublicSettings()).resolves.toMatchObject({ - modelBaseUrl: 'https://bigtoken.ai/v1', + modelBaseUrl: 'https://bigtoken.ai', modelName: 'gpt-image-2', - modelProtocol: 'openai-images-generations', + modelProtocol: 'anthropic-messages', apiKeyConfigured: true, credentialSource: 'encrypted', modelProfiles: [ expect.objectContaining({ - baseUrl: 'https://bigtoken.ai/v1', + baseUrl: 'https://bigtoken.ai', modelName: 'gpt-image-2', - protocol: 'openai-images-generations' + protocol: 'anthropic-messages' }) ] }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ - modelBaseUrl: 'https://bigtoken.ai/v1', + modelBaseUrl: 'https://bigtoken.ai', modelName: 'gpt-image-2', - modelProtocol: 'openai-images-generations', + modelProtocol: 'anthropic-messages', apiKey: 'image-secret' }) }) - it('repairs nondefault image protocols without rewriting custom root endpoints', async () => { + it('uses the explicit protocol as the image-generation capability marker', async () => { const { store } = await createStore() const chatId = crypto.randomUUID() const imageId = crypto.randomUUID() @@ -170,34 +170,36 @@ describe('RuntimeSettingsStore', () => { { id: imageId, name: 'Custom Image', - baseUrl: 'https://images.example', - modelName: 'gpt-image-custom', - protocol: 'anthropic-messages', + baseUrl: 'https://images.example/custom/v2', + modelName: 'vendor/custom-renderer', + protocol: 'openai-images-generations', authentication: 'api-key', apiKey: { action: 'replace', value: 'image-secret' } } ], - defaultModelProfileId: chatId, - continueModelSource: { kind: 'profile', profileId: imageId } + defaultModelProfileId: imageId }) ) await expect(store.getPublicSettings()).resolves.toMatchObject({ + modelBaseUrl: 'https://images.example/custom/v2', + modelName: 'vendor/custom-renderer', + modelProtocol: 'openai-images-generations', modelProfiles: [ expect.objectContaining({ id: chatId }), expect.objectContaining({ id: imageId, - baseUrl: 'https://images.example', + baseUrl: 'https://images.example/custom/v2', + modelName: 'vendor/custom-renderer', protocol: 'openai-images-generations' }) ] }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ - continueModelProfile: { - id: imageId, - baseUrl: 'https://images.example', - protocol: 'openai-images-generations' - } + modelBaseUrl: 'https://images.example/custom/v2', + modelName: 'vendor/custom-renderer', + modelProtocol: 'openai-images-generations', + apiKey: 'image-secret' }) }) diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index 21a3344..70d0f29 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -277,31 +277,6 @@ function normalizeModelBaseUrl(value: string): string { return url.toString().replace(/\/$/u, '') } -function normalizeEffectiveModelConnection( - baseUrl: string, - model: string, - protocol: RuntimeSettings['modelProtocol'] -): { - baseUrl: string - protocol: RuntimeSettings['modelProtocol'] -} { - if (!/^gpt-image-/iu.test(model)) { - return { baseUrl, protocol } - } - const url = new URL(baseUrl) - if ( - protocol !== 'openai-images-generations' && - url.hostname.toLowerCase() === 'bigtoken.ai' && - (url.pathname === '/' || url.pathname === '') - ) { - url.pathname = '/v1' - } - return { - baseUrl: url.toString().replace(/\/$/u, ''), - protocol: 'openai-images-generations' - } -} - export class RuntimeSettingsStore { private settings?: StoredSettings private loadWarning?: string @@ -475,16 +450,11 @@ export class RuntimeSettingsStore { const model = environmentApiKey ? environmentModel || defaultRuntimeSettings.modelName : profile.modelName - const effectiveConnection = normalizeEffectiveModelConnection( - baseUrl, - model, - profile.protocol - ) return { apiKey: environmentApiKey ?? storedApiKey, - baseUrl: effectiveConnection.baseUrl, + baseUrl, model, - protocol: effectiveConnection.protocol, + protocol: profile.protocol, authentication: profile.authentication, credentialSource: environmentApiKey ? 'environment' @@ -516,17 +486,12 @@ export class RuntimeSettingsStore { apiKey: effective.apiKey } } - const connection = normalizeEffectiveModelConnection( - profile.baseUrl, - profile.modelName, - profile.protocol - ) return { id: profile.id, name: profile.name, - baseUrl: connection.baseUrl, + baseUrl: profile.baseUrl, modelName: profile.modelName, - protocol: connection.protocol, + protocol: profile.protocol, authentication: profile.authentication, apiKey: profile.authentication === 'api-key' @@ -589,13 +554,6 @@ export class RuntimeSettingsStore { const agent = this.resolveAgentSettings(settings) const modelProfiles = settings.modelProfiles.map((profile) => { const isDefault = profile.id === settings.defaultModelProfileId - const connection = isDefault - ? undefined - : normalizeEffectiveModelConnection( - profile.baseUrl, - profile.modelName, - profile.protocol - ) const apiKey = profile.authentication === 'api-key' ? this.getStoredApiKey(profile) @@ -605,11 +563,11 @@ export class RuntimeSettingsStore { name: profile.name, baseUrl: isDefault ? effective.baseUrl - : (connection?.baseUrl ?? profile.baseUrl), + : profile.baseUrl, modelName: isDefault ? effective.model : profile.modelName, protocol: isDefault ? effective.protocol - : (connection?.protocol ?? profile.protocol), + : profile.protocol, authentication: isDefault ? effective.authentication : profile.authentication, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 4e0f9a9..b52634d 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -12,6 +12,7 @@ import type { AgentEvent, DesktopApi } from '../../shared/contracts' import App from './App' let agentListener: ((event: AgentEvent) => void) | undefined +let newConversationListener: (() => void) | undefined const run = vi.fn() const modelProfileId = '00000000-0000-4000-8000-000000000001' const projectId = '00000000-0000-4000-8000-000000000101' @@ -38,7 +39,12 @@ const api: DesktopApi = { show: vi.fn(async () => {}), hide: vi.fn(async () => {}), clearLocalData: vi.fn(async () => {}), - onNewConversation: vi.fn(() => () => {}), + onNewConversation: vi.fn((listener) => { + newConversationListener = listener + return () => { + newConversationListener = undefined + } + }), onOpenSettings: vi.fn(() => () => {}) }, agent: { @@ -380,7 +386,10 @@ const api: DesktopApi = { describe('App', () => { beforeEach(() => { localStorage.clear() + delete document.documentElement.dataset.theme + document.documentElement.style.colorScheme = '' vi.clearAllMocks() + newConversationListener = undefined vi.mocked(api.agent.getStatus).mockResolvedValue({ id: 'model', label: 'sonnet-5', @@ -434,6 +443,93 @@ describe('App', () => { expect(await screen.findByText('这是回答内容')).toBeInTheDocument() }) + it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => { + vi.mocked(api.agent.getStatus).mockReturnValue( + new Promise(() => {}) + ) + render() + + const composer = screen.getByLabelText('向 GoodBuddy 提问') + fireEvent.change(composer, { + target: { value: '等待 Runtime' } + }) + fireEvent.keyDown(composer, { key: 'Enter' }) + + expect(composer).toHaveValue('等待 Runtime') + expect( + screen.queryByRole('heading', { name: '设置中心' }) + ).not.toBeInTheDocument() + expect( + await screen.findByText('Agent Runtime 正在加载,请稍后重试') + ).toBeInTheDocument() + expect(run).not.toHaveBeenCalled() + }) + + it('keeps a new-conversation draft in chat when the runtime is unavailable', async () => { + vi.mocked(api.agent.getStatus).mockResolvedValue({ + id: 'setup', + label: '需要配置模型', + available: false, + supportsToolExecution: false, + detail: '请配置模型' + }) + render() + + expect( + await screen.findByRole('heading', { name: '设置中心' }) + ).toBeInTheDocument() + const newConversation = screen.getByRole('button', { + name: /新建对话/u + }) + fireEvent.click(newConversation) + + const composer = screen.getByLabelText('向 GoodBuddy 提问') + await waitFor(() => expect(composer).toHaveFocus()) + fireEvent.change(composer, { + target: { value: '保留这条草稿' } + }) + fireEvent.keyDown(composer, { key: 'Enter' }) + + expect(composer).toHaveValue('保留这条草稿') + expect( + screen.queryByRole('heading', { name: '设置中心' }) + ).not.toBeInTheDocument() + expect( + await screen.findByText(/请先配置可用的模型或 Agent Runtime/u) + ).toBeInTheDocument() + expect(run).not.toHaveBeenCalled() + }) + + it('opens chat and focuses the composer for tray conversations', async () => { + render() + + fireEvent.click(await screen.findByText('本地工作区')) + expect( + await screen.findByRole('heading', { name: '设置中心' }) + ).toBeInTheDocument() + + act(() => newConversationListener?.()) + + const composer = await screen.findByLabelText('向 GoodBuddy 提问') + await waitFor(() => expect(composer).toHaveFocus()) + }) + + it('applies and persists a dark appearance from Settings', async () => { + render() + + fireEvent.click(await screen.findByText('本地工作区')) + fireEvent.click(screen.getByRole('tab', { name: '外观' })) + fireEvent.click(screen.getByRole('radio', { name: /暗色/u })) + + await waitFor(() => + expect(document.documentElement.dataset.theme).toBe('dark') + ) + expect(document.documentElement.style.colorScheme).toBe('dark') + expect(localStorage.getItem('goodbuddy.appearance-theme')).toBe( + 'dark' + ) + }) + it('loads token usage in activity and refreshes it when a run finishes', async () => { vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({ totals: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3c70951..b7a48c8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -80,6 +80,15 @@ import { type SidebarArtifact } from './RightAssistantSidebar' import { SettingsPanel } from './SettingsPanel' +import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' +import goodbuddyLightIcon from './assets/goodbuddy-light.png' +import { + applyAppearanceTheme, + loadAppearanceTheme, + resolveAppearanceTheme, + saveAppearanceTheme, + type AppearanceTheme +} from './theme' type ToolActivity = { callId?: string @@ -454,6 +463,17 @@ function App(): React.JSX.Element { const [runtimeSettings, setRuntimeSettings] = useState() const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false) const [runtimeSwitching, setRuntimeSwitching] = useState(false) + const [appearanceTheme, setAppearanceTheme] = + useState(loadAppearanceTheme) + const [systemPrefersDark, setSystemPrefersDark] = useState( + () => + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ) + const resolvedAppearanceTheme = resolveAppearanceTheme( + appearanceTheme, + systemPrefersDark + ) const effectiveWorkMode = workMode === 'execute' && runtime?.supportsToolExecution === false ? 'ask' @@ -495,6 +515,47 @@ function App(): React.JSX.Element { const inputRef = useRef(null) const scrollRef = useRef(null) + const startNewConversation = useCallback((projectId?: string): void => { + const conversation = createConversation(projectId) + setConversations((current) => [conversation, ...current]) + setActiveId(conversation.id) + setView('chat') + setInput('') + setAttachments((current) => { + for (const attachment of current) { + void window.goodbuddy.context.remove(attachment.id) + } + return [] + }) + requestAnimationFrame(() => inputRef.current?.focus()) + }, []) + + useEffect(() => { + saveAppearanceTheme(appearanceTheme) + }, [appearanceTheme]) + + useEffect(() => { + applyAppearanceTheme(resolvedAppearanceTheme) + }, [resolvedAppearanceTheme]) + + useEffect(() => { + if (appearanceTheme !== 'system') { + return + } + if (typeof window.matchMedia !== 'function') { + return + } + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') + const updateSystemTheme = (): void => { + setSystemPrefersDark(systemTheme.matches) + } + updateSystemTheme() + systemTheme.addEventListener('change', updateSystemTheme) + return () => { + systemTheme.removeEventListener('change', updateSystemTheme) + } + }, [appearanceTheme]) + useEffect(() => { if (typeof window.matchMedia !== 'function') { return @@ -1352,18 +1413,9 @@ function App(): React.JSX.Element { window.goodbuddy.agent.onEvent(handleAgentEvent) const removeNewConversationListener = window.goodbuddy.app.onNewConversation(() => { - const conversation = createConversation( + startNewConversation( activeProjectIdRef.current || undefined ) - setConversations((current) => [conversation, ...current]) - setActiveId(conversation.id) - setAttachments((current) => { - for (const attachment of current) { - void window.goodbuddy.context.remove(attachment.id) - } - return [] - }) - inputRef.current?.focus() }) const removeOpenSettingsListener = window.goodbuddy.app.onOpenSettings(() => setView('settings')) @@ -1372,7 +1424,7 @@ function App(): React.JSX.Element { removeNewConversationListener() removeOpenSettingsListener() } - }, [handleAgentEvent]) + }, [handleAgentEvent, startNewConversation]) useEffect(() => { const frame = requestAnimationFrame(() => { @@ -1429,16 +1481,7 @@ function App(): React.JSX.Element { } const newConversation = (): void => { - const conversation = createConversation(activeProjectId || undefined) - setConversations((current) => [conversation, ...current]) - setActiveId(conversation.id) - setView('chat') - setInput('') - for (const attachment of attachments) { - void window.goodbuddy.context.remove(attachment.id) - } - setAttachments([]) - inputRef.current?.focus() + startNewConversation(activeProjectId || undefined) } const setMemoryStatus = async ( @@ -1576,8 +1619,11 @@ function App(): React.JSX.Element { if (!prompt || !activeConversation) { return } - if (!runtime?.available) { - setView('settings') + if (!runtime) { + setNotice('Agent Runtime 正在加载,请稍后重试') + return + } + if (!runtime.available) { setNotice('请先配置可用的模型或 Agent Runtime') return } @@ -1980,7 +2026,15 @@ function App(): React.JSX.Element {