feat: strengthen private runtime and adaptive UI behavior
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@@ -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()
|
||||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 464 KiB |
@@ -43,6 +43,7 @@
|
|||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"opencode-ai": "1.18.9",
|
"opencode-ai": "1.18.9",
|
||||||
"png-to-ico": "^3.0.2",
|
"png-to-ico": "^3.0.2",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"tar": "7.5.22",
|
"tar": "7.5.22",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"dist:linux": "npm run dist:linux:x64 && npm run dist:linux:arm64",
|
"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:x64": "npm run build && electron-builder --linux AppImage deb --x64",
|
||||||
"dist:linux:arm64": "npm run build && electron-builder --linux AppImage deb --arm64",
|
"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"
|
"portable": "npm run build && node build/build-portable.cjs"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
@@ -153,6 +154,7 @@
|
|||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"opencode-ai": "1.18.9",
|
"opencode-ai": "1.18.9",
|
||||||
"png-to-ico": "^3.0.2",
|
"png-to-ico": "^3.0.2",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"tar": "7.5.22",
|
"tar": "7.5.22",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
|
|||||||
@@ -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 () => {
|
it('launches the prepared host through the injected launcher', async () => {
|
||||||
const distribution = await createDistribution()
|
const distribution = await createDistribution()
|
||||||
let launch:
|
let launch:
|
||||||
@@ -266,10 +282,18 @@ describe('ContinueHostAdapter', () => {
|
|||||||
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
|
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
|
||||||
CONTINUE_METRICS_ENABLED: '0',
|
CONTINUE_METRICS_ENABLED: '0',
|
||||||
CONTINUE_GLOBAL_DIR: expect.stringContaining('isolated-global'),
|
CONTINUE_GLOBAL_DIR: expect.stringContaining('isolated-global'),
|
||||||
|
DO_NOT_TRACK: '1',
|
||||||
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1',
|
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1',
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||||
|
OTEL_EXPORTER_OTLP_HEADERS: '',
|
||||||
|
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '',
|
||||||
OTEL_EXPORTER_OTLP_METRICS_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(killed).toBe(true)
|
||||||
expect(JSON.parse(generatedConfig)).toMatchObject({
|
expect(JSON.parse(generatedConfig)).toMatchObject({
|
||||||
@@ -391,4 +415,79 @@ describe('ContinueHostAdapter', () => {
|
|||||||
expect(launchedEnvironment).not.toHaveProperty('OPENAI_API_KEY')
|
expect(launchedEnvironment).not.toHaveProperty('OPENAI_API_KEY')
|
||||||
expect(launchedEnvironment).not.toHaveProperty('ANTHROPIC_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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,10 +29,16 @@ import {
|
|||||||
createContinuePermissionRule
|
createContinuePermissionRule
|
||||||
} from './continue-permissions'
|
} from './continue-permissions'
|
||||||
import { getAvailableLoopbackPort } from './loopback-port'
|
import { getAvailableLoopbackPort } from './loopback-port'
|
||||||
import { buildRuntimeEnvironment } from './process-environment'
|
import {
|
||||||
|
buildRuntimeEnvironment,
|
||||||
|
runtimePrivacyEnvironment
|
||||||
|
} from './process-environment'
|
||||||
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
|
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
|
||||||
import { createOpenAIApiBaseUrl } from './openai-endpoint'
|
import { createOpenAIApiBaseUrl } from './openai-endpoint'
|
||||||
import { safeToolArgumentSummary } from './approval-summary'
|
import {
|
||||||
|
redactSensitiveText,
|
||||||
|
safeToolArgumentSummary
|
||||||
|
} from './approval-summary'
|
||||||
|
|
||||||
const supportedVersion = '1.5.47'
|
const supportedVersion = '1.5.47'
|
||||||
const supportedBundleHashes = new Set([
|
const supportedBundleHashes = new Set([
|
||||||
@@ -40,6 +46,8 @@ const supportedBundleHashes = new Set([
|
|||||||
])
|
])
|
||||||
const maximumBundleBytes = 32 * 1024 * 1024
|
const maximumBundleBytes = 32 * 1024 * 1024
|
||||||
const maximumStateBytes = 8 * 1024 * 1024
|
const maximumStateBytes = 8 * 1024 * 1024
|
||||||
|
export const continueConfigurationRequiredMessage =
|
||||||
|
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
||||||
const utilityBootstrap = [
|
const utilityBootstrap = [
|
||||||
"import { pathToFileURL } from 'node:url'",
|
"import { pathToFileURL } from 'node:url'",
|
||||||
'const entryPath = process.argv[2]',
|
'const entryPath = process.argv[2]',
|
||||||
@@ -115,6 +123,13 @@ export type ContinueHostAdapterOptions = {
|
|||||||
modelProfile?: ResolvedModelProfile
|
modelProfile?: ResolvedModelProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasContinueModelConfiguration(
|
||||||
|
configPath: string,
|
||||||
|
modelProfile?: ResolvedModelProfile
|
||||||
|
): boolean {
|
||||||
|
return Boolean(modelProfile || configPath.trim())
|
||||||
|
}
|
||||||
|
|
||||||
export type ContinueHostChild = {
|
export type ContinueHostChild = {
|
||||||
exitCode: number | null
|
exitCode: number | null
|
||||||
killed: boolean
|
killed: boolean
|
||||||
@@ -229,6 +244,58 @@ function extractAssistantText(
|
|||||||
return ''
|
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 {
|
function subtractTokenCount(completed: number, initial: number): number {
|
||||||
return Math.max(0, completed - initial)
|
return Math.max(0, completed - initial)
|
||||||
}
|
}
|
||||||
@@ -480,6 +547,14 @@ export class ContinueHostAdapter {
|
|||||||
authorize: RuntimeAuthorizer
|
authorize: RuntimeAuthorizer
|
||||||
): Promise<ContinueHostRunResult> {
|
): Promise<ContinueHostRunResult> {
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
|
if (
|
||||||
|
!hasContinueModelConfiguration(
|
||||||
|
this.options.configPath,
|
||||||
|
this.options.modelProfile
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(continueConfigurationRequiredMessage)
|
||||||
|
}
|
||||||
let generatedConfigPath: string | undefined
|
let generatedConfigPath: string | undefined
|
||||||
if (this.options.modelProfile) {
|
if (this.options.modelProfile) {
|
||||||
if (
|
if (
|
||||||
@@ -547,6 +622,7 @@ export class ContinueHostAdapter {
|
|||||||
}
|
}
|
||||||
args.push('serve', '--port', String(port), '--timeout', '300')
|
args.push('serve', '--port', String(port), '--timeout', '300')
|
||||||
const environment = buildRuntimeEnvironment({
|
const environment = buildRuntimeEnvironment({
|
||||||
|
...runtimePrivacyEnvironment,
|
||||||
CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1',
|
CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1',
|
||||||
CONTINUE_CLI_AUTO_UPDATED: '1',
|
CONTINUE_CLI_AUTO_UPDATED: '1',
|
||||||
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
|
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
|
||||||
@@ -554,12 +630,7 @@ export class ContinueHostAdapter {
|
|||||||
CONTINUE_GLOBAL_DIR: isolatedGlobalDirectory,
|
CONTINUE_GLOBAL_DIR: isolatedGlobalDirectory,
|
||||||
FORCE_NO_TTY: '1',
|
FORCE_NO_TTY: '1',
|
||||||
GOODBUDDY_CONTINUE_HOST_TOKEN: token,
|
GOODBUDDY_CONTINUE_HOST_TOKEN: token,
|
||||||
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1',
|
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'
|
|
||||||
})
|
})
|
||||||
if (this.options.modelProfile) {
|
if (this.options.modelProfile) {
|
||||||
delete environment.ANTHROPIC_API_KEY
|
delete environment.ANTHROPIC_API_KEY
|
||||||
@@ -699,6 +770,13 @@ export class ContinueHostAdapter {
|
|||||||
!state.pendingPermission &&
|
!state.pendingPermission &&
|
||||||
state.session.history.length > startIndex
|
state.session.history.length > startIndex
|
||||||
) {
|
) {
|
||||||
|
const failure = extractContinueFailure(
|
||||||
|
state.session.history,
|
||||||
|
startIndex
|
||||||
|
)
|
||||||
|
if (failure) {
|
||||||
|
throw new Error(failure)
|
||||||
|
}
|
||||||
const text = extractAssistantText(
|
const text = extractAssistantText(
|
||||||
state.session.history,
|
state.session.history,
|
||||||
startIndex
|
startIndex
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ describe('ContinueAgentRuntime', () => {
|
|||||||
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
||||||
const runtime = new ContinueAgentRuntime({
|
const runtime = new ContinueAgentRuntime({
|
||||||
binaryPath: '',
|
binaryPath: '',
|
||||||
configPath: '',
|
configPath: 'C:\\safe config\\continue.yaml',
|
||||||
mode: 'chat',
|
mode: 'chat',
|
||||||
defaultWorkspace: process.cwd(),
|
defaultWorkspace: process.cwd(),
|
||||||
hostCacheRoot: 'C:\\safe\\continue-host',
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
||||||
@@ -172,6 +172,39 @@ describe('ContinueAgentRuntime', () => {
|
|||||||
expect(prompt).toContain('test')
|
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 () => {
|
it('places the current request before untrusted conversation history', async () => {
|
||||||
const runtime = createRuntime()
|
const runtime = createRuntime()
|
||||||
for await (const _event of runtime.run(
|
for await (const _event of runtime.run(
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { detectRuntimeBinary } from './runtime-discovery'
|
|||||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||||
import {
|
import {
|
||||||
ContinueHostAdapter,
|
ContinueHostAdapter,
|
||||||
|
continueConfigurationRequiredMessage,
|
||||||
|
hasContinueModelConfiguration,
|
||||||
type ContinueHostAdapterOptions,
|
type ContinueHostAdapterOptions,
|
||||||
type ContinueHostLauncher
|
type ContinueHostLauncher
|
||||||
} from './continue-host-adapter'
|
} from './continue-host-adapter'
|
||||||
@@ -137,6 +139,20 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
'Continue 宿主暂不支持严格 OS 沙箱,请改用自动模式或嵌入式 OpenCode'
|
'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()
|
const detection = await this.getDetection()
|
||||||
if (detection.available && detection.path) {
|
if (detection.available && detection.path) {
|
||||||
try {
|
try {
|
||||||
@@ -179,6 +195,14 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
if (request.images?.length) {
|
if (request.images?.length) {
|
||||||
throw new Error('Continue Runtime 暂不支持图片上下文,请切换到视觉模型')
|
throw new Error('Continue Runtime 暂不支持图片上下文,请切换到视觉模型')
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!hasContinueModelConfiguration(
|
||||||
|
this.options.configPath,
|
||||||
|
this.options.modelProfile
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(continueConfigurationRequiredMessage)
|
||||||
|
}
|
||||||
const prompt = buildContinuePrompt(request)
|
const prompt = buildContinuePrompt(request)
|
||||||
const skillPrefix = this.options.skillInstructions
|
const skillPrefix = this.options.skillInstructions
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -439,6 +439,10 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
OPENCODE_DISABLE_MODELS_FETCH: '1',
|
OPENCODE_DISABLE_MODELS_FETCH: '1',
|
||||||
OPENCODE_DISABLE_SHARE: '1',
|
OPENCODE_DISABLE_SHARE: '1',
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
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'
|
OTEL_SDK_DISABLED: 'true'
|
||||||
})
|
})
|
||||||
await runtime.dispose()
|
await runtime.dispose()
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import { detectRuntimeBinary } from './runtime-discovery'
|
|||||||
import { getAvailableLoopbackPort } from './loopback-port'
|
import { getAvailableLoopbackPort } from './loopback-port'
|
||||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||||
import { buildRuntimeEnvironment } from './process-environment'
|
import {
|
||||||
|
buildRuntimeEnvironment,
|
||||||
|
runtimePrivacyEnvironment
|
||||||
|
} from './process-environment'
|
||||||
import {
|
import {
|
||||||
buildBubblewrapLaunch,
|
buildBubblewrapLaunch,
|
||||||
type RuntimeSandboxResolution
|
type RuntimeSandboxResolution
|
||||||
@@ -366,7 +369,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
throw new Error('OpenCode Server 启动已取消')
|
throw new Error('OpenCode Server 启动已取消')
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = buildRuntimeEnvironment({})
|
const env = buildRuntimeEnvironment(runtimePrivacyEnvironment)
|
||||||
if (this.options.modelProfile && !this.options.modelProfile.apiKey) {
|
if (this.options.modelProfile && !this.options.modelProfile.apiKey) {
|
||||||
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
|
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
|
||||||
}
|
}
|
||||||
@@ -380,18 +383,11 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
).toString('base64')}`
|
).toString('base64')}`
|
||||||
env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME
|
env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME
|
||||||
env.OPENCODE_SERVER_PASSWORD = serverPassword
|
env.OPENCODE_SERVER_PASSWORD = serverPassword
|
||||||
env.DO_NOT_TRACK = '1'
|
|
||||||
env.OPENCODE_DISABLE_AUTOUPDATE = '1'
|
env.OPENCODE_DISABLE_AUTOUPDATE = '1'
|
||||||
env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1'
|
env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1'
|
||||||
env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1'
|
env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1'
|
||||||
env.OPENCODE_DISABLE_MODELS_FETCH = '1'
|
env.OPENCODE_DISABLE_MODELS_FETCH = '1'
|
||||||
env.OPENCODE_DISABLE_SHARE = '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) {
|
if (this.options.modelProfile) {
|
||||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
||||||
model: `anthropic/${this.options.modelProfile.modelName}`,
|
model: `anthropic/${this.options.modelProfile.modelName}`,
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ const runtimeEnvironmentAllowlist = [
|
|||||||
'COHERE_API_KEY'
|
'COHERE_API_KEY'
|
||||||
] as const
|
] 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(
|
export function buildRuntimeEnvironment(
|
||||||
overrides: NodeJS.ProcessEnv,
|
overrides: NodeJS.ProcessEnv,
|
||||||
source: NodeJS.ProcessEnv = process.env
|
source: NodeJS.ProcessEnv = process.env
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
).rejects.toThrow('请重新输入或清除')
|
).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()
|
const { store } = await createStore()
|
||||||
await store.update(
|
await store.update(
|
||||||
settings({
|
settings({
|
||||||
@@ -130,28 +130,28 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||||
modelBaseUrl: 'https://bigtoken.ai/v1',
|
modelBaseUrl: 'https://bigtoken.ai',
|
||||||
modelName: 'gpt-image-2',
|
modelName: 'gpt-image-2',
|
||||||
modelProtocol: 'openai-images-generations',
|
modelProtocol: 'anthropic-messages',
|
||||||
apiKeyConfigured: true,
|
apiKeyConfigured: true,
|
||||||
credentialSource: 'encrypted',
|
credentialSource: 'encrypted',
|
||||||
modelProfiles: [
|
modelProfiles: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
baseUrl: 'https://bigtoken.ai/v1',
|
baseUrl: 'https://bigtoken.ai',
|
||||||
modelName: 'gpt-image-2',
|
modelName: 'gpt-image-2',
|
||||||
protocol: 'openai-images-generations'
|
protocol: 'anthropic-messages'
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||||
modelBaseUrl: 'https://bigtoken.ai/v1',
|
modelBaseUrl: 'https://bigtoken.ai',
|
||||||
modelName: 'gpt-image-2',
|
modelName: 'gpt-image-2',
|
||||||
modelProtocol: 'openai-images-generations',
|
modelProtocol: 'anthropic-messages',
|
||||||
apiKey: 'image-secret'
|
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 { store } = await createStore()
|
||||||
const chatId = crypto.randomUUID()
|
const chatId = crypto.randomUUID()
|
||||||
const imageId = crypto.randomUUID()
|
const imageId = crypto.randomUUID()
|
||||||
@@ -170,34 +170,36 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
{
|
{
|
||||||
id: imageId,
|
id: imageId,
|
||||||
name: 'Custom Image',
|
name: 'Custom Image',
|
||||||
baseUrl: 'https://images.example',
|
baseUrl: 'https://images.example/custom/v2',
|
||||||
modelName: 'gpt-image-custom',
|
modelName: 'vendor/custom-renderer',
|
||||||
protocol: 'anthropic-messages',
|
protocol: 'openai-images-generations',
|
||||||
authentication: 'api-key',
|
authentication: 'api-key',
|
||||||
apiKey: { action: 'replace', value: 'image-secret' }
|
apiKey: { action: 'replace', value: 'image-secret' }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
defaultModelProfileId: chatId,
|
defaultModelProfileId: imageId
|
||||||
continueModelSource: { kind: 'profile', profileId: imageId }
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||||
|
modelBaseUrl: 'https://images.example/custom/v2',
|
||||||
|
modelName: 'vendor/custom-renderer',
|
||||||
|
modelProtocol: 'openai-images-generations',
|
||||||
modelProfiles: [
|
modelProfiles: [
|
||||||
expect.objectContaining({ id: chatId }),
|
expect.objectContaining({ id: chatId }),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: imageId,
|
id: imageId,
|
||||||
baseUrl: 'https://images.example',
|
baseUrl: 'https://images.example/custom/v2',
|
||||||
|
modelName: 'vendor/custom-renderer',
|
||||||
protocol: 'openai-images-generations'
|
protocol: 'openai-images-generations'
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||||
continueModelProfile: {
|
modelBaseUrl: 'https://images.example/custom/v2',
|
||||||
id: imageId,
|
modelName: 'vendor/custom-renderer',
|
||||||
baseUrl: 'https://images.example',
|
modelProtocol: 'openai-images-generations',
|
||||||
protocol: 'openai-images-generations'
|
apiKey: 'image-secret'
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -277,31 +277,6 @@ function normalizeModelBaseUrl(value: string): string {
|
|||||||
return url.toString().replace(/\/$/u, '')
|
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 {
|
export class RuntimeSettingsStore {
|
||||||
private settings?: StoredSettings
|
private settings?: StoredSettings
|
||||||
private loadWarning?: string
|
private loadWarning?: string
|
||||||
@@ -475,16 +450,11 @@ export class RuntimeSettingsStore {
|
|||||||
const model = environmentApiKey
|
const model = environmentApiKey
|
||||||
? environmentModel || defaultRuntimeSettings.modelName
|
? environmentModel || defaultRuntimeSettings.modelName
|
||||||
: profile.modelName
|
: profile.modelName
|
||||||
const effectiveConnection = normalizeEffectiveModelConnection(
|
|
||||||
baseUrl,
|
|
||||||
model,
|
|
||||||
profile.protocol
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
apiKey: environmentApiKey ?? storedApiKey,
|
apiKey: environmentApiKey ?? storedApiKey,
|
||||||
baseUrl: effectiveConnection.baseUrl,
|
baseUrl,
|
||||||
model,
|
model,
|
||||||
protocol: effectiveConnection.protocol,
|
protocol: profile.protocol,
|
||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
credentialSource: environmentApiKey
|
credentialSource: environmentApiKey
|
||||||
? 'environment'
|
? 'environment'
|
||||||
@@ -516,17 +486,12 @@ export class RuntimeSettingsStore {
|
|||||||
apiKey: effective.apiKey
|
apiKey: effective.apiKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const connection = normalizeEffectiveModelConnection(
|
|
||||||
profile.baseUrl,
|
|
||||||
profile.modelName,
|
|
||||||
profile.protocol
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
baseUrl: connection.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
modelName: profile.modelName,
|
modelName: profile.modelName,
|
||||||
protocol: connection.protocol,
|
protocol: profile.protocol,
|
||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
apiKey:
|
apiKey:
|
||||||
profile.authentication === 'api-key'
|
profile.authentication === 'api-key'
|
||||||
@@ -589,13 +554,6 @@ export class RuntimeSettingsStore {
|
|||||||
const agent = this.resolveAgentSettings(settings)
|
const agent = this.resolveAgentSettings(settings)
|
||||||
const modelProfiles = settings.modelProfiles.map((profile) => {
|
const modelProfiles = settings.modelProfiles.map((profile) => {
|
||||||
const isDefault = profile.id === settings.defaultModelProfileId
|
const isDefault = profile.id === settings.defaultModelProfileId
|
||||||
const connection = isDefault
|
|
||||||
? undefined
|
|
||||||
: normalizeEffectiveModelConnection(
|
|
||||||
profile.baseUrl,
|
|
||||||
profile.modelName,
|
|
||||||
profile.protocol
|
|
||||||
)
|
|
||||||
const apiKey =
|
const apiKey =
|
||||||
profile.authentication === 'api-key'
|
profile.authentication === 'api-key'
|
||||||
? this.getStoredApiKey(profile)
|
? this.getStoredApiKey(profile)
|
||||||
@@ -605,11 +563,11 @@ export class RuntimeSettingsStore {
|
|||||||
name: profile.name,
|
name: profile.name,
|
||||||
baseUrl: isDefault
|
baseUrl: isDefault
|
||||||
? effective.baseUrl
|
? effective.baseUrl
|
||||||
: (connection?.baseUrl ?? profile.baseUrl),
|
: profile.baseUrl,
|
||||||
modelName: isDefault ? effective.model : profile.modelName,
|
modelName: isDefault ? effective.model : profile.modelName,
|
||||||
protocol: isDefault
|
protocol: isDefault
|
||||||
? effective.protocol
|
? effective.protocol
|
||||||
: (connection?.protocol ?? profile.protocol),
|
: profile.protocol,
|
||||||
authentication: isDefault
|
authentication: isDefault
|
||||||
? effective.authentication
|
? effective.authentication
|
||||||
: profile.authentication,
|
: profile.authentication,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { AgentEvent, DesktopApi } from '../../shared/contracts'
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
let agentListener: ((event: AgentEvent) => void) | undefined
|
let agentListener: ((event: AgentEvent) => void) | undefined
|
||||||
|
let newConversationListener: (() => void) | undefined
|
||||||
const run = vi.fn<DesktopApi['agent']['run']>()
|
const run = vi.fn<DesktopApi['agent']['run']>()
|
||||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
||||||
const projectId = '00000000-0000-4000-8000-000000000101'
|
const projectId = '00000000-0000-4000-8000-000000000101'
|
||||||
@@ -38,7 +39,12 @@ const api: DesktopApi = {
|
|||||||
show: vi.fn(async () => {}),
|
show: vi.fn(async () => {}),
|
||||||
hide: vi.fn(async () => {}),
|
hide: vi.fn(async () => {}),
|
||||||
clearLocalData: vi.fn(async () => {}),
|
clearLocalData: vi.fn(async () => {}),
|
||||||
onNewConversation: vi.fn(() => () => {}),
|
onNewConversation: vi.fn((listener) => {
|
||||||
|
newConversationListener = listener
|
||||||
|
return () => {
|
||||||
|
newConversationListener = undefined
|
||||||
|
}
|
||||||
|
}),
|
||||||
onOpenSettings: vi.fn(() => () => {})
|
onOpenSettings: vi.fn(() => () => {})
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
@@ -380,7 +386,10 @@ const api: DesktopApi = {
|
|||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
delete document.documentElement.dataset.theme
|
||||||
|
document.documentElement.style.colorScheme = ''
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
newConversationListener = undefined
|
||||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||||
id: 'model',
|
id: 'model',
|
||||||
label: 'sonnet-5',
|
label: 'sonnet-5',
|
||||||
@@ -434,6 +443,93 @@ describe('App', () => {
|
|||||||
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
|
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 () => {
|
it('loads token usage in activity and refreshes it when a run finishes', async () => {
|
||||||
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
|
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
|
||||||
totals: {
|
totals: {
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ import {
|
|||||||
type SidebarArtifact
|
type SidebarArtifact
|
||||||
} from './RightAssistantSidebar'
|
} from './RightAssistantSidebar'
|
||||||
import { SettingsPanel } from './SettingsPanel'
|
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 = {
|
type ToolActivity = {
|
||||||
callId?: string
|
callId?: string
|
||||||
@@ -454,6 +463,17 @@ function App(): React.JSX.Element {
|
|||||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
||||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
||||||
const [runtimeSwitching, setRuntimeSwitching] = 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 =
|
const effectiveWorkMode =
|
||||||
workMode === 'execute' && runtime?.supportsToolExecution === false
|
workMode === 'execute' && runtime?.supportsToolExecution === false
|
||||||
? 'ask'
|
? 'ask'
|
||||||
@@ -495,6 +515,47 @@ function App(): React.JSX.Element {
|
|||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
if (typeof window.matchMedia !== 'function') {
|
if (typeof window.matchMedia !== 'function') {
|
||||||
return
|
return
|
||||||
@@ -1352,18 +1413,9 @@ function App(): React.JSX.Element {
|
|||||||
window.goodbuddy.agent.onEvent(handleAgentEvent)
|
window.goodbuddy.agent.onEvent(handleAgentEvent)
|
||||||
const removeNewConversationListener =
|
const removeNewConversationListener =
|
||||||
window.goodbuddy.app.onNewConversation(() => {
|
window.goodbuddy.app.onNewConversation(() => {
|
||||||
const conversation = createConversation(
|
startNewConversation(
|
||||||
activeProjectIdRef.current || undefined
|
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 =
|
const removeOpenSettingsListener =
|
||||||
window.goodbuddy.app.onOpenSettings(() => setView('settings'))
|
window.goodbuddy.app.onOpenSettings(() => setView('settings'))
|
||||||
@@ -1372,7 +1424,7 @@ function App(): React.JSX.Element {
|
|||||||
removeNewConversationListener()
|
removeNewConversationListener()
|
||||||
removeOpenSettingsListener()
|
removeOpenSettingsListener()
|
||||||
}
|
}
|
||||||
}, [handleAgentEvent])
|
}, [handleAgentEvent, startNewConversation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
@@ -1429,16 +1481,7 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newConversation = (): void => {
|
const newConversation = (): void => {
|
||||||
const conversation = createConversation(activeProjectId || undefined)
|
startNewConversation(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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setMemoryStatus = async (
|
const setMemoryStatus = async (
|
||||||
@@ -1576,8 +1619,11 @@ function App(): React.JSX.Element {
|
|||||||
if (!prompt || !activeConversation) {
|
if (!prompt || !activeConversation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!runtime?.available) {
|
if (!runtime) {
|
||||||
setView('settings')
|
setNotice('Agent Runtime 正在加载,请稍后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!runtime.available) {
|
||||||
setNotice('请先配置可用的模型或 Agent Runtime')
|
setNotice('请先配置可用的模型或 Agent Runtime')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1980,7 +2026,15 @@ function App(): React.JSX.Element {
|
|||||||
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
|
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand__mark">
|
<div className="brand__mark">
|
||||||
<Bot size={20} strokeWidth={2.4} />
|
<img
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
src={
|
||||||
|
resolvedAppearanceTheme === 'dark'
|
||||||
|
? goodbuddyDarkIcon
|
||||||
|
: goodbuddyLightIcon
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="brand__copy">
|
<div className="brand__copy">
|
||||||
<strong>GoodBuddy</strong>
|
<strong>GoodBuddy</strong>
|
||||||
@@ -2233,7 +2287,9 @@ function App(): React.JSX.Element {
|
|||||||
title={runtime?.detail}
|
title={runtime?.detail}
|
||||||
>
|
>
|
||||||
<span className="runtime-status__dot" />
|
<span className="runtime-status__dot" />
|
||||||
{runtime?.label ?? '正在检测运行时'}
|
<span className="runtime-status__label">
|
||||||
|
{runtime?.label ?? '正在检测运行时'}
|
||||||
|
</span>
|
||||||
{runtime?.capability === 'image-generation' && (
|
{runtime?.capability === 'image-generation' && (
|
||||||
<span className="runtime-capability-badge">生图</span>
|
<span className="runtime-capability-badge">生图</span>
|
||||||
)}
|
)}
|
||||||
@@ -3018,7 +3074,7 @@ function App(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : view === 'heartbeat' ? (
|
) : view === 'heartbeat' ? (
|
||||||
<div className="workspace-panel-scroll">
|
<div className="workspace-panel-scroll workspace-panel-scroll--heartbeat">
|
||||||
<HeartbeatCenter
|
<HeartbeatCenter
|
||||||
configs={assistantHeartbeats}
|
configs={assistantHeartbeats}
|
||||||
entries={heartbeatEntries}
|
entries={heartbeatEntries}
|
||||||
@@ -3037,7 +3093,9 @@ function App(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
) : view === 'settings' ? (
|
) : view === 'settings' ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
appearanceTheme={appearanceTheme}
|
||||||
heartbeats={assistantHeartbeats}
|
heartbeats={assistantHeartbeats}
|
||||||
|
onAppearanceThemeChange={setAppearanceTheme}
|
||||||
onClearLocalData={clearLocalData}
|
onClearLocalData={clearLocalData}
|
||||||
onClose={() => setView('chat')}
|
onClose={() => setView('chat')}
|
||||||
onCreateHeartbeat={createHeartbeat}
|
onCreateHeartbeat={createHeartbeat}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const ready = true
|
|||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('region', { name: '表格,可横向滚动' })
|
||||||
|
).toContainElement(screen.getByRole('table'))
|
||||||
expect(screen.getByText('const ready = true')).toBeInTheDocument()
|
expect(screen.getByText('const ready = true')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ const components: Components = {
|
|||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
table: ({ children, node, ...properties }) => {
|
||||||
|
void node
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label="表格,可横向滚动"
|
||||||
|
className="markdown-table-scroll"
|
||||||
|
role="region"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<table {...properties}>{children}</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,28 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
cleanup()
|
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 () => {
|
it('automatically detects runtimes and displays path, version, and detail', async () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
@@ -230,6 +252,9 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText(/仅在实际请求高风险工具时暂停/)
|
screen.getByText(/仅在实际请求高风险工具时暂停/)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/不会匿名加载远程默认模型/)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
|
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
|
||||||
expect(input).toHaveValue('')
|
expect(input).toHaveValue('')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
Plus,
|
Plus,
|
||||||
|
SunMoon,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
Trash2,
|
Trash2,
|
||||||
X
|
X
|
||||||
@@ -28,8 +29,10 @@ import {
|
|||||||
import { McpSettingsSection } from './McpSettingsSection'
|
import { McpSettingsSection } from './McpSettingsSection'
|
||||||
import { SkillsSettingsSection } from './SkillsSettingsSection'
|
import { SkillsSettingsSection } from './SkillsSettingsSection'
|
||||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||||
|
import type { AppearanceTheme } from './theme'
|
||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab =
|
||||||
|
| 'appearance'
|
||||||
| 'model'
|
| 'model'
|
||||||
| 'runtime'
|
| 'runtime'
|
||||||
| 'security'
|
| 'security'
|
||||||
@@ -55,6 +58,8 @@ type SettingsPanelProps = {
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
|
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
|
||||||
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
||||||
|
appearanceTheme?: AppearanceTheme
|
||||||
|
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentialLabels: Record<
|
const credentialLabels: Record<
|
||||||
@@ -86,7 +91,9 @@ export function SettingsPanel({
|
|||||||
onCreateHeartbeat,
|
onCreateHeartbeat,
|
||||||
onSetHeartbeatPaused,
|
onSetHeartbeatPaused,
|
||||||
onRemoveHeartbeat,
|
onRemoveHeartbeat,
|
||||||
onRunHeartbeat
|
onRunHeartbeat,
|
||||||
|
appearanceTheme = 'system',
|
||||||
|
onAppearanceThemeChange = () => {}
|
||||||
}: SettingsPanelProps): React.JSX.Element | null {
|
}: SettingsPanelProps): React.JSX.Element | null {
|
||||||
const [settings, setSettings] = useState<RuntimeSettings>()
|
const [settings, setSettings] = useState<RuntimeSettings>()
|
||||||
const [provider, setProvider] =
|
const [provider, setProvider] =
|
||||||
@@ -517,6 +524,16 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
<div className="settings-panel__body">
|
<div className="settings-panel__body">
|
||||||
<nav aria-label="设置分类" className="settings-tabs">
|
<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
|
<button
|
||||||
aria-label="模型连接"
|
aria-label="模型连接"
|
||||||
aria-selected={activeTab === 'model'}
|
aria-selected={activeTab === 'model'}
|
||||||
@@ -580,6 +597,50 @@ export function SettingsPanel({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="settings-panel__content">
|
<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' && (
|
{activeTab === 'runtime' && (
|
||||||
<>
|
<>
|
||||||
{settings?.warning && (
|
{settings?.warning && (
|
||||||
@@ -827,7 +888,7 @@ export function SettingsPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="platform">使用 Continue 平台默认</option>
|
<option value="platform">使用指定的 Continue 配置文件</option>
|
||||||
{modelProfiles.map((profile) => (
|
{modelProfiles.map((profile) => (
|
||||||
<option
|
<option
|
||||||
disabled={!isContinueCompatible(profile)}
|
disabled={!isContinueCompatible(profile)}
|
||||||
@@ -843,7 +904,7 @@ export function SettingsPanel({
|
|||||||
</select>
|
</select>
|
||||||
<small>
|
<small>
|
||||||
Continue 支持 Anthropic Messages、OpenAI Chat
|
Continue 支持 Anthropic Messages、OpenAI Chat
|
||||||
Completions 和无认证本机模型。
|
Completions 和无认证本机模型。未选择独立连接时,必须在下方指定配置文件。
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -887,7 +948,7 @@ export function SettingsPanel({
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setContinueConfigPath(event.target.value)
|
setContinueConfigPath(event.target.value)
|
||||||
}
|
}
|
||||||
placeholder="留空使用工具默认配置"
|
placeholder="选择可信的本地 Continue 配置文件"
|
||||||
value={continueConfigPath}
|
value={continueConfigPath}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -912,6 +973,12 @@ export function SettingsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
{continueModelSource.kind === 'platform' &&
|
||||||
|
!continueConfigPath && (
|
||||||
|
<p className="settings-warning">
|
||||||
|
未指定配置文件时 Continue 将保持不可用,不会匿名加载远程默认模型。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{continueBinaryPath && (
|
{continueBinaryPath && (
|
||||||
<p className="settings-warning">
|
<p className="settings-warning">
|
||||||
自定义 Continue 可执行文件将以当前用户权限运行,请仅选择可信文件。
|
自定义 Continue 可执行文件将以当前用户权限运行,请仅选择可信文件。
|
||||||
|
|||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,6 +1,11 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import {
|
||||||
|
applyAppearanceTheme,
|
||||||
|
loadAppearanceTheme,
|
||||||
|
resolveAppearanceTheme
|
||||||
|
} from './theme'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
const root = document.getElementById('root')
|
const root = document.getElementById('root')
|
||||||
@@ -9,6 +14,14 @@ if (!root) {
|
|||||||
throw new Error('Root element not found')
|
throw new Error('Root element not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyAppearanceTheme(
|
||||||
|
resolveAppearanceTheme(
|
||||||
|
loadAppearanceTheme(),
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ textarea:focus-visible {
|
|||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand__mark img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 7px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.brand__copy {
|
.brand__copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -439,10 +446,12 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 21px;
|
padding: 0 21px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
@@ -467,6 +476,8 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar__expert {
|
.topbar__expert {
|
||||||
|
width: clamp(86px, 11vw, 130px);
|
||||||
|
min-width: 0;
|
||||||
max-width: 130px;
|
max-width: 130px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid #d9d9d9;
|
||||||
@@ -821,6 +832,7 @@ textarea:focus-visible {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
|
container: heartbeat-settings / inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heartbeat-settings__intro h3 {
|
.heartbeat-settings__intro h3 {
|
||||||
@@ -1045,6 +1057,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.conversation-title {
|
.conversation-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1057,6 +1070,16 @@ textarea:focus-visible {
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-title span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-title svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-title:hover {
|
.conversation-title:hover {
|
||||||
background: #e6f4ff;
|
background: #e6f4ff;
|
||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
@@ -1064,13 +1087,17 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.topbar__actions {
|
.topbar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex: 0 1 auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-status {
|
.runtime-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: clamp(108px, 18vw, 220px);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid #d9d9d9;
|
||||||
@@ -1082,6 +1109,16 @@ textarea:focus-visible {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-status__dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-status__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.runtime-status__dot {
|
.runtime-status__dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -1149,7 +1186,10 @@ textarea:focus-visible {
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(
|
||||||
|
auto-fit,
|
||||||
|
minmax(min(180px, 100%), 1fr)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions button {
|
.quick-actions button {
|
||||||
@@ -1349,10 +1389,24 @@ textarea:focus-visible {
|
|||||||
font-size: 0.9em;
|
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 {
|
.markdown-content table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: max-content;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 0.8em 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1870,7 +1924,7 @@ textarea:focus-visible {
|
|||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tabs button {
|
.settings-tabs button {
|
||||||
@@ -2561,6 +2615,10 @@ textarea:focus-visible {
|
|||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-panel-scroll--heartbeat {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
.knowledge-workspace {
|
.knowledge-workspace {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: max(520px, calc(100dvh - 114px));
|
min-height: max(520px, calc(100dvh - 114px));
|
||||||
@@ -3074,6 +3132,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.activity-panel__filters {
|
.activity-panel__filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -3109,6 +3168,8 @@ textarea:focus-visible {
|
|||||||
.activity-item__header,
|
.activity-item__header,
|
||||||
.activity-item__labels {
|
.activity-item__labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
@@ -3159,6 +3220,7 @@ textarea:focus-visible {
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
container: heartbeat-center / inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heartbeat-center__hero {
|
.heartbeat-center__hero {
|
||||||
@@ -3857,6 +3919,485 @@ textarea:focus-visible {
|
|||||||
background: #fff;
|
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 {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@@ -3896,14 +4437,6 @@ textarea:focus-visible {
|
|||||||
min-width: 206px;
|
min-width: 206px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-actions button {
|
|
||||||
min-height: 88px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-sidebar--open {
|
.assistant-sidebar--open {
|
||||||
width: min(390px, calc(100vw - 36px));
|
width: min(390px, calc(100vw - 36px));
|
||||||
flex-basis: min(390px, calc(100vw - 36px));
|
flex-basis: min(390px, calc(100vw - 36px));
|
||||||
@@ -3912,11 +4445,15 @@ textarea:focus-visible {
|
|||||||
.settings-page .settings-panel__body {
|
.settings-page .settings-panel__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-page .settings-tabs {
|
.settings-page .settings-tabs {
|
||||||
position: static;
|
position: static;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3957,6 +4494,10 @@ textarea:focus-visible {
|
|||||||
.heartbeat-center__history {
|
.heartbeat-center__history {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appearance-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||