feat: strengthen private runtime and adaptive UI behavior

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 21:51:53 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b3fdf96962
commit e8dc4d03fd
32 changed files with 1390 additions and 128 deletions
+163
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

+1
View File
@@ -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",
+2
View File
@@ -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",
+100 -1
View File
@@ -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)
})
})
+86 -8
View File
@@ -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<string, unknown>
const error = record.error
const message =
typeof error === 'string'
? error
: error && typeof error === 'object'
? (error as Record<string, unknown>).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<string, unknown>).message
if (!message || typeof message !== 'object') {
continue
}
const content = (message as Record<string, unknown>).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<ContinueHostRunResult> {
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
+34 -1
View File
@@ -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(
+24
View File
@@ -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
? [
+4
View File
@@ -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()
+5 -9
View File
@@ -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}`,
+14
View File
@@ -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
+21 -19
View File
@@ -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'
})
})
+6 -48
View File
@@ -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,
+97 -1
View File
@@ -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<DesktopApi['agent']['run']>()
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(<App />)
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(<App />)
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(<App />)
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(<App />)
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: {
+84 -26
View File
@@ -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<RuntimeSettings>()
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(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<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(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 {
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
<div className="brand">
<div className="brand__mark">
<Bot size={20} strokeWidth={2.4} />
<img
alt=""
aria-hidden="true"
src={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
/>
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
@@ -2233,7 +2287,9 @@ function App(): React.JSX.Element {
title={runtime?.detail}
>
<span className="runtime-status__dot" />
{runtime?.label ?? '正在检测运行时'}
<span className="runtime-status__label">
{runtime?.label ?? '正在检测运行时'}
</span>
{runtime?.capability === 'image-generation' && (
<span className="runtime-capability-badge"></span>
)}
@@ -3018,7 +3074,7 @@ function App(): React.JSX.Element {
/>
</div>
) : view === 'heartbeat' ? (
<div className="workspace-panel-scroll">
<div className="workspace-panel-scroll workspace-panel-scroll--heartbeat">
<HeartbeatCenter
configs={assistantHeartbeats}
entries={heartbeatEntries}
@@ -3037,7 +3093,9 @@ function App(): React.JSX.Element {
</div>
) : view === 'settings' ? (
<SettingsPanel
appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats}
onAppearanceThemeChange={setAppearanceTheme}
onClearLocalData={clearLocalData}
onClose={() => setView('chat')}
onCreateHeartbeat={createHeartbeat}
@@ -25,6 +25,9 @@ const ready = true
).toBeInTheDocument()
expect(screen.getByRole('checkbox')).toBeChecked()
expect(screen.getByRole('table')).toBeInTheDocument()
expect(
screen.getByRole('region', { name: '表格,可横向滚动' })
).toContainElement(screen.getByRole('table'))
expect(screen.getByText('const ready = true')).toBeInTheDocument()
})
+13
View File
@@ -10,6 +10,19 @@ const components: Components = {
{children}
</a>
)
},
table: ({ children, node, ...properties }) => {
void node
return (
<div
aria-label="表格,可横向滚动"
className="markdown-table-scroll"
role="region"
tabIndex={0}
>
<table {...properties}>{children}</table>
</div>
)
}
}
+25
View File
@@ -175,6 +175,28 @@ describe('SettingsPanel runtime files', () => {
cleanup()
})
it('offers system, light, and dark appearance modes', async () => {
const onAppearanceThemeChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
appearanceTheme="system"
onAppearanceThemeChange={onAppearanceThemeChange}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.getByRole('radio', { name: //u })
).toBeChecked()
fireEvent.click(screen.getByRole('radio', { name: //u }))
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('automatically detects runtimes and displays path, version, and detail', async () => {
render(
<SettingsPanel
@@ -230,6 +252,9 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.getByText(/仅在实际请求高风险工具时暂停/)
).toBeInTheDocument()
expect(
screen.getByText(/不会匿名加载远程默认模型/)
).toBeInTheDocument()
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
expect(input).toHaveValue('')
+71 -4
View File
@@ -4,6 +4,7 @@ import {
KeyRound,
LockKeyhole,
Plus,
SunMoon,
TerminalSquare,
Trash2,
X
@@ -28,8 +29,10 @@ import {
import { McpSettingsSection } from './McpSettingsSection'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { HeartbeatSettings } from './HeartbeatSettings'
import type { AppearanceTheme } from './theme'
type SettingsTab =
| 'appearance'
| 'model'
| 'runtime'
| 'security'
@@ -55,6 +58,8 @@ type SettingsPanelProps = {
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRunHeartbeat: (heartbeatId: string) => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
}
const credentialLabels: Record<
@@ -86,7 +91,9 @@ export function SettingsPanel({
onCreateHeartbeat,
onSetHeartbeatPaused,
onRemoveHeartbeat,
onRunHeartbeat
onRunHeartbeat,
appearanceTheme = 'system',
onAppearanceThemeChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const [settings, setSettings] = useState<RuntimeSettings>()
const [provider, setProvider] =
@@ -517,6 +524,16 @@ export function SettingsPanel({
<div className="settings-panel__body">
<nav aria-label="设置分类" className="settings-tabs">
<button
aria-label="外观"
aria-selected={activeTab === 'appearance'}
onClick={() => setActiveTab('appearance')}
role="tab"
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-label="模型连接"
aria-selected={activeTab === 'model'}
@@ -580,6 +597,50 @@ export function SettingsPanel({
</nav>
<div className="settings-panel__content">
{activeTab === 'appearance' && (
<div className="settings-section appearance-settings">
<div className="settings-section__title">
<SunMoon size={17} />
<div>
<strong></strong>
<small></small>
</div>
</div>
<div
aria-label="界面主题"
className="appearance-options"
role="radiogroup"
>
{(
[
['system', '跟随系统', '随操作系统自动切换'],
['light', '亮色', '明亮、清晰的工作界面'],
['dark', '暗色', '降低暗光环境下的亮度']
] as const
).map(([value, label, description]) => (
<label key={value}>
<input
checked={appearanceTheme === value}
name="appearance-theme"
onChange={() => onAppearanceThemeChange(value)}
type="radio"
value={value}
/>
<span
aria-hidden="true"
className={`appearance-options__preview appearance-options__preview--${value}`}
>
<i />
<i />
<i />
</span>
<strong>{label}</strong>
<small>{description}</small>
</label>
))}
</div>
</div>
)}
{activeTab === 'runtime' && (
<>
{settings?.warning && (
@@ -827,7 +888,7 @@ export function SettingsPanel({
)
}
>
<option value="platform">使 Continue </option>
<option value="platform">使 Continue </option>
{modelProfiles.map((profile) => (
<option
disabled={!isContinueCompatible(profile)}
@@ -843,7 +904,7 @@ export function SettingsPanel({
</select>
<small>
Continue Anthropic MessagesOpenAI Chat
Completions
Completions
</small>
</label>
<label className="field">
@@ -887,7 +948,7 @@ export function SettingsPanel({
onChange={(event) =>
setContinueConfigPath(event.target.value)
}
placeholder="留空使用工具默认配置"
placeholder="选择可信的本地 Continue 配置文件"
value={continueConfigPath}
/>
<button
@@ -912,6 +973,12 @@ export function SettingsPanel({
</button>
</div>
</label>
{continueModelSource.kind === 'platform' &&
!continueConfigPath && (
<p className="settings-warning">
Continue
</p>
)}
{continueBinaryPath && (
<p className="settings-warning">
Continue
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+13
View File
@@ -1,6 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import {
applyAppearanceTheme,
loadAppearanceTheme,
resolveAppearanceTheme
} from './theme'
import './styles.css'
const root = document.getElementById('root')
@@ -9,6 +14,14 @@ if (!root) {
throw new Error('Root element not found')
}
applyAppearanceTheme(
resolveAppearanceTheme(
loadAppearanceTheme(),
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
)
)
createRoot(root).render(
<StrictMode>
<App />
+552 -11
View File
@@ -88,6 +88,13 @@ textarea:focus-visible {
color: #1677ff;
}
.brand__mark img {
width: 100%;
height: 100%;
border-radius: 7px;
object-fit: cover;
}
.brand__copy {
display: flex;
flex-direction: column;
@@ -439,10 +446,12 @@ textarea:focus-visible {
.topbar {
display: flex;
min-width: 0;
align-items: center;
padding: 0 21px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
gap: 3px;
}
.icon-button {
@@ -467,6 +476,8 @@ textarea:focus-visible {
}
.topbar__expert {
width: clamp(86px, 11vw, 130px);
min-width: 0;
max-width: 130px;
padding: 6px 8px;
border: 1px solid #d9d9d9;
@@ -821,6 +832,7 @@ textarea:focus-visible {
min-width: 0;
flex-direction: column;
gap: 13px;
container: heartbeat-settings / inline-size;
}
.heartbeat-settings__intro h3 {
@@ -1045,6 +1057,7 @@ textarea:focus-visible {
.conversation-title {
display: flex;
min-width: 0;
align-items: center;
padding: 7px 9px;
border-radius: 8px;
@@ -1057,6 +1070,16 @@ textarea:focus-visible {
font-weight: 650;
}
.conversation-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-title svg {
flex: 0 0 auto;
}
.conversation-title:hover {
background: #e6f4ff;
color: #1677ff;
@@ -1064,13 +1087,17 @@ textarea:focus-visible {
.topbar__actions {
display: flex;
min-width: 0;
align-items: center;
flex: 0 1 auto;
margin-left: auto;
gap: 3px;
}
.runtime-status {
display: flex;
min-width: 0;
max-width: clamp(108px, 18vw, 220px);
align-items: center;
padding: 6px 10px;
border: 1px solid #d9d9d9;
@@ -1082,6 +1109,16 @@ textarea:focus-visible {
gap: 6px;
}
.runtime-status__dot {
flex: 0 0 auto;
}
.runtime-status__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-status__dot {
width: 6px;
height: 6px;
@@ -1149,7 +1186,10 @@ textarea:focus-visible {
max-width: 720px;
margin: 0 auto;
gap: 10px;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(
auto-fit,
minmax(min(180px, 100%), 1fr)
);
}
.quick-actions button {
@@ -1349,10 +1389,24 @@ textarea:focus-visible {
font-size: 0.9em;
}
.markdown-table-scroll {
max-width: 100%;
margin: 0.8em 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.markdown-table-scroll:focus-visible {
border-radius: 4px;
outline: 2px solid #1677ff;
outline-offset: 2px;
}
.markdown-content table {
width: 100%;
min-width: max-content;
border-collapse: collapse;
margin: 0.8em 0;
margin: 0;
font-size: 11px;
}
@@ -1870,7 +1924,7 @@ textarea:focus-visible {
padding: 3px;
border-radius: 9px;
background: #f5f5f5;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(7, 1fr);
}
.settings-tabs button {
@@ -2561,6 +2615,10 @@ textarea:focus-visible {
container-type: inline-size;
}
.workspace-panel-scroll--heartbeat {
container-type: inline-size;
}
.knowledge-workspace {
width: 100%;
min-height: max(520px, calc(100dvh - 114px));
@@ -3074,6 +3132,7 @@ textarea:focus-visible {
.activity-panel__filters {
display: flex;
flex-wrap: wrap;
margin-bottom: 12px;
gap: 6px;
}
@@ -3109,6 +3168,8 @@ textarea:focus-visible {
.activity-item__header,
.activity-item__labels {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 7px;
}
@@ -3159,6 +3220,7 @@ textarea:focus-visible {
min-height: 100%;
flex-direction: column;
margin: 0 auto;
container: heartbeat-center / inline-size;
}
.heartbeat-center__hero {
@@ -3857,6 +3919,485 @@ textarea:focus-visible {
background: #fff;
}
.appearance-settings {
gap: 14px;
}
.appearance-options {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.appearance-options label {
display: flex;
min-width: 0;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 10px;
background: #fff;
cursor: pointer;
flex-direction: column;
gap: 5px;
}
.appearance-options label:has(input:checked) {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(22 119 255 / 12%);
}
.appearance-options label:has(input:focus-visible) {
outline: 2px solid #1677ff;
outline-offset: 2px;
}
.appearance-options input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.appearance-options__preview {
display: grid;
height: 76px;
padding: 9px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #f5f5f5;
gap: 5px;
grid-template-columns: 30% 1fr;
grid-template-rows: 12px 1fr;
}
.appearance-options__preview i {
display: block;
border-radius: 3px;
background: #fff;
}
.appearance-options__preview i:first-child {
grid-column: 1 / -1;
}
.appearance-options__preview i:nth-child(2) {
grid-row: 2;
}
.appearance-options__preview i:nth-child(3) {
background: #e6f4ff;
grid-column: 2;
grid-row: 2;
}
.appearance-options__preview--dark {
border-color: #344258;
background: #0b111b;
}
.appearance-options__preview--dark i {
background: #1c2738;
}
.appearance-options__preview--dark i:nth-child(3) {
background: #15345f;
}
.appearance-options__preview--system {
background: linear-gradient(90deg, #f5f5f5 0 50%, #0b111b 50% 100%);
}
.appearance-options__preview--system i {
background: linear-gradient(90deg, #fff 0 50%, #1c2738 50% 100%);
}
.appearance-options__preview--system i:nth-child(3) {
background: linear-gradient(90deg, #e6f4ff 0 50%, #15345f 50% 100%);
}
:root[data-theme='dark'] {
color: #edf4ff;
background: #0b111b;
}
:root[data-theme='dark'] .app-shell {
background: #0b111b;
color: #edf4ff;
}
:root[data-theme='dark'] :where(
.settings-page,
.composer-wrap
) {
background: #0b111b;
}
:root[data-theme='dark'] :where(
.sidebar,
.topbar,
.assistant-sidebar,
.project-create-card,
.settings-panel,
.composer,
.runtime-picker__menu,
.knowledge-scope__popover,
.knowledge-panel__document,
.token-usage,
.activity-item,
.heartbeat-center__section,
.heartbeat-center__metrics > div,
.heartbeat-center__plans .heartbeat-settings,
.heartbeat-settings__item,
.capability-card,
.quick-actions button,
.secondary-button
) {
border-color: #293548;
background: #111827;
}
:root[data-theme='dark'] :where(
.app-shell,
.sidebar,
.topbar,
.assistant-sidebar,
.settings-panel,
.project-create-card,
.composer,
.workspace-panel-scroll
) :where(input, textarea, select) {
border-color: #344258;
background: #172033;
color: #edf4ff;
}
:root[data-theme='dark'] :where(
.app-shell,
.sidebar,
.topbar,
.assistant-sidebar,
.settings-panel,
.project-create-card,
.composer
) :where(input, textarea)::placeholder {
color: #718096;
}
:root[data-theme='dark'] :where(
.sidebar-search,
.settings-section,
.settings-tabs,
.settings-panel__footer,
.assistant-sidebar__row,
.assistant-sidebar__library,
.assistant-sidebar__schedule,
.assistant-sidebar__diff,
.assistant-sidebar__preview pre,
.tool-activity,
.approval-card,
.context-chip,
.token-usage__group,
.token-usage__stats div,
.activity-panel__stats div,
.activity-filter,
.heartbeat-center__tab,
.heartbeat-center__config-card,
.heartbeat-center__suggestion,
.heartbeat-center__run,
.heartbeat-settings--sidebar .heartbeat-settings__item,
.knowledge-graph__toolbar,
.markdown-content pre,
.markdown-content :not(pre) > code
) {
border-color: #293548;
background: #172033;
}
:root[data-theme='dark'] :where(
.primary-nav,
.sidebar-footer,
.topbar,
.assistant-sidebar,
.assistant-sidebar__header,
.assistant-sidebar__tabs,
.assistant-sidebar__context,
.assistant-sidebar__preview > header,
.message + .message,
.divider,
.runtime-picker__divider,
.settings-panel__header,
.settings-panel__footer,
.knowledge-workspace__sidebar,
.knowledge-workspace__header,
.knowledge-panel__header,
.activity-panel__header,
.heartbeat-center__hero,
.heartbeat-center__config-card,
.heartbeat-center__suggestion,
.heartbeat-center__run
) {
border-color: #293548;
}
:root[data-theme='dark'] :where(
h1,
h2,
h3,
h4,
.conversation-title,
.welcome h1,
.message__meta strong,
.markdown-content,
.settings-section__title strong,
.field,
.capability-card strong,
.knowledge-panel__document-info strong,
.token-usage__header h3,
.activity-item h3,
.heartbeat-center__latest > p,
.heartbeat-center__run strong
) {
color: #edf4ff;
}
:root[data-theme='dark'] .app-shell strong {
color: #edf4ff;
}
:root[data-theme='dark'] :where(
.nav-item,
.conversation-item,
.user-card,
.icon-button,
.assistant-sidebar__row,
.runtime-status,
.welcome__description,
.tool-activity,
.context-chip,
.settings-tabs button,
.credential-state,
.capability-card p,
.secondary-button,
.check-field,
.knowledge-panel__limits,
.knowledge-panel__summary,
.token-usage th,
.token-usage td,
.activity-filter,
.activity-item p,
.heartbeat-center__hero p:not(.eyebrow),
.heartbeat-center__tab,
.heartbeat-center__config-card,
.heartbeat-center__suggestion > p,
.heartbeat-center__run small
) {
color: #aebbd0;
}
:root[data-theme='dark'] :where(
.brand__copy span,
.nav-item__hint,
.section-label,
.conversation-item small,
.user-card__copy small,
.assistant-sidebar__tab,
.assistant-sidebar__empty,
.runtime-picker__menu > strong,
.runtime-picker__menu > button > small,
.message__meta span,
.message__status,
.composer-hint,
.settings-panel__description,
.settings-tabs button small,
.settings-section__title small,
.field small,
.settings-notice,
.settings-empty,
.knowledge-panel__empty,
.knowledge-panel__loading,
.activity-panel__empty,
.heartbeat-center__live,
.heartbeat-center__empty,
.heartbeat-center__legend
) {
color: #8290a6;
}
:root[data-theme='dark'] :where(
.icon-button:hover,
.icon-button--active,
.nav-item:hover,
.conversation-item:hover,
.conversation-item--active,
.user-card:hover,
.secondary-button:hover,
.activity-filter--active,
.settings-tabs button[aria-selected='true'],
.heartbeat-center__tab--active
) {
background: #1f2a3d;
}
:root[data-theme='dark'] .settings-page
.settings-tabs
button[aria-selected='true'] {
background: #1f2a3d;
color: #69adff;
}
:root[data-theme='dark'] :where(
.nav-item--active,
.brand__mark,
.composer__mode--ask,
.composer__mode--plan,
.runtime-capability-badge,
.model-capability-badge,
.heartbeat-center__tab > span
) {
border-color: #245fa8;
background: #15345f;
color: #69adff;
}
:root[data-theme='dark'] :where(
.settings-warning,
.approval-card,
.heartbeat-center__error
) {
border-color: #70511d;
background: #302511;
color: #f3c969;
}
:root[data-theme='dark'] .markdown-content blockquote {
border-left-color: #4c9aff;
color: #aebbd0;
}
:root[data-theme='dark'] .message--user .message__content {
border-color: #245fa8;
background: #15345f;
color: #edf4ff;
}
:root[data-theme='dark'] .heartbeat-center__hero p:not(.eyebrow),
:root[data-theme='dark'] .heartbeat-center__metrics small {
color: #aebbd0;
}
:root[data-theme='dark'] .assistant-sidebar__tab--active {
background: #15345f;
color: #69adff;
}
:root[data-theme='dark'] :where(
.runtime-picker__menu > button,
.quick-actions strong,
.user-card__copy strong
) {
color: #edf4ff;
}
:root[data-theme='dark'] .markdown-content :where(th, td) {
border-color: #344258;
}
:root[data-theme='dark'] .appearance-options label {
border-color: #344258;
background: #172033;
}
:root[data-theme='dark'] .appearance-options label:has(input:checked) {
border-color: #4c9aff;
box-shadow: 0 0 0 2px rgb(76 154 255 / 20%);
}
@container heartbeat-settings (max-width: 680px) {
.heartbeat-settings__form,
.heartbeat-settings__form--weekly {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.heartbeat-settings__form .primary-button {
grid-column: 1 / -1;
}
.heartbeat-settings__item {
align-items: stretch;
flex-direction: column;
}
.heartbeat-settings__actions {
flex-wrap: wrap;
}
}
@container heartbeat-settings (max-width: 420px) {
.heartbeat-settings__form,
.heartbeat-settings__form--weekly {
grid-template-columns: 1fr;
}
.heartbeat-settings__form .primary-button {
grid-column: auto;
}
.heartbeat-settings__actions button {
padding-inline: 8px;
flex: 1 1 auto;
}
}
@container heartbeat-center (max-width: 820px) {
.heartbeat-center__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.heartbeat-center__overview-grid,
.heartbeat-center__suggestions,
.heartbeat-center__history {
grid-template-columns: 1fr;
}
}
@container heartbeat-center (max-width: 620px) {
.heartbeat-center__hero {
flex-direction: column;
}
.heartbeat-center__hero-actions {
width: 100%;
}
.heartbeat-center__hero-actions button {
flex: 1;
}
.heartbeat-center__config-card > div:last-child {
flex-wrap: wrap;
}
}
@container heartbeat-center (max-width: 460px) {
.heartbeat-center__metrics {
grid-template-columns: 1fr;
}
.heartbeat-center__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
}
.heartbeat-center__tab {
justify-content: center;
}
.heartbeat-center__trend-row {
grid-template-columns: 70px minmax(0, 1fr) 18px;
}
}
@keyframes pulse {
0%,
100% {
@@ -3896,14 +4437,6 @@ textarea:focus-visible {
min-width: 206px;
}
.quick-actions {
grid-template-columns: 1fr;
}
.quick-actions button {
min-height: 88px;
}
.assistant-sidebar--open {
width: min(390px, calc(100vw - 36px));
flex-basis: min(390px, calc(100vw - 36px));
@@ -3912,11 +4445,15 @@ textarea:focus-visible {
.settings-page .settings-panel__body {
display: flex;
padding: 20px;
align-items: stretch;
justify-content: flex-start;
}
.settings-page .settings-tabs {
position: static;
display: grid;
width: 100%;
flex: 0 0 auto;
grid-template-columns: repeat(3, 1fr);
}
@@ -3957,6 +4494,10 @@ textarea:focus-visible {
.heartbeat-center__history {
grid-template-columns: 1fr;
}
.appearance-options {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
applyAppearanceTheme,
loadAppearanceTheme,
resolveAppearanceTheme,
saveAppearanceTheme
} from './theme'
describe('appearance theme', () => {
beforeEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
})
it('defaults to system and persists valid user choices', () => {
expect(loadAppearanceTheme()).toBe('system')
saveAppearanceTheme('dark')
expect(loadAppearanceTheme()).toBe('dark')
localStorage.setItem('goodbuddy.appearance-theme', 'invalid')
expect(loadAppearanceTheme()).toBe('system')
})
it('resolves system preference and applies it to the document', () => {
expect(resolveAppearanceTheme('system', true)).toBe('dark')
expect(resolveAppearanceTheme('system', false)).toBe('light')
expect(resolveAppearanceTheme('light', true)).toBe('light')
applyAppearanceTheme('dark')
expect(document.documentElement.dataset.theme).toBe('dark')
expect(document.documentElement.style.colorScheme).toBe('dark')
})
})
+39
View File
@@ -0,0 +1,39 @@
export type AppearanceTheme = 'system' | 'light' | 'dark'
export type ResolvedAppearanceTheme = 'light' | 'dark'
const storageKey = 'goodbuddy.appearance-theme'
export function loadAppearanceTheme(): AppearanceTheme {
try {
const value = localStorage.getItem(storageKey)
return value === 'light' || value === 'dark' ? value : 'system'
} catch {
return 'system'
}
}
export function saveAppearanceTheme(theme: AppearanceTheme): void {
try {
localStorage.setItem(storageKey, theme)
} catch {
// Theme persistence is optional when browser storage is unavailable.
}
}
export function resolveAppearanceTheme(
theme: AppearanceTheme,
systemPrefersDark: boolean
): ResolvedAppearanceTheme {
return theme === 'system'
? systemPrefersDark
? 'dark'
: 'light'
: theme
}
export function applyAppearanceTheme(
theme: ResolvedAppearanceTheme
): void {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
}