From 5946347481c88ddf05c808588dbaf2f132129ac7 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Fri, 25 Sep 2026 08:53:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(buddy):=20=E4=BF=AE=E5=A4=8D=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E5=BC=82=E5=B8=B8=E9=98=BB=E5=A1=9E=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../electron/main/app/DesktopApplication.ts | 1 + .../electron/main/app/DesktopRuntimeHost.ts | 5 +- .../main/app/desktopStartupFailure.ts | 18 +-- apps/buddy/electron/main/desktopNativeI18n.ts | 2 + .../applicationDiagnosticBundle.spec.ts | 10 ++ .../applicationDiagnosticBundle.ts | 4 +- .../electron/main/network/DesktopNetwork.ts | 59 ++++++-- .../electron/main/network/OutboundProxy.ts | 8 +- .../network/__tests__/OutboundProxy.spec.ts | 10 ++ .../main/network/registerWebHostRpc.ts | 4 +- .../__tests__/applicationDiagnostic.spec.ts | 11 ++ .../diagnostics/applicationDiagnostic.ts | 9 +- .../diagnostics/networkStartupFailure.ts | 29 ++++ .../pages/DesktopShortcutsSettingsView.vue | 31 +---- .../widgets/app/DesktopAboutSettings.vue | 15 +-- .../widgets/app/DesktopAppearanceSettings.vue | 31 ++--- .../widgets/app/DesktopApplicationToggle.vue | 16 +-- .../widgets/app/DesktopGeneralSettings.vue | 29 ++-- .../widgets/app/DesktopProxySettings.vue | 18 +-- .../app/__tests__/settingsFeedback.spec.ts | 127 ++++++++++++++++++ .../browser/DesktopBrowserSettings.vue | 13 +- .../runtime/DesktopRuntimeSettings.vue | 14 +- .../widgets/web/DesktopTavilySettings.vue | 15 ++- .../transcript/BuddyChatToolToolbar.vue | 20 +-- .../__tests__/BuddyChatActivities.spec.ts | 5 +- 25 files changed, 335 insertions(+), 169 deletions(-) create mode 100644 apps/buddy/shared/diagnostics/networkStartupFailure.ts create mode 100644 apps/buddy/src/modules/settings/widgets/app/__tests__/settingsFeedback.spec.ts diff --git a/apps/buddy/electron/main/app/DesktopApplication.ts b/apps/buddy/electron/main/app/DesktopApplication.ts index 9ff461d1..cde80375 100644 --- a/apps/buddy/electron/main/app/DesktopApplication.ts +++ b/apps/buddy/electron/main/app/DesktopApplication.ts @@ -135,6 +135,7 @@ class DesktopApplication { try { await this.#dispose() } + catch {} finally { try { if (!this.#environment.isSmokeTest) diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 6ef57b62..34120054 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -16,6 +16,7 @@ import { currentTarget } from '../../../platform/target' import { resolveWindowsPowerShell } from '../../../platform/windows/powerShell' import { automationNotifications } from '../../../shared/automation/automationApi' import { contextPanelRpc, contextPanelSourceSchema } from '../../../shared/context-panel/contextPanel' +import { readDiagnosticError } from '../../../shared/diagnostics/applicationDiagnostic' import { isLinux } from '../../../shared/platform/identifiers' import { runtimePreferencesRpc } from '../../../shared/runtime/runtimePreferences' import { installAttachmentProtocol } from '../attachmentProtocol' @@ -139,6 +140,8 @@ export class DesktopRuntimeHost { this.#config = config this.#network = new DesktopNetwork() await this.#network.start(config.proxy) + if (this.#network.startupError) + environment.events.publish({ event: 'network.start_failed', component: 'desktop.network', level: 'warn', ...readDiagnosticError(this.#network.startupError) }) this.#windowsPowerShell = currentPlatform.shell === 'powershell' ? await resolveWindowsPowerShell() : undefined @@ -162,7 +165,7 @@ export class DesktopRuntimeHost { const source = contextPanelSourceSchema.parse(params) return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness') }), - registerWebHostRpc(peer, this.#network!.authenticateProxy), + registerWebHostRpc(peer, this.#network!.authenticateProxy, this.#network!.assertAvailable), registerSandboxHostRpc(peer, { buddyHome: environment.paths.buddyHome, proxyUrl: this.#network!.sandboxProxyUrl, diff --git a/apps/buddy/electron/main/app/desktopStartupFailure.ts b/apps/buddy/electron/main/app/desktopStartupFailure.ts index e5965d3e..5e787c46 100644 --- a/apps/buddy/electron/main/app/desktopStartupFailure.ts +++ b/apps/buddy/electron/main/app/desktopStartupFailure.ts @@ -25,18 +25,20 @@ export function describeDesktopStartupFailure(error: unknown, language: LexoraCo const unconfirmedPermissions = error instanceof PrivateDirectoryError && (error.failure.acl?.reason === 'unsupported_ace' || error.failure.acl?.principal === 'other') const reason = error instanceof PowerShellUnavailableError ? 'powerShellUnavailable' - : unconfirmedPermissions - ? 'privateDirectoriesUnconfirmed' - : code === 'PRIVATE_DIRECTORIES_UNSAFE' - ? 'privateDirectoriesUnsafe' - : code.startsWith('PRIVATE_DIRECTORIES_') - ? 'privateDirectoriesFailed' - : 'startupFailureHelp' + : code === 'NETWORK_START_FAILED' + ? 'networkStartupFailed' + : unconfirmedPermissions + ? 'privateDirectoriesUnconfirmed' + : code === 'PRIVATE_DIRECTORIES_UNSAFE' + ? 'privateDirectoriesUnsafe' + : code.startsWith('PRIVATE_DIRECTORIES_') + ? 'privateDirectoriesFailed' + : 'startupFailureHelp' const t = (key: Parameters[1]) => translateDesktopNative(language, key) const fields: RecoveryPresentation['recovery']['fields'] = [ { label: t('startupErrorCode'), value: code }, ...(launchId ? [{ label: t('diagnosticReference'), value: launchId }] : []), - ...(failure ? [{ label: t('startupFailureStage'), value: [failure.operation, failure.directoryRole, failure.kind === 'desktop_bootstrap' ? failure.systemCode : undefined].filter(Boolean).join(' / ') }] : []), + ...(failure ? [{ label: t('startupFailureStage'), value: [failure.operation, failure.kind === 'network_startup' ? undefined : failure.directoryRole, failure.kind === 'private_directories' ? undefined : failure.systemCode].filter(Boolean).join(' / ') }] : []), ...(failure?.kind === 'private_directories' && failure.acl ? [{ label: t('startupPermissionCheck'), value: [failure.acl.reason, failure.acl.principal, failure.acl.accessMask === undefined ? undefined : `mask=0x${failure.acl.accessMask.toString(16)}`, failure.acl.aceFlags === undefined ? undefined : `flags=0x${failure.acl.aceFlags.toString(16)}`].filter(Boolean).join(' / ') }] : []), ...(directory ? [{ label: t('affectedDirectory'), value: directory, localOnly: true }] : []), ...(logsPath ? [{ label: t('startupLogsPath'), value: logsPath, localOnly: true }] : []), diff --git a/apps/buddy/electron/main/desktopNativeI18n.ts b/apps/buddy/electron/main/desktopNativeI18n.ts index c37a2820..16da6b53 100644 --- a/apps/buddy/electron/main/desktopNativeI18n.ts +++ b/apps/buddy/electron/main/desktopNativeI18n.ts @@ -10,6 +10,7 @@ const messages = { powerShellUnavailable: 'No usable PowerShell installation was found. Install PowerShell 7 or restore Windows PowerShell, then restart Lexora Buddy.', startupFailed: 'Lexora Buddy could not start', startupFailureHelp: 'Open the logs folder to view the diagnostic information.', + networkStartupFailed: 'The application network could not be initialized. Check the failed step and system error code in the diagnostic bundle.', privateDirectoriesUnsafe: 'An application data directory does not meet the required access permissions or points to another location. Check its permissions and location, then try again.', privateDirectoriesFailed: 'Lexora Buddy could not prepare its application data directories. Check the diagnostic information for the failed operation.', privateDirectoriesUnconfirmed: 'Lexora Buddy cannot confirm the access permissions of this data directory. This does not necessarily mean the directory is unsafe. Keep the diagnostic reference for support; do not remove unfamiliar Windows permissions.', @@ -59,6 +60,7 @@ const messages = { powerShellUnavailable: '未找到可用的 PowerShell。请安装 PowerShell 7 或修复系统自带的 Windows PowerShell,然后重新启动 Lexora Buddy。', startupFailed: 'Lexora Buddy 启动失败', startupFailureHelp: '可以打开日志目录查看诊断信息。', + networkStartupFailed: '应用网络初始化失败,请查看诊断包中的失败步骤与系统错误码。', privateDirectoriesUnsafe: '应用数据目录的访问权限不符合要求,或目录指向了其他位置。请检查目录权限和位置后重试。', privateDirectoriesFailed: '无法准备应用数据目录,请查看诊断信息中的失败操作。', privateDirectoriesUnconfirmed: '暂时无法确认此数据目录的访问权限,并不一定代表目录不安全。请保留诊断编号以便排查,不要自行删除不认识的 Windows 权限。', diff --git a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts index 8a49f65c..af6395ce 100644 --- a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts +++ b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts @@ -25,6 +25,16 @@ function unpack(bytes: Uint8Array) { } describe('application diagnostic bundles', () => { + it('keeps successful startup steps and the launch network failure when exporting a later incident', async () => { + const { reader } = await fixture([ + record(1, { event: 'network.start_failed', runId: undefined, level: 'warn', errorCode: 'NETWORK_START_FAILED', failure: { kind: 'network_startup', operation: 'listen', systemCode: 'UNKNOWN', errno: -4094 } }), + record(2, { timestamp: '2026-09-24T00:02:00.000Z', event: 'component.ready', component: 'runtime.database', level: 'info', runId: undefined, operationId: 'database-setup', errorCode: undefined }), + record(3, { timestamp: '2026-09-24T00:02:01.000Z', event: 'run.failed' }), + ]) + const { context } = unpack((await createApplicationDiagnosticBundle(reader, 'current', { launchId: 'launch-current', sequence: 3 }))!.bytes) + expect(context.map(record => record.sequence)).toEqual([1, 2, 3]) + expect(context[0].failure).toEqual({ kind: 'network_startup', operation: 'listen', systemCode: 'UNKNOWN', errno: -4094 }) + }) it('exports related activity before and after errors without free-form content or unrelated user files', async () => { const { reader } = await fixture([ record(1, { launchId: 'launch-history' }), diff --git a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts index d80f1bd4..a6977c6b 100644 --- a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts +++ b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts @@ -211,7 +211,9 @@ function incidentRecords(records: ApplicationLogRecord[], seed: ApplicationLogRe const times = related.map(record => Date.parse(record.timestamp)) const from = Math.min(...times) - 30_000 const to = Math.max(...times) + 30_000 - return launch.filter(record => linked(record) || (!record.runId && (!record.operationId || /^(?:app|startup|runtime|recorder)\./.test(record.event)) && Date.parse(record.timestamp) >= from && Date.parse(record.timestamp) <= to)) + return launch.filter(record => linked(record) + || record.event === 'network.start_failed' + || (!record.runId && (!record.operationId || /^(?:app|startup|runtime|recorder|component)\./.test(record.event)) && Date.parse(record.timestamp) >= from && Date.parse(record.timestamp) <= to)) } function encodeRecords(records: ApplicationLogRecord[]): Uint8Array { diff --git a/apps/buddy/electron/main/network/DesktopNetwork.ts b/apps/buddy/electron/main/network/DesktopNetwork.ts index 62009575..10c579b8 100644 --- a/apps/buddy/electron/main/network/DesktopNetwork.ts +++ b/apps/buddy/electron/main/network/DesktopNetwork.ts @@ -1,10 +1,14 @@ import type { AuthenticationResponseDetails, AuthInfo, Event, ProxyConfig, Session, WebContents } from 'electron' +import type { NetworkStartupFailure } from '../../../shared/diagnostics/networkStartupFailure' import type { ProxySettings } from '../../../shared/network/proxySettings' import { randomUUID } from 'node:crypto' import { app, session } from 'electron' +import { NetworkStartupError } from '../../../shared/diagnostics/networkStartupFailure' import { OutboundProxy, toElectronProxyConfig } from './OutboundProxy' import { requestThroughHost } from './requestThroughHost' +const UNAVAILABLE_PROXY_URL = 'http://127.0.0.1:0' + export class DesktopNetwork { readonly #resolver = session.fromPartition(`lexora-proxy-resolver:${randomUUID()}`, { cache: false }) readonly #proxy = new OutboundProxy(url => this.#resolve(url)) @@ -13,16 +17,26 @@ export class DesktopNetwork { #sessionConfig: ProxyConfig | null = null readonly #sessionSetup = new Set>() #sessionFailure: unknown + #startupError: NetworkStartupError | null = null + + get startupError(): NetworkStartupError | null { return this.#startupError } + get proxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.url } + get sandboxProxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.sandboxUrl } - get proxyUrl(): string { return this.#proxy.url } - get sandboxProxyUrl(): string { return this.#proxy.sandboxUrl } + readonly assertAvailable = (): void => { + if (this.#startupError) + throw this.#startupError + } - readonly get = (url: string, init?: Pick): Promise => requestThroughHost( - session.defaultSession, - { url, method: 'GET', headers: Object.fromEntries(new Headers(init?.headers)) }, - init?.signal ?? AbortSignal.timeout(30_000), - this.authenticateProxy, - ) + readonly get = async (url: string, init?: Pick): Promise => { + this.assertAvailable() + return requestThroughHost( + session.defaultSession, + { url, method: 'GET', headers: Object.fromEntries(new Headers(init?.headers)) }, + init?.signal ?? AbortSignal.timeout(30_000), + this.authenticateProxy, + ) + } readonly authenticateProxy = (authInfo: AuthInfo, callback: (username?: string, password?: string) => void): boolean => { if (!authInfo.isProxy || authInfo.host !== '127.0.0.1' || authInfo.port !== this.#proxy.port) @@ -32,7 +46,9 @@ export class DesktopNetwork { } readonly #onSessionCreated = (created: Session) => { - if (!this.#sessionConfig || created === this.#resolver) + if (created === this.#resolver) + return + if (!this.#sessionConfig) return const setup = created.setProxy(this.#sessionConfig) this.#sessionSetup.add(setup) @@ -49,15 +65,30 @@ export class DesktopNetwork { } async start(settings: ProxySettings): Promise { - await this.apply(settings) - await this.#proxy.start() - this.#sessionConfig = { mode: 'fixed_servers', proxyRules: this.#proxy.address, proxyBypassRules: '<-loopback>' } + let operation: NetworkStartupFailure['operation'] = 'configure_upstream' + try { + await this.apply(settings) + operation = 'listen' + await this.#proxy.start() + } + catch (cause) { + this.#startupError = new NetworkStartupError(operation, cause) + this.#proxy.disconnect() + } + this.#sessionConfig = { mode: 'fixed_servers', proxyRules: this.#startupError ? this.proxyUrl : this.#proxy.address, proxyBypassRules: '<-loopback>' } app.on('login', this.#onLogin) app.on('session-created', this.#onSessionCreated) - await Promise.all([app.setProxy(this.#sessionConfig), session.defaultSession.setProxy(this.#sessionConfig)]) + try { + await Promise.all([app.setProxy(this.#sessionConfig), session.defaultSession.setProxy(this.#sessionConfig)]) + } + catch (cause) { + throw new NetworkStartupError('configure_sessions', cause) + } } async apply(settings: ProxySettings): Promise { + if (this.#startupError) + return if (settings.mode === this.#settings?.mode && settings.server === this.#settings.server) return const operation = this.#updating.then(async () => { @@ -71,6 +102,8 @@ export class DesktopNetwork { async #resolve(url: string): Promise { await this.#updating + if (this.#startupError) + throw this.#startupError if (this.#sessionFailure) throw this.#sessionFailure return this.#resolver.resolveProxy(url) diff --git a/apps/buddy/electron/main/network/OutboundProxy.ts b/apps/buddy/electron/main/network/OutboundProxy.ts index aeecd178..fab72109 100644 --- a/apps/buddy/electron/main/network/OutboundProxy.ts +++ b/apps/buddy/electron/main/network/OutboundProxy.ts @@ -67,7 +67,13 @@ export class OutboundProxy { async stop(): Promise { this.disconnect() - await this.#server.close(true) + try { + await this.#server.close(true) + } + catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ERR_SERVER_NOT_RUNNING') + throw error + } } } diff --git a/apps/buddy/electron/main/network/__tests__/OutboundProxy.spec.ts b/apps/buddy/electron/main/network/__tests__/OutboundProxy.spec.ts index 126c0c13..5fb0c850 100644 --- a/apps/buddy/electron/main/network/__tests__/OutboundProxy.spec.ts +++ b/apps/buddy/electron/main/network/__tests__/OutboundProxy.spec.ts @@ -42,6 +42,16 @@ function get(proxy: OutboundProxy, url: string, authenticated = true) { } describe('outbound proxy', () => { + it('can clean up before listening and after repeated shutdown', async () => { + const proxy = new OutboundProxy(async () => 'DIRECT') + await expect(proxy.stop()).resolves.toBeUndefined() + await expect(proxy.stop()).resolves.toBeUndefined() + const started = new OutboundProxy(async () => 'DIRECT') + await started.start() + expect(started.port).toBeGreaterThan(0) + await expect(started.stop()).resolves.toBeUndefined() + await expect(started.stop()).resolves.toBeUndefined() + }) it.each([ ['lexora', '[::1]:443', 'https://[::1]:443/'], ['lexora-http', 'fixture.invalid:80', 'http://fixture.invalid:80/'], diff --git a/apps/buddy/electron/main/network/registerWebHostRpc.ts b/apps/buddy/electron/main/network/registerWebHostRpc.ts index 492e8693..989fc3cd 100644 --- a/apps/buddy/electron/main/network/registerWebHostRpc.ts +++ b/apps/buddy/electron/main/network/registerWebHostRpc.ts @@ -8,7 +8,7 @@ import { HostWebNetwork } from './HostWebNetwork' import { requestThroughHost } from './requestThroughHost' import { renderWebDocument } from './WebRenderHost' -export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticateProxy?: ProxyAuthenticator): () => void { +export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticateProxy?: ProxyAuthenticator, assertAvailable?: () => void): () => void { const isolated = session.fromPartition(`buddy-web-network:${randomUUID()}`, { cache: false }) isolated.setPermissionCheckHandler(() => false) isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)) @@ -21,6 +21,7 @@ export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticatePro peer.notify('host.web.chunk', params) } const begin = (id: string, timeout: number) => { + assertAvailable?.() if (disposed || active.has(id) || active.size >= 8) throw new WebError('WEB_BUSY') const controller = new AbortController() @@ -38,6 +39,7 @@ export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticatePro peer.onRequest('host.web.authorize', async (params) => { const input = webRenderInputSchema.pick({ url: true }).parse(params) try { + assertAvailable?.() await network.authorizePublicUrl(input.url) return { ok: true } } diff --git a/apps/buddy/shared/diagnostics/__tests__/applicationDiagnostic.spec.ts b/apps/buddy/shared/diagnostics/__tests__/applicationDiagnostic.spec.ts index 7398bc4b..3df11fbd 100644 --- a/apps/buddy/shared/diagnostics/__tests__/applicationDiagnostic.spec.ts +++ b/apps/buddy/shared/diagnostics/__tests__/applicationDiagnostic.spec.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from 'vitest' import { applicationDiagnosticSchema, readDiagnosticError } from '../applicationDiagnostic' +import { NetworkStartupError } from '../networkStartupFailure' describe('safe diagnostic errors', () => { + it('preserves network startup evidence without inferring a cause or copying endpoint details', () => { + const cause = Object.assign(new Error('listen UNKNOWN fixture-private-endpoint'), { code: 'UNKNOWN', errno: -4094, address: 'fixture-private-endpoint', port: 8080 }) + const error = new NetworkStartupError('listen', cause) + const diagnostic = readDiagnosticError(error) + expect(diagnostic).toEqual({ errorCode: 'NETWORK_START_FAILED', errorType: 'NetworkStartupError', failure: { kind: 'network_startup', operation: 'listen', systemCode: 'UNKNOWN', errno: -4094 } }) + expect(JSON.stringify(diagnostic)).not.toContain('fixture-private') + expect(applicationDiagnosticSchema.safeParse({ event: 'network.start_failed', level: 'warn', ...diagnostic }).success).toBe(true) + expect(applicationDiagnosticSchema.safeParse({ event: 'network.start_failed', level: 'warn', failure: { ...error.failure, address: 'fixture-private-endpoint' } }).success).toBe(false) + expect(new NetworkStartupError('configure_sessions', { code: 'fixture-secret', errno: 'fixture-secret' }).failure).toEqual({ kind: 'network_startup', operation: 'configure_sessions' }) + }) it('preserves bounded ACL evidence but rejects identities, paths and arbitrary failure details', () => { const acl = { reason: 'untrusted_access', aceIndex: 3, aceType: 0, aceFlags: 19, accessMask: 0xFFFFFFFF, principal: 'other' } const failure = { kind: 'private_directories', operation: 'validate_acl', acl } diff --git a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts index dace9732..a18b5eab 100644 --- a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts +++ b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { readLocalChatErrorCode } from '../runtime/localChatError' import { desktopBootstrapFailureSchema, processExitSchema, rendererLoadFailureSchema } from './desktopStartupDiagnostic' +import { networkStartupFailureSchema } from './networkStartupFailure' import { privateDirectoryErrorCodeSchema, privateDirectoryFailureSchema } from './privateDirectoryFailure' import { providerRequestDiagnosticSchema } from './providerRequestDiagnostic' @@ -29,7 +30,7 @@ export const applicationDiagnosticSchema = z.object({ durationMs: z.number().finite().nonnegative().optional(), errorCode: diagnosticCodeSchema.optional(), errorType: z.string().regex(/^[a-z]\w{0,95}$/i).optional(), - failure: z.union([privateDirectoryFailureSchema, desktopBootstrapFailureSchema]).optional(), + failure: z.union([privateDirectoryFailureSchema, desktopBootstrapFailureSchema, networkStartupFailureSchema]).optional(), providerRequest: providerRequestDiagnosticSchema.optional(), recorderLoss: z.object({ dropped: z.number().int().nonnegative(), failed: z.number().int().nonnegative() }).strict().optional(), processExit: processExitSchema.optional(), @@ -71,11 +72,11 @@ export function readDiagnosticError(error: unknown): DiagnosticError { if (!errorCode) { if (privateDirectoryCode.success) errorCode = privateDirectoryCode.data - else if (typeof code === 'string' && ['DESKTOP_BOOTSTRAP_FAILED', 'INITIAL_STATE_UNAVAILABLE', 'POWERSHELL_UNAVAILABLE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC', 'EIO', 'EMFILE', 'ERR_SQLITE_ERROR'].includes(code)) + else if (typeof code === 'string' && ['DESKTOP_BOOTSTRAP_FAILED', 'NETWORK_START_FAILED', 'INITIAL_STATE_UNAVAILABLE', 'POWERSHELL_UNAVAILABLE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC', 'EIO', 'EMFILE', 'ERR_SQLITE_ERROR'].includes(code)) errorCode = code } - if (!failure && (privateDirectoryCode.success || code === 'DESKTOP_BOOTSTRAP_FAILED')) { - const schema = privateDirectoryCode.success ? privateDirectoryFailureSchema : desktopBootstrapFailureSchema + if (!failure && (privateDirectoryCode.success || code === 'DESKTOP_BOOTSTRAP_FAILED' || code === 'NETWORK_START_FAILED')) { + const schema = privateDirectoryCode.success ? privateDirectoryFailureSchema : code === 'NETWORK_START_FAILED' ? networkStartupFailureSchema : desktopBootstrapFailureSchema const parsed = schema.safeParse('failure' in current ? current.failure : undefined) if (parsed.success) failure = parsed.data diff --git a/apps/buddy/shared/diagnostics/networkStartupFailure.ts b/apps/buddy/shared/diagnostics/networkStartupFailure.ts new file mode 100644 index 00000000..dd365cd1 --- /dev/null +++ b/apps/buddy/shared/diagnostics/networkStartupFailure.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +export const networkStartupFailureSchema = z.object({ + kind: z.literal('network_startup'), + operation: z.enum(['configure_upstream', 'listen', 'configure_sessions']), + systemCode: z.enum(['EACCES', 'EPERM', 'EADDRINUSE', 'EADDRNOTAVAIL', 'EAFNOSUPPORT', 'ENETDOWN', 'ENETUNREACH', 'ENOBUFS', 'ENOMEM', 'EMFILE', 'ENFILE', 'EINVAL', 'UNKNOWN']).optional(), + errno: z.number().int().min(-0x80000000).max(0x7FFFFFFF).optional(), +}).strict() + +export type NetworkStartupFailure = z.infer + +export class NetworkStartupError extends Error { + readonly code = 'NETWORK_START_FAILED' + readonly failure: NetworkStartupFailure + + constructor(operation: NetworkStartupFailure['operation'], cause: unknown) { + super('The application network could not be initialized', { cause }) + this.name = 'NetworkStartupError' + const details = cause && typeof cause === 'object' ? cause : {} + const systemCode = networkStartupFailureSchema.shape.systemCode.safeParse('code' in details ? details.code : undefined) + const errno = networkStartupFailureSchema.shape.errno.safeParse('errno' in details ? details.errno : undefined) + this.failure = { + kind: 'network_startup', + operation, + ...(systemCode.success && systemCode.data ? { systemCode: systemCode.data } : {}), + ...(errno.success && errno.data !== undefined ? { errno: errno.data } : {}), + } + } +} diff --git a/apps/buddy/src/modules/settings/pages/DesktopShortcutsSettingsView.vue b/apps/buddy/src/modules/settings/pages/DesktopShortcutsSettingsView.vue index a92df07b..1a914a3e 100644 --- a/apps/buddy/src/modules/settings/pages/DesktopShortcutsSettingsView.vue +++ b/apps/buddy/src/modules/settings/pages/DesktopShortcutsSettingsView.vue @@ -2,7 +2,7 @@ import type { ShortcutEntry } from '@buddy-shared/shortcuts/keybinding' import { conflictingShortcuts, formatKeybinding, keybindingFromInput } from '@buddy-shared/shortcuts/keybinding' import { ArrowCounterclockwise20Regular, Edit20Regular } from '@vicons/fluent' -import { NAlert, NButton, NInput, NModal, NTag, NTooltip } from 'naive-ui' +import { NButton, NInput, NModal, NTag, NTooltip, useMessage } from 'naive-ui' import { computed, nextTick, shallowRef, useTemplateRef } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' @@ -11,6 +11,7 @@ import { useSettingsContext } from '../settingsContext' const { applicationSettings, shortcuts } = useSettingsContext() const { t } = useBuddyI18n(applicationSettings.language) +const message = useMessage() const query = shallowRef('') const editing = shallowRef(null) const restoring = shallowRef(null) @@ -18,9 +19,6 @@ const resetConfirming = shallowRef(false) const candidate = shallowRef('') const invalid = shallowRef(false) const saving = shallowRef(false) -const editFailed = shallowRef(false) -const restoreFailed = shallowRef(false) -const resetFailed = shallowRef(false) const recorder = useTemplateRef>('recorder') const showEditor = computed({ get: () => !!editing.value, set: (value) => { if (!value) @@ -43,7 +41,6 @@ async function edit(entry: ShortcutEntry) { editing.value = entry candidate.value = entry.binding invalid.value = false - editFailed.value = false await nextTick() recorder.value?.focus() } @@ -61,12 +58,11 @@ function capture(event: KeyboardEvent) { } async function save(id: string, binding: string | null) { saving.value = true - editFailed.value = false try { if (await shortcuts.set(id, binding)) editing.value = null else - editFailed.value = true + message.error(t('desktop.shortcuts.saveFailed')) } finally { saving.value = false @@ -83,18 +79,16 @@ async function saveCandidate() { } function confirmRestore(entry: ShortcutEntry) { restoring.value = entry - restoreFailed.value = false } async function restoreShortcut() { if (!restoring.value) return saving.value = true - restoreFailed.value = false try { if (await shortcuts.set(restoring.value.id, null)) restoring.value = null else - restoreFailed.value = true + message.error(t('desktop.shortcuts.saveFailed')) } finally { saving.value = false @@ -102,19 +96,17 @@ async function restoreShortcut() { } async function resetAll() { saving.value = true - resetFailed.value = false try { if (await shortcuts.reset()) resetConfirming.value = false else - resetFailed.value = true + message.error(t('desktop.shortcuts.saveFailed')) } finally { saving.value = false } } function confirmReset() { - resetFailed.value = false resetConfirming.value = true } @@ -134,9 +126,6 @@ function confirmReset() {
- - {{ applicationSettings.settingsError.value }} -
@@ -194,9 +183,6 @@ function confirmReset() {

{{ t('desktop.shortcuts.resetConfirm') }}

- - {{ t('desktop.shortcuts.saveFailed') }} - @@ -114,7 +114,6 @@ async function update(patch: LexoraConfigPatch) { .desktop-browser-settings__copy small { font-size: 0.72rem; line-height: 1.6; color: var(--buddy-text-secondary); } .desktop-browser-settings__clear, .desktop-browser-settings__switch { justify-self: end; } .desktop-browser-settings__hint { margin: 0; font-size: 0.72rem; line-height: 1.6; color: var(--buddy-text-secondary); } -.desktop-browser-settings__error { margin: 0; color: var(--buddy-status-danger-text); font-size: 0.75rem; } @container (max-width: 560px) { .desktop-browser-settings__row { grid-template-columns: minmax(0, 1fr); gap: 0.8rem; } .desktop-browser-settings__clear, .desktop-browser-settings__switch { justify-self: start; } diff --git a/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue b/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue index cafb3c72..05b6454f 100644 --- a/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue +++ b/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue @@ -1,16 +1,16 @@ @@ -57,9 +61,6 @@ async function copy() { - diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts index e44eff1d..be3e96f0 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts +++ b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom import type { ChatAgentToolNode, ChatAgentTurn, ChatAgentTurnNode } from '../../../model/transcript/chatAgentTurn' +import { NMessageProvider } from 'naive-ui' import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, h, nextTick, shallowRef } from 'vue' import { useProvideDesktopUi } from '@/shared/ui/desktopUiContext' @@ -415,10 +416,10 @@ function mountTurn(nodes: ChatAgentTurnNode[], status: ChatAgentTurn['status'] = }) useProvideChatContent({ canPreviewFile: () => false, previewFile: () => {}, writeClipboardText: async () => {} }) const navigation = useChatActivityNavigation() - return () => [ + return () => h(NMessageProvider, null, { default: () => [ h(BuddyChatAgentTurn, { ref: view => navigation.register('run', view), language: 'zh-CN', turn: turn.value }), ...turn.value.status === 'running' ? [h(BuddyChatRunActivity, { language: 'zh-CN', turn: turn.value, onRevealActivity: nodeId => navigation.reveal('run', nodeId) })] : [], - ] + ] }) }, }) app.mount(root)
{{ t('desktop.shortcuts.command') }}{{ t('desktop.shortcuts.binding') }}{{ t('desktop.shortcuts.scope') }}{{ t('desktop.shortcuts.actions') }}