-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(vis): faithful wire.jsonl rendering + built-in kimi vis command
#788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
4b2984f
c9df743
d356b03
18d5b92
2bbee68
0547312
16f1a0d
57637e8
e017447
70d085c
7c72dcf
27db6e6
9158fca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| node_modules/ | ||
| dist/ | ||
| dist-single/ | ||
| dist-native/ | ||
| .tmp-api-extractor/ | ||
| coverage/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,8 @@ | ||
| # Copied from packages/kimi-core at build time | ||
| agents/ | ||
|
|
||
| # Generated at build time by scripts/build-vis-asset.mjs. | ||
| # Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub | ||
| # next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh | ||
| # clone (before any build has produced the `.ts`). | ||
| src/generated/vis-web-asset.ts |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| // Builds the vis web single-file bundle, gzips it, and writes a generated | ||
| // TS module that embeds it as base64 so tsdown can later bundle it into | ||
| // dist/main.mjs (works identically for the npm package and the native SEA | ||
| // binary). | ||
| import { execFileSync } from 'node:child_process'; | ||
| import { gzipSync } from 'node:zlib'; | ||
| import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; | ||
| import { dirname, join, resolve } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
|
|
||
| const here = dirname(fileURLToPath(import.meta.url)); | ||
| const repoRoot = resolve(here, '..', '..', '..'); | ||
| const visWeb = join(repoRoot, 'apps', 'vis', 'web'); | ||
| const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts'); | ||
|
|
||
| console.log('[build-vis-asset] building vis web single-file bundle…'); | ||
| try { | ||
| execFileSync('pnpm', ['--filter', '@moonshot-ai/vis-web', 'build:single'], { | ||
| stdio: 'inherit', | ||
| cwd: repoRoot, | ||
| }); | ||
| } catch (err) { | ||
| throw new Error( | ||
| `[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`, | ||
| ); | ||
| } | ||
|
|
||
| const html = readFileSync(join(visWeb, 'dist-single', 'index.html')); | ||
| if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes('<!doctype html')) { | ||
| throw new Error( | ||
| `[build-vis-asset] dist-single/index.html looks invalid (${html.length} bytes) — the web build may have failed`, | ||
| ); | ||
| } | ||
| const b64 = gzipSync(html, { level: 9 }).toString('base64'); | ||
|
|
||
| mkdirSync(dirname(out), { recursive: true }); | ||
| writeFileSync( | ||
| out, | ||
| `// GENERATED by scripts/build-vis-asset.mjs — do not edit.\n` + | ||
| `export const VIS_WEB_GZIP_B64 = ${JSON.stringify(b64)};\n`, | ||
| ); | ||
| console.log(`[build-vis-asset] wrote ${out} (${(b64.length / 1024).toFixed(0)} KB base64)`); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| /** | ||
| * `kimi vis` sub-command. | ||
| * | ||
| * CLI glue only: resolves the kimi home, starts the in-process session | ||
| * visualizer server (auto-picking a free port by default), prints the URL, | ||
| * optionally opens the browser (with an optional session deep-link), then | ||
| * waits for Ctrl-C and shuts the server down. The visualizer server itself | ||
| * lives in `@moonshot-ai/vis-server`. | ||
| */ | ||
|
|
||
| import type { Command } from 'commander'; | ||
|
|
||
| import { createCliTelemetryBootstrap } from '#/cli/telemetry'; | ||
| import { openUrl } from '#/utils/open-url'; | ||
|
|
||
| interface WritableLike { | ||
| write(chunk: string): boolean; | ||
| } | ||
|
|
||
| export interface StartedVisServer { | ||
| readonly port: number; | ||
| readonly host: string; | ||
| readonly url: string; | ||
| readonly close: () => Promise<void>; | ||
| } | ||
|
|
||
| export interface StartVisServerArgs { | ||
| readonly homeDir: string; | ||
| readonly port: number; | ||
| readonly host?: string; | ||
| readonly webAsset?: { gzipped: Uint8Array }; | ||
| } | ||
|
|
||
| export interface VisDeps { | ||
| readonly getHomeDir: () => string; | ||
| readonly startVisServer: (opts: StartVisServerArgs) => Promise<StartedVisServer>; | ||
| readonly openUrl: (url: string) => Promise<void>; | ||
| readonly waitForShutdown: () => Promise<void>; | ||
| readonly stdout: WritableLike; | ||
| readonly stderr: WritableLike; | ||
| readonly exit: (code: number) => never; | ||
| } | ||
|
|
||
| export interface VisOptions { | ||
| readonly open: boolean; | ||
| readonly port?: number; | ||
| readonly host?: string; | ||
| readonly sessionId?: string; | ||
| } | ||
|
|
||
| export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void> { | ||
| const homeDir = deps.getHomeDir(); | ||
|
|
||
| // Lazily load the embedded single-file SPA so normal `kimi` startup never | ||
| // pays for it. The module is generated at build time; when it has not been | ||
| // generated (empty placeholder) the server falls back to its own static | ||
| // `public/` directory. | ||
| const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset'); | ||
|
Check failure on line 58 in apps/kimi-code/src/cli/sub/vis.ts
|
||
| const webAsset = | ||
| VIS_WEB_GZIP_B64.length > 0 | ||
| ? { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) } | ||
| : undefined; | ||
|
|
||
| let server: StartedVisServer; | ||
| try { | ||
| server = await deps.startVisServer({ | ||
| homeDir, | ||
| port: opts.port ?? 0, | ||
| ...(opts.host === undefined ? {} : { host: opts.host }), | ||
| ...(webAsset === undefined ? {} : { webAsset }), | ||
| }); | ||
| } catch (error) { | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
| deps.stderr.write(`Failed to start kimi vis: ${msg}\n`); | ||
| return deps.exit(1); | ||
| } | ||
|
|
||
| const target = | ||
| opts.sessionId === undefined | ||
| ? server.url | ||
| : `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`; | ||
|
Comment on lines
+83
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| deps.stdout.write(`kimi vis is running at ${server.url}\n`); | ||
| deps.stdout.write('Press Ctrl-C to stop.\n'); | ||
|
|
||
| if (opts.open) { | ||
| try { | ||
| await deps.openUrl(target); | ||
| } catch { | ||
| deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`); | ||
| } | ||
| } | ||
|
|
||
| await deps.waitForShutdown(); | ||
| await server.close(); | ||
| } | ||
|
|
||
| export function registerVisCommand(parent: Command, overrides?: Partial<VisDeps>): void { | ||
| parent | ||
| .command('vis') | ||
| .description('Launch the session visualizer in your browser.') | ||
| .option('--port <number>', 'Port to bind. Default: auto-pick a free port.') | ||
| .option('--host <host>', 'Host to bind. Default: 127.0.0.1.') | ||
| .option('--no-open', 'Do not open the browser automatically.') | ||
| .argument('[sessionId]', 'Open directly to this session.') | ||
| .action( | ||
| async ( | ||
| sessionId: string | undefined, | ||
| options: { port?: string; host?: string; open?: boolean }, | ||
| ) => { | ||
| const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10); | ||
| await handleVis(createDefaultVisDeps(overrides), { | ||
| open: options.open !== false, | ||
| ...(port === undefined || Number.isNaN(port) ? {} : { port }), | ||
| ...(options.host === undefined ? {} : { host: options.host }), | ||
| ...(sessionId === undefined ? {} : { sessionId }), | ||
| }); | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| function createDefaultVisDeps(overrides: Partial<VisDeps> = {}): VisDeps { | ||
| return { | ||
| getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir), | ||
| startVisServer: | ||
| overrides.startVisServer ?? | ||
| (async (opts) => { | ||
| // Dynamic import keeps the vis server (and Hono) out of the hot path. | ||
| const { startVisServer } = await import('@moonshot-ai/vis-server/start'); | ||
| return startVisServer(opts); | ||
| }), | ||
| // `openUrl` is a synchronous fire-and-forget; adapt it to the async dep. | ||
| openUrl: | ||
| overrides.openUrl ?? | ||
| (async (url: string) => { | ||
| openUrl(url); | ||
| }), | ||
| waitForShutdown: overrides.waitForShutdown ?? waitForSigint, | ||
| stdout: overrides.stdout ?? process.stdout, | ||
| stderr: overrides.stderr ?? process.stderr, | ||
| exit: overrides.exit ?? ((code: number) => process.exit(code)), | ||
| }; | ||
| } | ||
|
|
||
| function waitForSigint(): Promise<void> { | ||
| return new Promise<void>((resolve) => { | ||
| const onSig = (): void => { | ||
| process.off('SIGINT', onSig); | ||
| resolve(); | ||
| }; | ||
| process.on('SIGINT', onSig); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export declare const VIS_WEB_GZIP_B64: string; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| /** | ||
| * `kimi vis` | ||
| * | ||
| * Verifies the CLI layer for the session visualizer: home + auto-port | ||
| * resolution, browser open vs `--no-open`, and the session deep-link path. | ||
| * Uses injected deps so no real port is bound and the real vis server is | ||
| * never started. | ||
| */ | ||
|
|
||
| import { describe, it, expect, vi } from 'vitest'; | ||
|
|
||
| import { handleVis, type VisDeps } from '#/cli/sub/vis'; | ||
|
|
||
| function makeDeps(over: Partial<VisDeps> = {}): { | ||
| deps: VisDeps; | ||
| opened: string[]; | ||
| out: string[]; | ||
| } { | ||
| const opened: string[] = []; | ||
| const out: string[] = []; | ||
| const deps: VisDeps = { | ||
| getHomeDir: () => '/home/k', | ||
| startVisServer: vi.fn(async (o) => ({ | ||
| port: 41234, | ||
| host: '127.0.0.1', | ||
| url: 'http://127.0.0.1:41234/', | ||
| close: async () => {}, | ||
| _opts: o, | ||
| })) as unknown as VisDeps['startVisServer'], | ||
| openUrl: async (u: string) => { | ||
| opened.push(u); | ||
| }, | ||
| waitForShutdown: async () => {}, | ||
| stdout: { | ||
| write: (s: string) => { | ||
| out.push(s); | ||
| return true; | ||
| }, | ||
| }, | ||
| stderr: { write: () => true }, | ||
| exit: vi.fn() as unknown as VisDeps['exit'], | ||
| ...over, | ||
| }; | ||
| return { deps, opened, out }; | ||
| } | ||
|
|
||
| describe('handleVis', () => { | ||
| it('starts the server with the home dir + auto port and opens the browser', async () => { | ||
| const { deps, opened, out } = makeDeps(); | ||
| await handleVis(deps, { open: true }); | ||
| expect(deps.startVisServer).toHaveBeenCalledWith( | ||
| expect.objectContaining({ homeDir: '/home/k', port: 0 }), | ||
| ); | ||
| expect(opened).toEqual(['http://127.0.0.1:41234/']); | ||
| expect(out.join('')).toContain('http://127.0.0.1:41234/'); | ||
| }); | ||
|
|
||
| it('does not open the browser when open is false', async () => { | ||
| const { deps, opened } = makeDeps(); | ||
| await handleVis(deps, { open: false }); | ||
| expect(opened).toEqual([]); | ||
| }); | ||
|
|
||
| it('deep-links to a session when sessionId is given', async () => { | ||
| const { deps, opened } = makeDeps(); | ||
| await handleVis(deps, { open: true, sessionId: 'sess_abc' }); | ||
| expect(opened[0]).toBe('http://127.0.0.1:41234/sessions/sess_abc'); | ||
| }); | ||
|
|
||
| it('uses the explicit port when provided', async () => { | ||
| const { deps } = makeDeps(); | ||
| await handleVis(deps, { open: false, port: 4321 }); | ||
| expect(deps.startVisServer).toHaveBeenCalledWith( | ||
| expect.objectContaining({ homeDir: '/home/k', port: 4321 }), | ||
| ); | ||
| }); | ||
|
|
||
| it('closes the server after shutdown', async () => { | ||
| const close = vi.fn(async () => {}); | ||
| const { deps } = makeDeps({ | ||
| startVisServer: vi.fn(async () => ({ | ||
| port: 41234, | ||
| host: '127.0.0.1', | ||
| url: 'http://127.0.0.1:41234/', | ||
| close, | ||
| })) as unknown as VisDeps['startVisServer'], | ||
| }); | ||
| await handleVis(deps, { open: false }); | ||
| expect(close).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| it('reports a clean error and exits when the server fails to start', async () => { | ||
| const errored: string[] = []; | ||
| const { deps, opened } = makeDeps({ | ||
| startVisServer: vi.fn(async () => { | ||
| throw new Error('listen EADDRINUSE: address already in use 127.0.0.1:4321'); | ||
| }) as unknown as VisDeps['startVisServer'], | ||
| stderr: { | ||
| write: (s: string) => { | ||
| errored.push(s); | ||
| return true; | ||
| }, | ||
| }, | ||
| waitForShutdown: vi.fn(async () => {}), | ||
| }); | ||
| await handleVis(deps, { open: true, port: 4321 }); | ||
| expect(errored.join('')).toContain('Failed to start kimi vis'); | ||
| expect(errored.join('')).toContain('EADDRINUSE'); | ||
| expect(deps.exit).toHaveBeenCalledWith(1); | ||
| // Nothing past the failed start should run. | ||
| expect(opened).toEqual([]); | ||
| expect(deps.waitForShutdown).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.