diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index d9dd8cf5..7cf9763e 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -1,11 +1,11 @@ import { runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist'; -import { +import type { createOpencodeClient, - type AssistantMessage, - type OpencodeClient, - type PermissionRequest, - type PermissionRuleset, - type QuestionRequest, + AssistantMessage, + OpencodeClient, + PermissionRequest, + PermissionRuleset, + QuestionRequest, } from "@opencode-ai/sdk/v2"; import spawn from "cross-spawn"; import { createHash, randomBytes } from "node:crypto"; @@ -740,11 +740,27 @@ async function defaultValidateBinary( }); } +type OpencodeSdk = typeof import("@opencode-ai/sdk/v2"); +let opencodeSdk: OpencodeSdk | undefined; + +// The SDK is loaded when an OpenCode client is first created, not at Main +// startup (P6). initializeClient awaits this before any default factory runs. +async function loadOpencodeSdk(): Promise { + opencodeSdk ??= await import("@opencode-ai/sdk/v2"); + return opencodeSdk; +} + +const defaultCreateClient: typeof createOpencodeClient = (config) => { + if (!opencodeSdk) throw new Error("OpenCode SDK is not loaded"); + return opencodeSdk.createOpencodeClient(config); +}; + async function defaultCheckServerHealth( url: string, authorization: string, signal: AbortSignal, ): Promise { + const { createOpencodeClient } = await loadOpencodeSdk(); const client = createOpencodeClient({ baseUrl: url, headers: { Authorization: authorization }, @@ -798,7 +814,7 @@ export class OpenCodeRuntime implements AgentRuntime { spawn, detectBinary: defaultDetectBinary, validateBinary: defaultValidateBinary, - createClient: createOpencodeClient, + createClient: defaultCreateClient, checkServerHealth: defaultCheckServerHealth, platform: process.platform, startupTimeoutMs: STARTUP_TIMEOUT_MS, @@ -1483,6 +1499,7 @@ export class OpenCodeRuntime implements AgentRuntime { if (baseUrl && this.options.modelProfile) { throw new Error("OpenCode 独立模型连接仅支持由 GoodBuddy 启动的本机服务"); } + await loadOpencodeSdk(); if (!baseUrl && this.options.embedded) { this.server = await this.launchEmbedded(signal); baseUrl = this.server.url; diff --git a/src/main/document-extracted-text.ts b/src/main/document-extracted-text.ts index da333df2..b85b09b6 100644 --- a/src/main/document-extracted-text.ts +++ b/src/main/document-extracted-text.ts @@ -1,8 +1,15 @@ -import { convert } from 'html-to-text' +import { createLazyRequire } from './lazy-require' + +/** + * Loads html-to-text on first use instead of at Main startup (P6, ~45 ms). + * Synchronous so the text checks keep their signatures. + */ +export const loadHtmlToText = + createLazyRequire('html-to-text') export function hasExtractedDocumentText(content: string): boolean { const text = content .replace(/!\[[^\]]*\]\([^)]*\)/gu, '') .replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, '') - return Boolean(convert(text, { wordwrap: false }).trim()) + return Boolean(loadHtmlToText().convert(text, { wordwrap: false }).trim()) } diff --git a/src/main/document-result-storage.ts b/src/main/document-result-storage.ts index 3c7b18be..ef8d02fd 100644 --- a/src/main/document-result-storage.ts +++ b/src/main/document-result-storage.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { basename, dirname, extname, join } from 'node:path' -import { createCanvas, loadImage } from '@napi-rs/canvas' import { documentResultSchema, type DocumentResult } from '../shared/document-result-contracts' import type { DocumentParsingSettings } from '../shared/document-parsing-contracts' import type { ParsedDocument } from './knowledge/document-parser' @@ -96,6 +95,8 @@ export class DocumentResultStorage { if (!image) throw new Error('解析图片不存在') const data = await readFile(join(this.require(id).directory, 'images', imageId)) if (thumbnail) { + // Native canvas loads on first use (P6). + const { createCanvas, loadImage } = await import('@napi-rs/canvas') const source = await loadImage(data) const scale = Math.min(1, 640 / Math.max(source.width, source.height)) const canvas = createCanvas(Math.max(1, Math.round(source.width * scale)), Math.max(1, Math.round(source.height * scale))) diff --git a/src/main/http-document-ocr.ts b/src/main/http-document-ocr.ts index 7c2c34f9..c87a8837 100644 --- a/src/main/http-document-ocr.ts +++ b/src/main/http-document-ocr.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto' -import { loadImage } from '@napi-rs/canvas' import { z } from 'zod' import { maximumDocumentExtractedCharacters, @@ -131,6 +130,8 @@ export class HttpDocumentOcr { : bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? 'image/png' : bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : undefined if (!mimeType) throw new Error('图片格式不支持') + // Native canvas loads on first use (P6). + const { loadImage } = await import('@napi-rs/canvas') const image = await loadImage(bytes) if (!image.width || !image.height || image.width * image.height > 40_000_000) throw new Error('图片尺寸无效或超过 4000 万像素') imageBytes += bytes.length diff --git a/src/main/knowledge/document-parser.ts b/src/main/knowledge/document-parser.ts index 6b40b217..7f833c1f 100644 --- a/src/main/knowledge/document-parser.ts +++ b/src/main/knowledge/document-parser.ts @@ -1,4 +1,4 @@ -import { convert } from 'html-to-text' +import { loadHtmlToText } from '../document-extracted-text' import { unzipSync } from 'fflate' import { extname } from 'node:path' import type { ParsedDocumentImage } from '../http-document-ocr' @@ -701,7 +701,7 @@ export async function parseDocument( } else if (['.docx', '.xlsx', '.pptx'].includes(extension)) { sections = parseOfficeArchive(buffer, extension) } else if (['.html', '.htm'].includes(extension)) { - const content = convert(decodeText(buffer), { + const content = loadHtmlToText().convert(decodeText(buffer), { wordwrap: false, selectors: [ { selector: 'script', format: 'skip' }, diff --git a/src/main/lazy-require.test.ts b/src/main/lazy-require.test.ts new file mode 100644 index 00000000..f5694a3f --- /dev/null +++ b/src/main/lazy-require.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { createLazyRequire } from './lazy-require' +import { loadHtmlToText } from './document-extracted-text' +import { loadSsh2 } from './ssh/ssh2-module' + +describe('createLazyRequire', () => { + it('loads a module on first call and returns the cached instance afterwards', () => { + const load = createLazyRequire('node:path') + const first = load() + expect(typeof first.join).toBe('function') + expect(load()).toBe(first) + }) + + it('surfaces a missing module at first use, not at import time', () => { + const load = createLazyRequire('goodbuddy-module-that-does-not-exist') + expect(load).toThrow() + }) + + it('resolves the deferred Main dependencies', () => { + expect(typeof loadHtmlToText().convert).toBe('function') + expect(typeof loadSsh2().Client).toBe('function') + }) +}) diff --git a/src/main/lazy-require.ts b/src/main/lazy-require.ts new file mode 100644 index 00000000..afddefb8 --- /dev/null +++ b/src/main/lazy-require.ts @@ -0,0 +1,14 @@ +import { createRequire } from 'node:module' + +/** + * Synchronously loads a dependency on first use instead of at module + * evaluation (P6). Main is ESM, but some test fixtures bundle the same code as + * CommonJS, where `import.meta.url` is empty and `__filename` exists instead. + */ +export function createLazyRequire(specifier: string): () => T { + let loaded: T | undefined + return () => { + loaded ??= createRequire(import.meta.url || __filename)(specifier) as T + return loaded + } +} diff --git a/src/main/render-ocr-pdf.ts b/src/main/render-ocr-pdf.ts index d8b14914..42db523a 100644 --- a/src/main/render-ocr-pdf.ts +++ b/src/main/render-ocr-pdf.ts @@ -1,4 +1,4 @@ -import { createCanvas } from '@napi-rs/canvas' +// @napi-rs/canvas is a native module (~85 ms to load); load it on first OCR render (P6). export async function* renderOcrPdf( buffer: Buffer, @@ -6,7 +6,10 @@ export async function* renderOcrPdf( signal?: AbortSignal ): AsyncGenerator<{ pageNumber: number; data: Buffer; width: number; height: number }> { signal?.throwIfAborted() - const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs') + const [{ getDocument }, { createCanvas }] = await Promise.all([ + import('pdfjs-dist/legacy/build/pdf.mjs'), + import('@napi-rs/canvas') + ]) const task = getDocument({ data: Uint8Array.from(buffer), disableFontFace: true, isOffscreenCanvasSupported: false, useSystemFonts: false, useWorkerFetch: false diff --git a/src/main/ssh/ssh-connection-pool.ts b/src/main/ssh/ssh-connection-pool.ts index 85f8af56..3363d7ce 100644 --- a/src/main/ssh/ssh-connection-pool.ts +++ b/src/main/ssh/ssh-connection-pool.ts @@ -1,7 +1,7 @@ import { createHmac, getCiphers, randomBytes } from "node:crypto"; import { connect as connectLoopback, type Socket } from "node:net"; -import { Client } from "ssh2"; -import type { ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2"; +import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2"; +import { loadSsh2 } from "./ssh2-module"; import { BoundedStagedSftp, type BoundedSftpLimits, @@ -344,7 +344,7 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection { validatePoolTarget(target); validateConnectionIdentity(target, identity); const resolvedDependencies: SshConnectionDependencies = { - createClient: () => new Client(), + createClient: () => new (loadSsh2().Client)(), supportedOpenSslCiphers: getCiphers, systemAgent: defaultSystemAgent, controlPlanePackageInstaller: Buffer.alloc(0), diff --git a/src/main/ssh/ssh-transport.ts b/src/main/ssh/ssh-transport.ts index dd1947a2..bd26f198 100644 --- a/src/main/ssh/ssh-transport.ts +++ b/src/main/ssh/ssh-transport.ts @@ -1,11 +1,12 @@ import { createHash, getCiphers } from 'node:crypto' -import { +import type { Client, - type Algorithms, - type CipherAlgorithm, - type ConnectConfig, - type ServerHostKeyAlgorithm + Algorithms, + CipherAlgorithm, + ConnectConfig, + ServerHostKeyAlgorithm } from 'ssh2' +import { loadSsh2 } from './ssh2-module' import type { SshHostConnectionTestResult } from '../../shared/ssh-host-contracts' @@ -336,7 +337,7 @@ export class Ssh2Transport implements SshTransport { dependencies: Partial = {} ) { this.dependencies = { - createClient: () => new Client(), + createClient: () => new (loadSsh2().Client)(), now: Date.now, supportedOpenSslCiphers: getCiphers, systemAgent: defaultSystemAgent, diff --git a/src/main/ssh/ssh2-module.ts b/src/main/ssh/ssh2-module.ts new file mode 100644 index 00000000..ab3bd7a8 --- /dev/null +++ b/src/main/ssh/ssh2-module.ts @@ -0,0 +1,8 @@ +import { createLazyRequire } from '../lazy-require' + +/** + * Loads ssh2 on the first SSH connection instead of at Main startup (P6): + * evaluating it costs ~65 ms and most sessions never open a remote host. + * Synchronous so existing client factories keep their signatures. + */ +export const loadSsh2 = createLazyRequire('ssh2') diff --git a/src/main/terminal/local-terminal-session.ts b/src/main/terminal/local-terminal-session.ts index e7c775fa..30eaa969 100644 --- a/src/main/terminal/local-terminal-session.ts +++ b/src/main/terminal/local-terminal-session.ts @@ -3,7 +3,7 @@ import { constants as fileSystemConstants } from 'node:fs' import { access, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' -import * as nodePty from 'node-pty' +import type * as nodePty from 'node-pty' import type { IDisposable, IPty } from 'node-pty' import { TERMINAL_LIMITS, @@ -393,7 +393,9 @@ export class LocalTerminalSession { : undefined, this.dependencies ) - const spawn = this.dependencies.spawn ?? nodePty.spawn + // node-pty is native; load it when the first terminal starts (P6). + const spawn = this.dependencies.spawn ?? + (await import('node-pty')).spawn this.pty = spawn(this.launch.shell, spec?.args ?? [], { name: 'xterm-256color', cols: this.size.cols,