mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
perf(tui): reduce cold-start latency
This commit is contained in:
@@ -1488,7 +1488,9 @@ describe("NanobotTui layout", () => {
|
||||
test("overlaps automatic terminal detection with connection startup", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let connected = false
|
||||
let rendered = false
|
||||
let resolveMode: (mode: "light") => void = () => undefined
|
||||
setup.renderer.start = () => { rendered = true }
|
||||
setup.renderer.waitForThemeMode = () => new Promise((resolve) => {
|
||||
resolveMode = resolve
|
||||
})
|
||||
@@ -1505,6 +1507,7 @@ describe("NanobotTui layout", () => {
|
||||
const starting = app.start()
|
||||
await Bun.sleep(1)
|
||||
expect(connected).toBe(true)
|
||||
expect(rendered).toBe(true)
|
||||
|
||||
resolveMode("light")
|
||||
await starting
|
||||
@@ -1994,6 +1997,26 @@ describe("NanobotTui layout", () => {
|
||||
expect(closed).toBe(true)
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts the exit command before the gateway connection is ready", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let closed = false
|
||||
const transport = client()
|
||||
transport.close = () => { closed = true }
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
options,
|
||||
transport,
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const composer = (app as unknown as { composer: TextareaRenderable }).composer
|
||||
|
||||
composer.setText("exit")
|
||||
composer.submit()
|
||||
await waitUntil(() => closed)
|
||||
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("NanobotTui in a Herdr pane", () => {
|
||||
|
||||
+41
-12
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
NanobotClient,
|
||||
fetchHistory,
|
||||
fetchGatewayConnection,
|
||||
fetchMentionCandidates,
|
||||
fetchSessionContext,
|
||||
fetchSessions,
|
||||
@@ -78,7 +79,9 @@ import {
|
||||
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
|
||||
|
||||
interface AppOptions {
|
||||
wsUrl: string
|
||||
wsUrl?: string
|
||||
bootstrapUrl?: string
|
||||
bootstrapSecret?: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
chatId?: string
|
||||
@@ -472,7 +475,23 @@ export class NanobotTui {
|
||||
)
|
||||
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
||||
this.client = client || new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
...(options.bootstrapUrl
|
||||
? {
|
||||
resolveConnection: () => fetchGatewayConnection(
|
||||
options.bootstrapUrl || "",
|
||||
options.bootstrapSecret || "",
|
||||
options.apiUrl,
|
||||
`tui-${process.pid}`,
|
||||
),
|
||||
onConnection: (connection) => this.useGatewayConnection(
|
||||
connection.apiUrl,
|
||||
connection.apiToken,
|
||||
),
|
||||
connectionRetryLabel: "Starting local gateway",
|
||||
reconnectDelayMs: 100,
|
||||
startupRetryMaxDelayMs: 250,
|
||||
}
|
||||
: { url: options.wsUrl }),
|
||||
chatId: options.chatId,
|
||||
onEvent: (event) => this.accept(event),
|
||||
onStatus: (status, detail) => this.handleStatus(status, detail),
|
||||
@@ -719,16 +738,15 @@ export class NanobotTui {
|
||||
void this.loadCommands()
|
||||
void this.loadMentions()
|
||||
this.runtimeControls.preload()
|
||||
this.renderer.start()
|
||||
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
|
||||
// that bounded probe before first paint, as OpenCode does, so a light
|
||||
// terminal does not briefly render the dark palette. The app already owns
|
||||
// the renderer here, so a signal during the probe can still restore it.
|
||||
// that bounded probe after first paint. The neutral terminal background is
|
||||
// safe to render immediately, and the detected palette can be applied later.
|
||||
if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
|
||||
if (this.quitting) return
|
||||
if (this.options.theme === "auto" && this.renderer.themeMode) {
|
||||
this.applyTheme(this.renderer.themeMode)
|
||||
}
|
||||
this.renderer.start()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -775,6 +793,10 @@ export class NanobotTui {
|
||||
return
|
||||
}
|
||||
if (!visibleContent) return
|
||||
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
const completion = this.commandMenu.completion(visibleContent)
|
||||
if (completion) {
|
||||
this.setComposer(completion)
|
||||
@@ -804,10 +826,6 @@ export class NanobotTui {
|
||||
this.status.content = "Preparing chat…"
|
||||
return
|
||||
}
|
||||
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
||||
if (this.activeTurn) {
|
||||
this.sendPrompt(prompt, true)
|
||||
@@ -1132,6 +1150,14 @@ export class NanobotTui {
|
||||
for (const event of events || []) this.accept(event)
|
||||
}
|
||||
|
||||
private useGatewayConnection(apiUrl: string, apiToken: string): void {
|
||||
this.options.apiUrl = apiUrl
|
||||
this.options.apiToken = apiToken
|
||||
this.runtimeControls.useApiConnection(apiUrl, apiToken)
|
||||
void this.loadCommands()
|
||||
void this.loadMentions()
|
||||
}
|
||||
|
||||
private handleStatus(status: ConnectionStatus, detail?: string): void {
|
||||
if (status === "connected") {
|
||||
this.ready = false
|
||||
@@ -1141,9 +1167,12 @@ export class NanobotTui {
|
||||
}
|
||||
if (status === "connecting") {
|
||||
this.ready = false
|
||||
this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting")
|
||||
const label = detail === "Starting local gateway"
|
||||
? detail
|
||||
: detail ? "Reconnecting" : "Connecting"
|
||||
this.host.reportState("unknown", label)
|
||||
if (detail) this.setActive(false)
|
||||
this.status.content = detail ? "Reconnecting…" : "Connecting…"
|
||||
this.status.content = `${label}…`
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
|
||||
+11
-7
@@ -1,12 +1,6 @@
|
||||
import { NanobotTui, type AppOptions } from "./app"
|
||||
import { currentGitBranch } from "./host"
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
function themePreference(): AppOptions["theme"] {
|
||||
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
|
||||
if (value === "auto" || value === "dark" || value === "light") return value
|
||||
@@ -15,8 +9,18 @@ function themePreference(): AppOptions["theme"] {
|
||||
|
||||
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
|
||||
const hostWorkspace = process.cwd()
|
||||
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
|
||||
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
|
||||
if (!bootstrapUrl && !wsUrl) {
|
||||
throw new Error("NANOBOT_TUI_BOOTSTRAP_URL or NANOBOT_TUI_WS_URL is required")
|
||||
}
|
||||
const options: AppOptions = {
|
||||
wsUrl: required("NANOBOT_TUI_WS_URL"),
|
||||
...(bootstrapUrl
|
||||
? {
|
||||
bootstrapUrl,
|
||||
bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "",
|
||||
}
|
||||
: { wsUrl }),
|
||||
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
|
||||
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
|
||||
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
|
||||
import {
|
||||
NanobotClient,
|
||||
GatewayConnectionError,
|
||||
fetchGatewayConnection,
|
||||
fetchHistory,
|
||||
fetchMentionCandidates,
|
||||
fetchRuntimeControls,
|
||||
@@ -37,6 +39,156 @@ class FakeSocket {
|
||||
}
|
||||
|
||||
describe("gateway protocol", () => {
|
||||
test("bootstraps fresh websocket and API credentials", async () => {
|
||||
const original = globalThis.fetch
|
||||
let headers: Headers | undefined
|
||||
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
headers = new Headers(init?.headers)
|
||||
return new Response(JSON.stringify({
|
||||
ws_url: "ws://nanobot.test/ws?mode=local",
|
||||
token: "socket token",
|
||||
api_token: "api-token",
|
||||
}))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const connection = await fetchGatewayConnection(
|
||||
"http://nanobot.test/webui/bootstrap",
|
||||
"bootstrap-secret",
|
||||
"http://nanobot.test",
|
||||
"tui-42",
|
||||
)
|
||||
expect(headers?.get("X-Nanobot-Auth")).toBe("bootstrap-secret")
|
||||
expect(connection).toEqual({
|
||||
wsUrl: "ws://nanobot.test/ws?mode=local&token=socket+token&client_id=tui-42",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "api-token",
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects malformed bootstrap responses without retrying", async () => {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
await expect(fetchGatewayConnection(
|
||||
"http://nanobot.test/webui/bootstrap",
|
||||
"bootstrap-secret",
|
||||
"http://nanobot.test",
|
||||
"tui-42",
|
||||
)).rejects.toMatchObject({
|
||||
message: "gateway bootstrap response is invalid",
|
||||
retryable: false,
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for bootstrap before opening the websocket", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
let resolveConnection: ((value: {
|
||||
wsUrl: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
}) => void) | undefined
|
||||
let requestedUrl = ""
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor(url: string) {
|
||||
super()
|
||||
requestedUrl = url
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const connections: string[] = []
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
|
||||
onConnection: (connection) => connections.push(connection.apiToken),
|
||||
onEvent: () => undefined,
|
||||
onStatus: () => undefined,
|
||||
})
|
||||
client.connect()
|
||||
await Bun.sleep(1)
|
||||
expect(requestedUrl).toBe("")
|
||||
resolveConnection?.({
|
||||
wsUrl: "ws://nanobot.test/ws?token=fresh",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "fresh-api-token",
|
||||
})
|
||||
await Bun.sleep(1)
|
||||
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
|
||||
expect(connections).toEqual(["fresh-api-token"])
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("retries bootstrap while the local gateway starts", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
let attempts = 0
|
||||
let requestedUrl = ""
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor(url: string) {
|
||||
super()
|
||||
requestedUrl = url
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error("gateway is still starting")
|
||||
return {
|
||||
wsUrl: "ws://nanobot.test/ws?token=second",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "second-api-token",
|
||||
}
|
||||
},
|
||||
reconnectDelayMs: 1,
|
||||
onEvent: () => undefined,
|
||||
onStatus: () => undefined,
|
||||
})
|
||||
client.connect()
|
||||
for (let index = 0; index < 20 && !requestedUrl; index += 1) await Bun.sleep(2)
|
||||
expect(attempts).toBe(2)
|
||||
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=second")
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a permanent bootstrap rejection without retrying", async () => {
|
||||
let attempts = 0
|
||||
const statuses: string[] = []
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: async () => {
|
||||
attempts += 1
|
||||
throw new GatewayConnectionError("gateway bootstrap failed: HTTP 401", false)
|
||||
},
|
||||
reconnectDelayMs: 1,
|
||||
onEvent: () => undefined,
|
||||
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
||||
})
|
||||
client.connect()
|
||||
await Bun.sleep(5)
|
||||
expect(attempts).toBe(1)
|
||||
expect(statuses.at(-1)).toBe("error:gateway bootstrap failed: HTTP 401")
|
||||
client.close()
|
||||
})
|
||||
|
||||
test("represents lifecycle frames without browser state", () => {
|
||||
const events: InboundEvent[] = [
|
||||
{
|
||||
|
||||
+110
-9
@@ -151,13 +151,30 @@ type OutboundEvent =
|
||||
}
|
||||
|
||||
export interface ClientOptions {
|
||||
url: string
|
||||
url?: string
|
||||
resolveConnection?: () => Promise<GatewayConnection>
|
||||
onConnection?: (connection: GatewayConnection) => void
|
||||
connectionRetryLabel?: string
|
||||
startupRetryMaxDelayMs?: number
|
||||
chatId?: string
|
||||
reconnectDelayMs?: number
|
||||
onEvent: (event: InboundEvent) => void
|
||||
onStatus: (status: ConnectionStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
export interface GatewayConnection {
|
||||
wsUrl: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
}
|
||||
|
||||
export class GatewayConnectionError extends Error {
|
||||
constructor(message: string, readonly retryable: boolean) {
|
||||
super(message)
|
||||
this.name = "GatewayConnectionError"
|
||||
}
|
||||
}
|
||||
|
||||
export interface HistoryMessage {
|
||||
role: "user" | "assistant" | "activity"
|
||||
content: string
|
||||
@@ -747,12 +764,63 @@ function sessionLabelForMention(session: SessionSummary): string {
|
||||
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
|
||||
}
|
||||
|
||||
/** Resolve fresh short-lived credentials once the local gateway is reachable. */
|
||||
export async function fetchGatewayConnection(
|
||||
bootstrapUrl: string,
|
||||
bootstrapSecret: string,
|
||||
apiUrl: string,
|
||||
clientId: string,
|
||||
): Promise<GatewayConnection> {
|
||||
const response = await fetch(bootstrapUrl, {
|
||||
headers: bootstrapSecret ? { "X-Nanobot-Auth": bootstrapSecret } : {},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
|
||||
throw new GatewayConnectionError(
|
||||
`gateway bootstrap failed: HTTP ${response.status}`,
|
||||
retryable,
|
||||
)
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
|
||||
}
|
||||
if (typeof payload.ws_url !== "string" || !payload.ws_url.trim()) {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is missing ws_url", false)
|
||||
}
|
||||
let wsUrl: URL
|
||||
try {
|
||||
wsUrl = new URL(payload.ws_url)
|
||||
} catch {
|
||||
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
|
||||
}
|
||||
if (wsUrl.protocol !== "ws:" && wsUrl.protocol !== "wss:") {
|
||||
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
|
||||
}
|
||||
if (typeof payload.token === "string" && payload.token) {
|
||||
wsUrl.searchParams.append("token", payload.token)
|
||||
}
|
||||
wsUrl.searchParams.append("client_id", clientId)
|
||||
return {
|
||||
wsUrl: wsUrl.toString(),
|
||||
apiUrl,
|
||||
apiToken: typeof payload.api_token === "string" ? payload.api_token : "",
|
||||
}
|
||||
}
|
||||
|
||||
export class NanobotClient {
|
||||
private socket: WebSocket | null = null
|
||||
private chatId = ""
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempt = 0
|
||||
private closedByClient = false
|
||||
private opening = false
|
||||
private connectedOnce = false
|
||||
|
||||
constructor(private readonly options: ClientOptions) {}
|
||||
|
||||
@@ -762,16 +830,46 @@ export class NanobotClient {
|
||||
|
||||
connect(): void {
|
||||
this.closedByClient = false
|
||||
this.open()
|
||||
void this.open()
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.socket) return
|
||||
private async open(): Promise<void> {
|
||||
if (this.socket || this.opening || this.closedByClient) return
|
||||
this.opening = true
|
||||
this.options.onStatus("connecting")
|
||||
const socket = new WebSocket(this.options.url)
|
||||
let url = this.options.url
|
||||
try {
|
||||
if (this.options.resolveConnection) {
|
||||
const connection = await this.options.resolveConnection()
|
||||
if (this.closedByClient) return
|
||||
this.options.onConnection?.(connection)
|
||||
url = connection.wsUrl
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.closedByClient) {
|
||||
if (error instanceof GatewayConnectionError && !error.retryable) {
|
||||
this.options.onStatus("error", error.message)
|
||||
return
|
||||
}
|
||||
this.options.onStatus(
|
||||
"connecting",
|
||||
this.options.connectionRetryLabel || "gateway unavailable",
|
||||
)
|
||||
this.scheduleReconnect(false)
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
this.opening = false
|
||||
}
|
||||
if (!url) {
|
||||
this.options.onStatus("error", "gateway URL is not configured")
|
||||
return
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
this.socket = socket
|
||||
socket.addEventListener("open", () => {
|
||||
if (this.socket !== socket) return
|
||||
this.connectedOnce = true
|
||||
this.reconnectAttempt = 0
|
||||
this.options.onStatus("connected")
|
||||
})
|
||||
@@ -872,14 +970,17 @@ export class NanobotClient {
|
||||
this.options.onEvent(event)
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
private scheduleReconnect(announce = true): void {
|
||||
if (this.reconnectTimer || this.closedByClient) return
|
||||
const base = this.options.reconnectDelayMs ?? 500
|
||||
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4))
|
||||
this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
||||
const maxDelay = this.connectedOnce
|
||||
? 8_000
|
||||
: this.options.startupRetryMaxDelayMs ?? 8_000
|
||||
const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4))
|
||||
if (announce) this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.open()
|
||||
void this.open()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,14 @@ export class RuntimeControls {
|
||||
void this.load().catch(() => {})
|
||||
}
|
||||
|
||||
useApiConnection(apiUrl: string, apiToken: string): void {
|
||||
this.options.apiUrl = apiUrl
|
||||
this.options.apiToken = apiToken
|
||||
this.controlsLoaded = false
|
||||
this.controlsLoadedAt = 0
|
||||
this.preload()
|
||||
}
|
||||
|
||||
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
|
||||
this.scope = scope
|
||||
this.render()
|
||||
|
||||
Reference in New Issue
Block a user