mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-14 19:25:35 +00:00
feat(kimi-inspect): add kap-server web inspector with dev-only /api/v1/debug RPC surface (#1806)
* feat(kimi-inspect): add web inspector for kap-server /api/v2 surface - new apps/kimi-inspect app: connect screen (server URL + optional bearer token, persisted in localStorage, deep-linkable via ?url=/?token=), workspace/session browser sidebar, per-session chat view, and live Service panels with data and trigger buttons for Session/Agent scopes - built on @moonshot-ai/klient (HTTP for calls, /api/v2/ws for events); Vite dev server proxies /api to a running kap-server - register the workspace in AGENTS.md project map and flake.nix workspacePaths/workspaceNames * fix(kimi-inspect): align dependency versions with the workspace (sherif) * feat: dev /api/v1/debug RPC surface and kimi-inspect channel rework - kimi-inspect: replace @moonshot-ai/klient with an in-app old-klient-style channel layer (service-bound IChannel, HTTP ProxyChannel, shared /api/v2/ws socket with ref-counted event listens), typed by agent-core-v2 interfaces; /channels descriptors + serviceByName keep every wire protocol loaded 1:1 - kimi-inspect: local server auto-discovery (Vite middleware over the kap-server instance registry + home token), zero-config startup connect, and a header switcher for runtime server switching - kap-server: wire the dormant --debug-endpoints flag to a new whitelist-free /api/v1/debug dispatcher (every scoped service callable), gated to loopback binds; repo dev scripts pass the flag - kimi-inspect: probe the debug surface at connect, falling back to /api/v2 on servers without it - tests: channel + discovery unit tests in kimi-inspect; debug RPC and loopback-gating coverage in the kap-server rpc/debugNonloopback suites * chore(changesets): ignore @moonshot-ai/kimi-inspect The private dev app never ships, so it should never appear in a changeset. Add it to the changeset config ignore list (next to vis*) and note the rule in the gen-changesets skill.
This commit is contained in:
parent
a76d54bd8a
commit
9b496946dc
42 changed files with 4239 additions and 112 deletions
|
|
@ -21,7 +21,7 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including
|
|||
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`.
|
||||
- **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output.
|
||||
5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump.
|
||||
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled.
|
||||
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. `@moonshot-ai/kimi-inspect` (a private dev app that never ships) is likewise ignored and must never appear in a changeset frontmatter.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
"ignore": [
|
||||
"@moonshot-ai/vis",
|
||||
"@moonshot-ai/vis-server",
|
||||
"@moonshot-ai/vis-web"
|
||||
"@moonshot-ai/vis-web",
|
||||
"@moonshot-ai/kimi-inspect"
|
||||
],
|
||||
"snapshot": {
|
||||
"useCalculatedVersion": true,
|
||||
|
|
|
|||
5
.changeset/debug-rpc-surface.md
Normal file
5
.changeset/debug-rpc-surface.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Mount the dev-only /api/v1/debug RPC surface behind the --debug-endpoints flag, exposing every scoped service for local debugging on loopback binds. Pass --debug-endpoints to kimi server run to enable it.
|
||||
|
|
@ -17,13 +17,14 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
|
|||
- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`).
|
||||
- `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`.
|
||||
- `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays.
|
||||
- `apps/kimi-inspect`: web inspector for the v2 (kap-server) `/api/v2` surface — workspace/session browser, per-session chat, and live Service panels (data + trigger buttons) for the Session and Agent scopes. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v2`, `WsChannel` over the shared `/api/v2/ws` socket for events), typed by `agent-core-v2` Service interfaces; `GET {rpcBasePath}/channels` loads every wire protocol 1:1 — probing `/api/v1/debug` first (dev, whitelist-free) and falling back to `/api/v2`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime.
|
||||
- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities.
|
||||
- `packages/node-sdk`: the public TypeScript SDK and harness.
|
||||
- `packages/kosong`: the LLM / provider abstraction layer.
|
||||
- `packages/kaos`: the execution environment and file/process abstractions.
|
||||
- `packages/oauth`: Kimi OAuth and managed auth utilities.
|
||||
- `packages/telemetry`: shared client-side telemetry infrastructure.
|
||||
- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`.
|
||||
- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. With `--debug-endpoints` on a loopback bind it additionally mounts `/api/v1/debug/*` — the same reflection dispatcher as `/api/v2` but without the channel whitelist (every scoped Service callable, `src/transport/registerDebugRoutes.ts`); internal only, repo dev scripts pass the flag.
|
||||
- `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/http|ipc|memory`); all three return the same `Klient`. The package also hosts the e2e suites: dual-backend session/agent suites (`test/e2e/dual/`, in-memory + in-process server), `/api/v2` wire tests (`test/e2e/v2/`), the legacy `/api/v1` live suites (`test/e2e/legacy/`), and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`.
|
||||
|
||||
## Environment Requirements
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@
|
|||
"test:native:smoke": "node scripts/native/smoke.mjs",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
|
||||
"dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
|
||||
"dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
|
||||
"dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
|
||||
"dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
|
||||
"dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
|
||||
"dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
|
||||
"dev:server:restart": "node scripts/dev-server-restart.mjs",
|
||||
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
|
||||
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
|
||||
|
|
|
|||
12
apps/kimi-inspect/index.html
Normal file
12
apps/kimi-inspect/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kimi Inspect</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
apps/kimi-inspect/package.json
Normal file
40
apps/kimi-inspect/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "@moonshot-ai/kimi-inspect",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#/*": {
|
||||
"types": [
|
||||
"./src/*.ts",
|
||||
"./src/*.tsx",
|
||||
"./src/*/index.ts",
|
||||
"./src/*/index.tsx"
|
||||
],
|
||||
"default": "./src/*"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moonshot-ai/agent-core-v2": "workspace:^",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "6.0.2",
|
||||
"vite": "^6.3.3",
|
||||
"vitest": "4.1.4"
|
||||
}
|
||||
}
|
||||
126
apps/kimi-inspect/src/App.tsx
Normal file
126
apps/kimi-inspect/src/App.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* App shell — selection state, session resume, and the three WS event
|
||||
* subscriptions that feed the live bus:
|
||||
* core `events` — process-wide domain events
|
||||
* session `interactions` — pending approvals / questions
|
||||
* agent `events` — the active agent's live event stream
|
||||
* Layout: header / left sidebar (workspaces + sessions) / chat / inspector.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle';
|
||||
|
||||
import { ChatView } from './components/ChatView';
|
||||
import { Inspector } from './components/Inspector';
|
||||
import { ServerSwitcher } from './components/ServerSwitcher';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { useConnection } from './connection';
|
||||
import { LiveBusProvider, type Emit, type LiveEvent } from './live';
|
||||
import { Badge, errorMessage } from './ui';
|
||||
|
||||
export function App() {
|
||||
const { klient, wsState, baseUrl, disconnect } = useConnection();
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [agentId, setAgentId] = useState('main');
|
||||
const [ready, setReady] = useState(false);
|
||||
const [resumeError, setResumeError] = useState<unknown>(null);
|
||||
const emitRef = useRef<Emit | null>(null);
|
||||
const publish = (source: LiveEvent['source'], data: unknown) =>
|
||||
emitRef.current?.({ source, data, at: Date.now() });
|
||||
|
||||
// Core events — for the lifetime of the connection.
|
||||
useEffect(() => {
|
||||
const sub = klient.ws().listen('events', (data) => publish('core', data));
|
||||
return () => sub.dispose();
|
||||
}, [klient]);
|
||||
|
||||
// Session interactions — re-subscribe when the session changes.
|
||||
useEffect(() => {
|
||||
if (sessionId === null || !ready) return;
|
||||
const session = klient.ws().session(sessionId);
|
||||
const a = session.listen('interactions', (data) => publish('session', data));
|
||||
const b = session.listen('interactions:resolved', (data) => publish('session', data));
|
||||
return () => {
|
||||
a.dispose();
|
||||
b.dispose();
|
||||
};
|
||||
}, [klient, sessionId, ready]);
|
||||
|
||||
// Agent events — re-subscribe when session or agent changes.
|
||||
useEffect(() => {
|
||||
if (sessionId === null || !ready) return;
|
||||
const sub = klient
|
||||
.ws()
|
||||
.session(sessionId)
|
||||
.agent(agentId)
|
||||
.listen('events', (data) => publish('agent', data));
|
||||
return () => sub.dispose();
|
||||
}, [klient, sessionId, agentId, ready]);
|
||||
|
||||
// Resume (materialize) the session on the server when it is selected, so
|
||||
// session / agent scoped Services become reachable.
|
||||
useEffect(() => {
|
||||
if (sessionId === null) return;
|
||||
let cancelled = false;
|
||||
setReady(false);
|
||||
setResumeError(null);
|
||||
klient
|
||||
.core(ISessionLifecycleService)
|
||||
.resume(sessionId)
|
||||
.then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setResumeError(error);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [klient, sessionId]);
|
||||
|
||||
// Switching servers invalidates every session/agent selection: sessions
|
||||
// belong to the server they were listed from. The client and its WS
|
||||
// subscriptions rebuild from the new config on their own.
|
||||
useEffect(() => {
|
||||
setSessionId(null);
|
||||
setAgentId('main');
|
||||
}, [baseUrl]);
|
||||
|
||||
return (
|
||||
<LiveBusProvider busRef={emitRef}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center gap-3 border-b border-neutral-800 px-4 py-1.5">
|
||||
<span className="text-[12px] font-bold tracking-widest text-neutral-200">KIMI INSPECT</span>
|
||||
<ServerSwitcher />
|
||||
<Badge tone={wsState === 'open' ? 'green' : wsState === 'connecting' ? 'amber' : 'red'}>
|
||||
ws: {wsState}
|
||||
</Badge>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
className="text-[11px] text-neutral-500 hover:text-neutral-300"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Sidebar activeSessionId={sessionId} onSelectSession={setSessionId} />
|
||||
{resumeError !== null ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-[12px] text-red-400">
|
||||
Failed to open session: {errorMessage(resumeError)}
|
||||
</div>
|
||||
) : (
|
||||
<ChatView sessionId={sessionId} agentId={agentId} ready={ready} />
|
||||
)}
|
||||
<Inspector
|
||||
sessionId={sessionId}
|
||||
agentId={agentId}
|
||||
onAgentChange={setAgentId}
|
||||
ready={ready}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</LiveBusProvider>
|
||||
);
|
||||
}
|
||||
195
apps/kimi-inspect/src/channel/channel.test.ts
Normal file
195
apps/kimi-inspect/src/channel/channel.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Channel layer unit tests — `ProxyChannel` URL/envelope semantics, `makeProxy`
|
||||
* routing, and `WsChannel`'s ref-counted `listen`. The WS wire protocol itself
|
||||
* is covered by the kap-server contract and klient's e2e suites.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Event, IChannel } from './channel';
|
||||
import { RPCError } from './errors';
|
||||
import { makeProxy } from './proxy';
|
||||
import { ProxyChannel } from './proxyChannel';
|
||||
import { WsChannel } from './wsChannel';
|
||||
import type { WsSocket } from './wsSocket';
|
||||
|
||||
const ok = (data: unknown) => ({ code: 0, msg: 'success', data, request_id: 'r1' });
|
||||
|
||||
function fakeFetch(envelope: unknown) {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return { json: async () => envelope };
|
||||
}) as unknown as typeof fetch;
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
function stubSocket() {
|
||||
const state = {
|
||||
listens: 0,
|
||||
disposed: 0,
|
||||
handler: undefined as ((data: unknown) => void) | undefined,
|
||||
};
|
||||
const raw = {
|
||||
call: vi.fn(async () => 'ws-ret'),
|
||||
listen: (
|
||||
_scope: string,
|
||||
_event: string,
|
||||
_ids: unknown,
|
||||
handler: (data: unknown) => void,
|
||||
_service?: string,
|
||||
) => {
|
||||
state.listens += 1;
|
||||
state.handler = handler;
|
||||
return {
|
||||
dispose: () => {
|
||||
state.disposed += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return { raw, state, socket: raw as unknown as WsSocket };
|
||||
}
|
||||
|
||||
describe('ProxyChannel.call', () => {
|
||||
it('POSTs the command to the service base URL; no body and no header without args/token', async () => {
|
||||
const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' }));
|
||||
const channel = new ProxyChannel({
|
||||
baseUrl: 'http://h:1/api/v2/session/s%201/agent/main/agentRPCService',
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const result = await channel.call('getModel', []);
|
||||
expect(result).toEqual({ id: 's1' });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.url).toBe('http://h:1/api/v2/session/s%201/agent/main/agentRPCService/getModel');
|
||||
expect(calls[0]!.init?.method).toBe('POST');
|
||||
expect(calls[0]!.init?.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends the complete argument array as the JSON body, plus the bearer token', async () => {
|
||||
const { calls, fetchImpl } = fakeFetch(ok(null));
|
||||
const channel = new ProxyChannel({
|
||||
baseUrl: 'http://h:2/api/v2/configService',
|
||||
token: 'tok',
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
await channel.call('set', ['workspace', { theme: 'dark' }]);
|
||||
expect(calls[0]!.init?.body).toBe(JSON.stringify(['workspace', { theme: 'dark' }]));
|
||||
expect(calls[0]!.init?.headers).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer tok',
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps the envelope and throws RPCError on a non-zero code', async () => {
|
||||
const { fetchImpl } = fakeFetch({
|
||||
code: 40401,
|
||||
msg: 'session not found',
|
||||
data: null,
|
||||
request_id: 'r2',
|
||||
details: { id: 's9' },
|
||||
});
|
||||
const channel = new ProxyChannel({ baseUrl: 'http://h:3/api/v2/sessionIndex', fetch: fetchImpl });
|
||||
const err: unknown = await channel.call('get', ['s9']).catch((error: unknown) => error);
|
||||
expect(err).toBeInstanceOf(RPCError);
|
||||
expect((err as RPCError).code).toBe(40401);
|
||||
expect((err as RPCError).message).toBe('session not found');
|
||||
expect((err as RPCError).details).toEqual({ id: 's9' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeProxy', () => {
|
||||
interface DemoService {
|
||||
read(id: string, n: number): Promise<string>;
|
||||
onDidChangeMetadata: Event<{ title: string }>;
|
||||
}
|
||||
|
||||
it('routes methods to call and onXxx members to listen', async () => {
|
||||
const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] };
|
||||
const channel: IChannel = {
|
||||
call: async <T,>(command: string, args?: unknown[]): Promise<T> => {
|
||||
seen.calls.push([command, args ?? []]);
|
||||
return 'ret' as T;
|
||||
},
|
||||
listen: <T,>(event: string): Event<T> => {
|
||||
seen.listens.push(event);
|
||||
return () => ({ dispose: () => {} });
|
||||
},
|
||||
};
|
||||
const svc = makeProxy<DemoService>(channel);
|
||||
await expect(svc.read('a', 1)).resolves.toBe('ret');
|
||||
expect(seen.calls).toEqual([['read', ['a', 1]]]);
|
||||
const d = svc.onDidChangeMetadata(() => {});
|
||||
d.dispose();
|
||||
expect(seen.listens).toEqual(['onDidChangeMetadata']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WsChannel', () => {
|
||||
it('forwards calls over the socket with scope + service + ids', async () => {
|
||||
const { raw, socket } = stubSocket();
|
||||
const channel = new WsChannel({
|
||||
socket,
|
||||
scope: 'agent',
|
||||
service: 'agentRPCService',
|
||||
sessionId: 's1',
|
||||
agentId: 'main',
|
||||
});
|
||||
const result = await channel.call('getModel', [{}]);
|
||||
expect(result).toBe('ws-ret');
|
||||
expect(raw.call).toHaveBeenCalledWith('agent', 'agentRPCService', 'getModel', [{}], {
|
||||
sessionId: 's1',
|
||||
agentId: 'main',
|
||||
});
|
||||
});
|
||||
|
||||
it('multiplexes local listeners onto one remote subscription', () => {
|
||||
const { state, socket } = stubSocket();
|
||||
const channel = new WsChannel({
|
||||
socket,
|
||||
scope: 'session',
|
||||
service: 'sessionMetadata',
|
||||
sessionId: 's1',
|
||||
});
|
||||
const onDidChange = channel.listen<unknown>('onDidChangeMetadata');
|
||||
const seen: unknown[] = [];
|
||||
const sub1 = onDidChange((e) => seen.push(['l1', e]));
|
||||
const sub2 = onDidChange((e) => seen.push(['l2', e]));
|
||||
expect(state.listens).toBe(1);
|
||||
state.handler!({ title: 't' });
|
||||
expect(seen).toEqual([
|
||||
['l1', { title: 't' }],
|
||||
['l2', { title: 't' }],
|
||||
]);
|
||||
sub1.dispose();
|
||||
expect(state.disposed).toBe(0);
|
||||
sub2.dispose();
|
||||
expect(state.disposed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxyChannel.listen', () => {
|
||||
it('throws without a WS binding', () => {
|
||||
const channel = new ProxyChannel({
|
||||
baseUrl: 'http://h:4/api/v2/configService',
|
||||
fetch: fakeFetch(ok(null)).fetchImpl,
|
||||
});
|
||||
expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/);
|
||||
});
|
||||
|
||||
it('delegates to one lazily-created WsChannel when a WS binding is provided', () => {
|
||||
const { state, socket } = stubSocket();
|
||||
const factory = vi.fn(() => new WsChannel({ socket, scope: 'core', service: 'configService' }));
|
||||
const channel = new ProxyChannel(
|
||||
{ baseUrl: 'http://h:5/api/v2/configService', fetch: fakeFetch(ok(null)).fetchImpl },
|
||||
factory,
|
||||
);
|
||||
const sub = channel.listen<unknown>('onDidChangeConfiguration')(() => {});
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(state.listens).toBe(1);
|
||||
channel.listen('onDidSectionChange');
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
sub.dispose();
|
||||
expect(state.disposed).toBe(1);
|
||||
});
|
||||
});
|
||||
42
apps/kimi-inspect/src/channel/channel.ts
Normal file
42
apps/kimi-inspect/src/channel/channel.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Transport-agnostic channel contract for the `/api/v2` client — the
|
||||
* old-klient / VS Code `ProxyChannel` model: the channel is bound to one
|
||||
* Service (the URL carries the scope + the Service's decorator id) and
|
||||
* `command` is the method name, invoked by reflection on the server.
|
||||
* `listen` is for the Service's `onXxx` emitter events over the persistent
|
||||
* `/api/v2/ws` transport.
|
||||
*/
|
||||
|
||||
import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface Event<T> {
|
||||
(listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable;
|
||||
}
|
||||
|
||||
/** The client-facing channel contract. Calls always carry the complete argument array. */
|
||||
export interface IChannel {
|
||||
call<T>(command: string, args?: unknown[]): Promise<T>;
|
||||
listen<T>(event: string, arg?: unknown): Event<T>;
|
||||
}
|
||||
|
||||
/** A wire Service reference: a DI decorator (stringifies to the wire channel
|
||||
* name) or the raw channel name as a string. */
|
||||
export type ServiceRef<T> = ServiceIdentifier<T> | string;
|
||||
|
||||
/**
|
||||
* Remote view of a Service contract: every method becomes an async wire call;
|
||||
* `onXxx` event members (`Event<T>` — callables returning `IDisposable`) stay
|
||||
* subscribable events; plain non-function members become zero-arg property
|
||||
* reads (the `/api/v2` dispatcher returns non-function members as-is).
|
||||
*/
|
||||
export type ServiceProxy<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer A) => infer R
|
||||
? R extends IDisposable
|
||||
? T[K]
|
||||
: (...args: A) => Promise<Awaited<R>>
|
||||
: () => Promise<Awaited<T[K]>>;
|
||||
};
|
||||
112
apps/kimi-inspect/src/channel/channels.ts
Normal file
112
apps/kimi-inspect/src/channel/channels.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Protocol loading — the server's `{rpcBasePath}/channels` endpoint is its
|
||||
* self-description of every wire-callable Service (name, scope, domain,
|
||||
* methods + properties). On dev servers that's the whitelist-free
|
||||
* `/api/v1/debug`; older/production servers fall back to the `/api/v2`
|
||||
* whitelist set (`probeRpcBasePath` decides). Paired with `serviceByName`,
|
||||
* each descriptor materializes 1:1 into a typed proxy of the channel layer:
|
||||
* same channel name, same scope route, methods invoked by reflection.
|
||||
*/
|
||||
|
||||
import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
|
||||
|
||||
import { RPCError } from './errors';
|
||||
import type { InspectClient } from './client';
|
||||
import type { ServiceProxy } from './channel';
|
||||
|
||||
/** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */
|
||||
export type ChannelScope = 'app' | 'session' | 'agent';
|
||||
|
||||
/** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v2/channels`). */
|
||||
export interface ChannelDescriptor {
|
||||
readonly name: string;
|
||||
readonly scope: ChannelScope;
|
||||
readonly domain: string;
|
||||
readonly methods: readonly {
|
||||
readonly name: string;
|
||||
readonly kind: 'method' | 'property';
|
||||
readonly arity: number;
|
||||
readonly params: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Fetch the dynamic channel list (unwrapped from the project envelope),
|
||||
* from whichever RPC surface the connection probed (`rpcBasePath`). */
|
||||
export async function fetchChannelDescriptors(
|
||||
client: InspectClient,
|
||||
): Promise<readonly ChannelDescriptor[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (client.token !== undefined && client.token !== '') {
|
||||
headers['authorization'] = `Bearer ${client.token}`;
|
||||
}
|
||||
const res = await fetch(`${client.baseUrl}${client.rpcBasePath}/channels`, { headers });
|
||||
const envelope = (await res.json()) as {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: readonly ChannelDescriptor[];
|
||||
};
|
||||
if (envelope.code !== 0) throw new RPCError(envelope.code, envelope.msg);
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
/** The dev server's whitelist-free debug surface (`--debug-endpoints`). */
|
||||
export const DEBUG_RPC_BASE = '/api/v1/debug' as const;
|
||||
/** The stable whitelist RPC surface — fallback when debug is not mounted. */
|
||||
export const V2_RPC_BASE = '/api/v2' as const;
|
||||
|
||||
export type RpcBasePath = typeof DEBUG_RPC_BASE | typeof V2_RPC_BASE;
|
||||
|
||||
/**
|
||||
* Probe which RPC surface a server offers: dev servers started with
|
||||
* `--debug-endpoints` answer `/api/v1/debug/channels`; older/production
|
||||
* servers only the whitelisted `/api/v2`. Always resolves (fallback `/api/v2`).
|
||||
*/
|
||||
export async function probeRpcBasePath(options: {
|
||||
readonly baseUrl: string;
|
||||
readonly token?: string;
|
||||
}): Promise<RpcBasePath> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.token !== undefined && options.token !== '') {
|
||||
headers['authorization'] = `Bearer ${options.token}`;
|
||||
}
|
||||
const res = await fetch(
|
||||
`${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`,
|
||||
{ headers },
|
||||
);
|
||||
if (res.ok) {
|
||||
const envelope = (await res.json()) as { code?: number };
|
||||
if (envelope.code === 0) return DEBUG_RPC_BASE;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the v2 fallback
|
||||
}
|
||||
return V2_RPC_BASE;
|
||||
}
|
||||
|
||||
export interface ServiceTarget {
|
||||
readonly scope: ChannelScope;
|
||||
readonly sessionId?: string;
|
||||
readonly agentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Service proxy by wire channel name. The DI decorator registry keys
|
||||
* identifiers by name, so re-creating the decorator resolves to the same token
|
||||
* the server channel registry created — the name is the wire channel, which is
|
||||
* all the proxy uses. Returns `undefined` when the target scope needs a
|
||||
* session/agent id that isn't available.
|
||||
*/
|
||||
export function serviceByName<T extends object>(
|
||||
client: InspectClient,
|
||||
name: string,
|
||||
target: ServiceTarget,
|
||||
): ServiceProxy<T> | undefined {
|
||||
const id = createDecorator<T>(name);
|
||||
if (target.scope === 'app') return client.core(id);
|
||||
if (target.sessionId === undefined) return undefined;
|
||||
const base = client.session(target.sessionId);
|
||||
if (target.scope === 'session') return base.service(id);
|
||||
if (target.agentId === undefined) return undefined;
|
||||
return base.agent(target.agentId).service(id);
|
||||
}
|
||||
149
apps/kimi-inspect/src/channel/client.ts
Normal file
149
apps/kimi-inspect/src/channel/client.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Inspect client — the app's `/api/v2` entry point, in the old-klient VS Code
|
||||
* `ProxyChannel` model: a three-level scope entry (`core` / `session` /
|
||||
* `agent`) whose every Service handle is a `makeProxy`-materialized typed
|
||||
* proxy over a service-bound channel, plus the one shared `/api/v2/ws` socket
|
||||
* for scope event streams and connection state.
|
||||
*
|
||||
* const client = createInspectClient({ url: 'http://127.0.0.1:58627' });
|
||||
* await client.core(ISessionIndex).list({});
|
||||
* await client.session('s1').service(ISessionMetadata).read();
|
||||
* await client.session('s1').agent('main').service(IAgentRPCService).getModel();
|
||||
*
|
||||
* The `agent-core-v2` service token is the whole key: its type parameter `T`
|
||||
* types the returned proxy, and its decorator id (`String(id)`) is the channel
|
||||
* name in the URL. Calls ride HTTP (`ProxyChannel`); the proxy's `onXxx`
|
||||
* emitter events and the scope streams (`events` / `interactions` /
|
||||
* `interactions:resolved`) ride the one shared `WsSocket`.
|
||||
*/
|
||||
|
||||
import type { ServiceProxy, ServiceRef } from './channel';
|
||||
import { makeProxy } from './proxy';
|
||||
import { ProxyChannel } from './proxyChannel';
|
||||
import { WsChannel } from './wsChannel';
|
||||
import {
|
||||
WsSocket,
|
||||
type WsScopeIds,
|
||||
type WsScopeKind,
|
||||
type WsSocketState,
|
||||
type WsSubscription,
|
||||
} from './wsSocket';
|
||||
|
||||
export interface InspectAgentHandle {
|
||||
service<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
|
||||
}
|
||||
|
||||
export interface InspectSessionHandle extends InspectAgentHandle {
|
||||
agent(agentId: string): InspectAgentHandle;
|
||||
}
|
||||
|
||||
export interface InspectWsAgent {
|
||||
listen(stream: string, handler: (data: unknown) => void): WsSubscription;
|
||||
}
|
||||
|
||||
export interface InspectWsSession extends InspectWsAgent {
|
||||
agent(agentId: string): InspectWsAgent;
|
||||
}
|
||||
|
||||
/** The one owned WebSocket: connection state plus per-scope stream subscriptions. */
|
||||
export interface InspectWs {
|
||||
readonly state: WsSocketState;
|
||||
onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription;
|
||||
listen(stream: string, handler: (data: unknown) => void): WsSubscription;
|
||||
session(sessionId: string): InspectWsSession;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface InspectClient {
|
||||
/** Absolute server base URL, e.g. `http://127.0.0.1:58627`. */
|
||||
readonly baseUrl: string;
|
||||
/** Bearer token in use, when any. */
|
||||
readonly token?: string;
|
||||
/** RPC base path for calls and `/channels`: `/api/v1/debug` on dev servers
|
||||
* (whitelist-free), `/api/v2` otherwise. Resolved by the connection probe. */
|
||||
readonly rpcBasePath: string;
|
||||
core<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
|
||||
session(sessionId: string): InspectSessionHandle;
|
||||
ws(): InspectWs;
|
||||
}
|
||||
|
||||
export interface InspectClientOptions {
|
||||
/** Base URL of the server, e.g. `http://127.0.0.1:58627`. */
|
||||
readonly url: string;
|
||||
/** Optional bearer token. */
|
||||
readonly token?: string;
|
||||
/** RPC base path for service calls + `/channels` introspection. Default
|
||||
* `/api/v2`; the connection layer probes and passes `/api/v1/debug` when
|
||||
* the server mounts the dev debug surface (`--debug-endpoints`). */
|
||||
readonly rpcBasePath?: string;
|
||||
}
|
||||
|
||||
export function createInspectClient(options: InspectClientOptions): InspectClient {
|
||||
const url = options.url.replace(/\/$/, '');
|
||||
const rpcBasePath = options.rpcBasePath ?? '/api/v2';
|
||||
const socket = new WsSocket(options);
|
||||
|
||||
/** Materialize a typed proxy for one Service on one scope binding. */
|
||||
function proxy<T extends object>(
|
||||
scopePath: string,
|
||||
scope: WsScopeKind,
|
||||
ids: WsScopeIds,
|
||||
id: ServiceRef<T>,
|
||||
): ServiceProxy<T> {
|
||||
const service = String(id);
|
||||
return makeProxy<T>(
|
||||
new ProxyChannel(
|
||||
{ baseUrl: `${url}${rpcBasePath}${scopePath}/${service}`, token: options.token },
|
||||
() => new WsChannel({ socket, scope, service, ...ids }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function wsListen(ids: WsScopeIds): InspectWsAgent {
|
||||
return {
|
||||
listen: (stream, handler) =>
|
||||
socket.listen(
|
||||
ids.agentId !== undefined ? 'agent' : ids.sessionId !== undefined ? 'session' : 'core',
|
||||
stream,
|
||||
ids,
|
||||
handler,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const ws: InspectWs = {
|
||||
get state() {
|
||||
return socket.currentState;
|
||||
},
|
||||
onDidChangeState: (listener) => socket.onDidChangeState(listener),
|
||||
...wsListen({}),
|
||||
session: (sessionId) => ({
|
||||
...wsListen({ sessionId }),
|
||||
agent: (agentId) => wsListen({ sessionId, agentId }),
|
||||
}),
|
||||
close: () => {
|
||||
socket.close();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
baseUrl: url,
|
||||
token: options.token,
|
||||
rpcBasePath,
|
||||
core: (id) => proxy('', 'core', {}, id),
|
||||
session: (sessionId) => {
|
||||
const scopePath = `/session/${encodeURIComponent(sessionId)}`;
|
||||
return {
|
||||
service: (id) => proxy(scopePath, 'session', { sessionId }, id),
|
||||
agent: (agentId) => ({
|
||||
service: (subId) =>
|
||||
proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, 'agent', {
|
||||
sessionId,
|
||||
agentId,
|
||||
}, subId),
|
||||
}),
|
||||
};
|
||||
},
|
||||
ws: () => ws,
|
||||
};
|
||||
}
|
||||
15
apps/kimi-inspect/src/channel/errors.ts
Normal file
15
apps/kimi-inspect/src/channel/errors.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Client-side RPC error surfaced when the `/api/v2` envelope carries a non-zero
|
||||
* `code`. Mirrors the server envelope (`{ code, msg, data, request_id }`) — the
|
||||
* numeric `code` is the stable branch key across the wire, not `instanceof`.
|
||||
*/
|
||||
export class RPCError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RPCError';
|
||||
}
|
||||
}
|
||||
8
apps/kimi-inspect/src/channel/index.ts
Normal file
8
apps/kimi-inspect/src/channel/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from './channel';
|
||||
export * from './channels';
|
||||
export * from './client';
|
||||
export * from './errors';
|
||||
export * from './proxy';
|
||||
export * from './proxyChannel';
|
||||
export * from './wsChannel';
|
||||
export * from './wsSocket';
|
||||
21
apps/kimi-inspect/src/channel/proxy.ts
Normal file
21
apps/kimi-inspect/src/channel/proxy.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Typed proxy turning an `IChannel` (bound to one Service) into a value
|
||||
* satisfying that Service's interface `T` — VS Code's `ProxyChannel.toService`.
|
||||
*
|
||||
* Members named `onUpperCase` become channel events; every other property access
|
||||
* becomes a function forwarding its complete argument array to `channel.call`
|
||||
* (the dispatcher also answers property reads this way). The shared interface
|
||||
* `T` is the whole contract, with no per-method allowlist or renaming.
|
||||
*/
|
||||
|
||||
import type { IChannel, ServiceProxy } from './channel';
|
||||
|
||||
export function makeProxy<T extends object>(channel: IChannel): ServiceProxy<T> {
|
||||
return new Proxy({} as ServiceProxy<T>, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop !== 'string') return undefined;
|
||||
if (/^on[A-Z]/.test(prop)) return channel.listen(prop);
|
||||
return (...args: unknown[]) => channel.call(prop, args);
|
||||
},
|
||||
});
|
||||
}
|
||||
81
apps/kimi-inspect/src/channel/proxyChannel.ts
Normal file
81
apps/kimi-inspect/src/channel/proxyChannel.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* `ProxyChannel` — an `IChannel` bound to one Service, routing `call`s to
|
||||
* kap-server's `/api/v2` HTTP surface. Every call `POST`s the method name to
|
||||
* the Service base URL with the complete argument array as the JSON body,
|
||||
* then unwraps the project envelope: a non-zero `code` throws `RPCError`,
|
||||
* otherwise `data` is returned. Non-function members answer as property reads
|
||||
* through the same route (the dispatcher returns them as-is).
|
||||
*
|
||||
* `listen` cannot be served by HTTP; when the client supplies a WS binding
|
||||
* (`events` factory) it is delegated to a lazily-created `WsChannel` bound to
|
||||
* the same scope + Service, so the Service's `onXxx` emitter events work 1:1.
|
||||
* Without a factory `listen` throws, matching the old HTTP-only channel.
|
||||
*/
|
||||
|
||||
import type { Event, IChannel } from './channel';
|
||||
import { RPCError } from './errors';
|
||||
import type { WsChannel } from './wsChannel';
|
||||
|
||||
interface Envelope<T> {
|
||||
readonly code: number;
|
||||
readonly msg: string;
|
||||
readonly data: T;
|
||||
readonly request_id: string;
|
||||
readonly details?: unknown;
|
||||
}
|
||||
|
||||
export interface ProxyChannelOptions {
|
||||
/** Service base URL, e.g. `http://127.0.0.1:58627/api/v2[/session/:sid[/agent/:aid]]/:service`. */
|
||||
readonly baseUrl: string;
|
||||
/** Optional bearer token. */
|
||||
readonly token?: string;
|
||||
/** `fetch` implementation; defaults to the global `fetch`. */
|
||||
readonly fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export class ProxyChannel implements IChannel {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token?: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly eventsFactory?: () => WsChannel;
|
||||
private eventsChannel: WsChannel | undefined;
|
||||
|
||||
constructor(opts: ProxyChannelOptions, events?: () => WsChannel) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
this.token = opts.token;
|
||||
// Bind the global fetch: browsers throw "Illegal invocation" when the
|
||||
// native function is invoked with a non-Window receiver.
|
||||
this.fetchImpl = opts.fetch ?? fetch.bind(globalThis);
|
||||
this.eventsFactory = events;
|
||||
}
|
||||
|
||||
async call<T>(command: string, args: unknown[] = []): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
if (args.length > 0) {
|
||||
headers['content-type'] = 'application/json';
|
||||
body = JSON.stringify(args);
|
||||
}
|
||||
if (this.token !== undefined) {
|
||||
headers['authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
const res = await this.fetchImpl(`${this.baseUrl}/${command}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const envelope = (await res.json()) as Envelope<T>;
|
||||
if (envelope.code !== 0) {
|
||||
throw new RPCError(envelope.code, envelope.msg, envelope.details);
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
listen<T>(event: string): Event<T> {
|
||||
if (this.eventsFactory === undefined) {
|
||||
throw new Error('events are not supported on this channel (no WS binding)');
|
||||
}
|
||||
this.eventsChannel ??= this.eventsFactory();
|
||||
return this.eventsChannel.listen(event);
|
||||
}
|
||||
}
|
||||
79
apps/kimi-inspect/src/channel/wsChannel.ts
Normal file
79
apps/kimi-inspect/src/channel/wsChannel.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* `WsChannel` — an `IChannel` bound to one Service that forwards `call`s and
|
||||
* `listen`s over the shared `/api/v2/ws` socket instead of HTTP. Same VS Code
|
||||
* shape as `ProxyChannel` (the URL equivalent is the `{scope, service, ids}`
|
||||
* triple the socket puts on each frame). `listen` multiplexes local listeners
|
||||
* onto one remote subscription: the first local listener opens it, the last
|
||||
* `dispose()` tears it down, and it survives reconnects until then.
|
||||
*/
|
||||
|
||||
import type { Event, IChannel } from './channel';
|
||||
import type { WsScopeIds, WsScopeKind, WsSocket } from './wsSocket';
|
||||
|
||||
export interface WsChannelOptions {
|
||||
readonly socket: WsSocket;
|
||||
readonly scope: WsScopeKind;
|
||||
/** Service channel name (the decorator id, `String(id)`). */
|
||||
readonly service: string;
|
||||
readonly sessionId?: string;
|
||||
readonly agentId?: string;
|
||||
}
|
||||
|
||||
interface SharedEvent {
|
||||
readonly listeners: Set<{ listener: (data: unknown) => unknown; thisArg: unknown }>;
|
||||
remote?: { dispose(): void };
|
||||
}
|
||||
|
||||
export class WsChannel implements IChannel {
|
||||
private readonly socket: WsSocket;
|
||||
private readonly scope: WsScopeKind;
|
||||
private readonly service: string;
|
||||
private readonly ids: WsScopeIds;
|
||||
private readonly events = new Map<string, SharedEvent>();
|
||||
|
||||
constructor(opts: WsChannelOptions) {
|
||||
this.socket = opts.socket;
|
||||
this.scope = opts.scope;
|
||||
this.service = opts.service;
|
||||
this.ids = { sessionId: opts.sessionId, agentId: opts.agentId };
|
||||
}
|
||||
|
||||
call<T>(command: string, args: unknown[] = []): Promise<T> {
|
||||
return this.socket.call(this.scope, this.service, command, args, this.ids);
|
||||
}
|
||||
|
||||
listen<T>(event: string): Event<T> {
|
||||
let shared = this.events.get(event);
|
||||
if (shared === undefined) {
|
||||
shared = { listeners: new Set() };
|
||||
this.events.set(event, shared);
|
||||
}
|
||||
return (listener, thisArg, disposables) => {
|
||||
const entry = { listener: listener as (data: unknown) => unknown, thisArg };
|
||||
shared.listeners.add(entry);
|
||||
shared.remote ??= this.socket.listen(
|
||||
this.scope,
|
||||
event,
|
||||
this.ids,
|
||||
(data) => {
|
||||
for (const current of shared.listeners) current.listener.call(current.thisArg, data);
|
||||
},
|
||||
this.service,
|
||||
);
|
||||
let disposed = false;
|
||||
const subscription = {
|
||||
dispose: (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
shared.listeners.delete(entry);
|
||||
if (shared.listeners.size === 0) {
|
||||
shared.remote?.dispose();
|
||||
shared.remote = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
disposables?.push(subscription);
|
||||
return subscription;
|
||||
};
|
||||
}
|
||||
}
|
||||
456
apps/kimi-inspect/src/channel/wsSocket.ts
Normal file
456
apps/kimi-inspect/src/channel/wsSocket.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* `/api/v2/ws` socket — the persistent WebSocket transport behind the inspect
|
||||
* client's `WsChannel` (Service emitter `listen`s) and scope event streams.
|
||||
*
|
||||
* Speaks the kap-server v2 JSON protocol: one socket multiplexes RPC `call`s
|
||||
* and event `listen`s, correlated by client-chosen ids. Adds the client-side
|
||||
* safety features a long-lived devtool connection needs: `hello` handshake,
|
||||
* `ping`→`pong` heartbeat answers, per-call timeouts, and opt-out automatic
|
||||
* reconnect (active `listen`s are re-subscribed after a reconnect; in-flight
|
||||
* calls reject on close — the server cannot resume them).
|
||||
*
|
||||
* The bearer token is presented at the upgrade through the
|
||||
* `kimi-code.bearer.<token>` subprotocol (the only credential channel a browser
|
||||
* WebSocket has) and again in the `hello` frame for the present-only handshake
|
||||
* check. Works against the DOM WebSocket (browsers, Node ≥ 21); any compatible
|
||||
* implementation can be injected for tests.
|
||||
*/
|
||||
|
||||
import { RPCError } from './errors';
|
||||
|
||||
/** Wire scope kinds, mirroring kap-server's `ScopeKind`. */
|
||||
export type WsScopeKind = 'core' | 'session' | 'agent';
|
||||
|
||||
/** Scope coordinates carried on `call` / `listen` frames. */
|
||||
export interface WsScopeIds {
|
||||
readonly sessionId?: string;
|
||||
readonly agentId?: string;
|
||||
}
|
||||
|
||||
export type WsSocketState = 'connecting' | 'open' | 'closed';
|
||||
|
||||
export interface WsSubscription {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Minimal DOM-compatible WebSocket surface this module codes against. */
|
||||
export interface WsLike {
|
||||
readonly readyState: number;
|
||||
send(data: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: never) => void): void;
|
||||
}
|
||||
|
||||
export interface WsLikeCtor {
|
||||
new (url: string, protocols?: string | string[]): WsLike;
|
||||
readonly OPEN: number;
|
||||
}
|
||||
|
||||
export interface WsSocketOptions {
|
||||
/** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v2/ws` URL. */
|
||||
readonly url: string;
|
||||
/** Optional bearer token. */
|
||||
readonly token?: string;
|
||||
/** WebSocket implementation; defaults to the global `WebSocket`. */
|
||||
readonly WebSocketImpl?: WsLikeCtor;
|
||||
/** Reconnect after an unexpected close. Default `true`. */
|
||||
readonly autoReconnect?: boolean;
|
||||
/** Base delay (ms) for the reconnect backoff. Default `500`. */
|
||||
readonly reconnectDelayMs?: number;
|
||||
/** Per-call deadline (ms). Default `30000`. */
|
||||
readonly callTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PendingCall {
|
||||
readonly resolve: (data: unknown) => void;
|
||||
readonly reject: (err: Error) => void;
|
||||
readonly timer: ReturnType<typeof setTimeout> | undefined;
|
||||
}
|
||||
|
||||
interface ActiveListen {
|
||||
readonly scope: WsScopeKind;
|
||||
readonly service?: string;
|
||||
readonly event: string;
|
||||
readonly ids: WsScopeIds;
|
||||
readonly handler: (data: unknown) => void;
|
||||
readonly onError?: (error: Error) => void;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export interface WsListenError {
|
||||
readonly scope: WsScopeKind;
|
||||
readonly service?: string;
|
||||
readonly event: string;
|
||||
readonly error: Error;
|
||||
}
|
||||
|
||||
interface ServerFrame {
|
||||
readonly type: string;
|
||||
readonly id?: string;
|
||||
readonly data?: unknown;
|
||||
readonly code?: number;
|
||||
readonly msg?: string;
|
||||
readonly eventId?: string;
|
||||
}
|
||||
|
||||
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
|
||||
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class WsSocket {
|
||||
private readonly wsUrl: string;
|
||||
private readonly token?: string;
|
||||
private readonly WsCtor: WsLikeCtor;
|
||||
private readonly autoReconnect: boolean;
|
||||
private readonly reconnectDelayMs: number;
|
||||
private readonly callTimeoutMs: number;
|
||||
|
||||
private ws: WsLike | undefined;
|
||||
private state: WsSocketState = 'connecting';
|
||||
private manualClose = false;
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readyWaiters: { resolve: () => void; reject: (err: Error) => void }[] = [];
|
||||
|
||||
private readonly pending = new Map<string, PendingCall>();
|
||||
private readonly listens = new Map<string, ActiveListen>();
|
||||
private readonly eventControllers = new Map<string, AbortController>();
|
||||
private readonly stateListeners = new Set<(state: WsSocketState) => void>();
|
||||
private readonly listenErrorListeners = new Set<(event: WsListenError) => void>();
|
||||
|
||||
private seq = 0;
|
||||
private readonly idPrefix = `k${Date.now().toString(36)}`;
|
||||
|
||||
constructor(opts: WsSocketOptions) {
|
||||
this.wsUrl = toWsUrl(opts.url);
|
||||
this.token = opts.token;
|
||||
const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined);
|
||||
if (ctor === undefined) {
|
||||
throw new Error('no WebSocket implementation available; pass WebSocketImpl');
|
||||
}
|
||||
this.WsCtor = ctor;
|
||||
this.autoReconnect = opts.autoReconnect ?? true;
|
||||
this.reconnectDelayMs = opts.reconnectDelayMs ?? 500;
|
||||
this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
get currentState(): WsSocketState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription {
|
||||
this.stateListeners.add(listener);
|
||||
return { dispose: () => this.stateListeners.delete(listener) };
|
||||
}
|
||||
|
||||
onDidListenError(listener: (event: WsListenError) => void): WsSubscription {
|
||||
this.listenErrorListeners.add(listener);
|
||||
return { dispose: () => this.listenErrorListeners.delete(listener) };
|
||||
}
|
||||
|
||||
/** RPC call over the socket; rejects on `error` frame, timeout, or close. */
|
||||
async call<T>(
|
||||
scope: WsScopeKind,
|
||||
service: string,
|
||||
method: string,
|
||||
arg?: unknown,
|
||||
ids?: WsScopeIds,
|
||||
): Promise<T> {
|
||||
await this.whenReady();
|
||||
// The socket may have dropped between `whenReady` resolving and this
|
||||
// continuation running; never register a call we cannot send.
|
||||
if (this.state !== 'open') {
|
||||
throw new Error('ws closed');
|
||||
}
|
||||
const id = this.nextId();
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
const timer =
|
||||
this.callTimeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new RPCError(50001, `call timed out after ${this.callTimeoutMs}ms`));
|
||||
}, this.callTimeoutMs)
|
||||
: undefined;
|
||||
this.pending.set(id, {
|
||||
resolve: resolve as (data: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
});
|
||||
this.send({ type: 'call', id, scope, service, method, arg, ...ids });
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a scope event stream. The subscription survives reconnects
|
||||
* (re-sent after each reconnect) until `dispose()`d.
|
||||
*/
|
||||
listen(
|
||||
scope: WsScopeKind,
|
||||
event: string,
|
||||
ids: WsScopeIds,
|
||||
handler: (data: unknown) => void,
|
||||
service?: string,
|
||||
onError?: (error: Error) => void,
|
||||
): WsSubscription {
|
||||
const id = this.nextId();
|
||||
this.listens.set(id, { scope, service, event, ids, handler, onError, acknowledged: false });
|
||||
if (this.state === 'open') {
|
||||
this.send({ type: 'listen', id, scope, service, event, ...ids });
|
||||
}
|
||||
return {
|
||||
dispose: () => {
|
||||
if (!this.listens.delete(id)) return;
|
||||
if (this.state === 'open') {
|
||||
this.send({ type: 'unlisten', id });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Tear the socket down permanently; rejects in-flight calls. */
|
||||
close(): void {
|
||||
this.manualClose = true;
|
||||
if (this.reconnectTimer !== undefined) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
this.setState('closed');
|
||||
this.ws?.close();
|
||||
this.ws = undefined;
|
||||
this.failAll(new Error('ws closed'));
|
||||
this.rejectReadyWaiters(new Error('ws closed'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private nextId(): string {
|
||||
this.seq += 1;
|
||||
return `${this.idPrefix}_${this.seq}`;
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
this.setState('connecting');
|
||||
const protocols =
|
||||
this.token !== undefined && this.token.length > 0
|
||||
? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`]
|
||||
: undefined;
|
||||
let ws: WsLike;
|
||||
try {
|
||||
ws = new this.WsCtor(this.wsUrl, protocols);
|
||||
} catch (error) {
|
||||
this.scheduleReconnect(error);
|
||||
return;
|
||||
}
|
||||
this.ws = ws;
|
||||
ws.addEventListener('open', () => {
|
||||
this.onOpen();
|
||||
});
|
||||
ws.addEventListener('message', (event: { data: unknown }) => {
|
||||
this.onMessage(event.data);
|
||||
});
|
||||
ws.addEventListener('close', () => {
|
||||
this.onClose();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
// The 'close' event always follows 'error'; reconnect logic lives there.
|
||||
});
|
||||
}
|
||||
|
||||
private onOpen(): void {
|
||||
this.reconnectAttempt = 0;
|
||||
this.setState('open');
|
||||
this.send({ type: 'hello', token: this.token });
|
||||
for (const [id, sub] of this.listens) {
|
||||
this.send({
|
||||
type: 'listen',
|
||||
id,
|
||||
scope: sub.scope,
|
||||
service: sub.service,
|
||||
event: sub.event,
|
||||
...sub.ids,
|
||||
});
|
||||
}
|
||||
const waiters = this.readyWaiters;
|
||||
this.readyWaiters = [];
|
||||
for (const w of waiters) w.resolve();
|
||||
}
|
||||
|
||||
private onMessage(raw: unknown): void {
|
||||
let frame: ServerFrame;
|
||||
try {
|
||||
frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (frame.type) {
|
||||
case 'ready':
|
||||
case 'server_hello':
|
||||
return;
|
||||
case 'ping':
|
||||
this.send({ type: 'pong' });
|
||||
return;
|
||||
case 'result': {
|
||||
const p = this.take(frame.id);
|
||||
p?.resolve(frame.data);
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
const p = this.take(frame.id);
|
||||
if (p !== undefined) {
|
||||
p.reject(new RPCError(frame.code ?? 50001, frame.msg ?? 'error'));
|
||||
} else {
|
||||
const sub = this.listens.get(frame.id ?? '');
|
||||
if (sub !== undefined) {
|
||||
this.listens.delete(frame.id ?? '');
|
||||
const error = new RPCError(frame.code ?? 50001, frame.msg ?? 'error');
|
||||
sub.onError?.(error);
|
||||
queueMicrotask(() => {
|
||||
for (const listener of this.listenErrorListeners) {
|
||||
listener({ scope: sub.scope, service: sub.service, event: sub.event, error });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'listen_result': {
|
||||
const sub = this.listens.get(frame.id ?? '');
|
||||
if (sub !== undefined) sub.acknowledged = true;
|
||||
return;
|
||||
}
|
||||
case 'event': {
|
||||
const sub = this.listens.get(frame.id ?? '');
|
||||
if (sub === undefined) return;
|
||||
if (frame.eventId === undefined) {
|
||||
sub.handler(frame.data);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const eventKey = `${frame.id}:${frame.eventId}`;
|
||||
this.eventControllers.set(eventKey, controller);
|
||||
const waits: Promise<unknown>[] = [];
|
||||
const data = {
|
||||
...(frame.data as object),
|
||||
signal: controller.signal,
|
||||
waitUntil: (promise: Promise<unknown>) => waits.push(promise),
|
||||
};
|
||||
try {
|
||||
sub.handler(data);
|
||||
} catch {
|
||||
// Listener failures are fail-open for the server-side event.
|
||||
}
|
||||
void Promise.allSettled(waits).finally(() => {
|
||||
if (!this.eventControllers.delete(eventKey)) return;
|
||||
this.send({ type: 'event_result', id: frame.id, eventId: frame.eventId });
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'event_cancel': {
|
||||
const key = `${frame.id}:${frame.eventId}`;
|
||||
const controller = this.eventControllers.get(key);
|
||||
if (controller !== undefined) {
|
||||
this.eventControllers.delete(key);
|
||||
controller.abort();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onClose(): void {
|
||||
this.ws = undefined;
|
||||
for (const controller of this.eventControllers.values()) controller.abort();
|
||||
this.eventControllers.clear();
|
||||
this.failAll(new Error('ws closed'));
|
||||
if (this.manualClose || !this.autoReconnect) {
|
||||
this.setState('closed');
|
||||
this.rejectReadyWaiters(new Error('ws closed'));
|
||||
return;
|
||||
}
|
||||
// Transient drop: queued calls keep waiting for the reconnect.
|
||||
this.scheduleReconnect(undefined);
|
||||
}
|
||||
|
||||
private scheduleReconnect(_cause: unknown): void {
|
||||
if (this.manualClose || !this.autoReconnect) {
|
||||
this.setState('closed');
|
||||
return;
|
||||
}
|
||||
this.reconnectAttempt += 1;
|
||||
const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000);
|
||||
this.setState('connecting');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
this.connect();
|
||||
}, delay);
|
||||
this.reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
private whenReady(): Promise<void> {
|
||||
if (this.state === 'open') return Promise.resolve();
|
||||
if (this.state === 'closed' && this.manualClose) {
|
||||
return Promise.reject(new Error('ws closed'));
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.readyWaiters.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
private rejectReadyWaiters(err: Error): void {
|
||||
const waiters = this.readyWaiters;
|
||||
this.readyWaiters = [];
|
||||
for (const w of waiters) w.reject(err);
|
||||
}
|
||||
|
||||
private take(id: string | undefined): PendingCall | undefined {
|
||||
const p = this.pending.get(id ?? '');
|
||||
if (p !== undefined) {
|
||||
this.pending.delete(id ?? '');
|
||||
if (p.timer !== undefined) clearTimeout(p.timer);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private failAll(err: Error): void {
|
||||
for (const p of this.pending.values()) {
|
||||
if (p.timer !== undefined) clearTimeout(p.timer);
|
||||
p.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private send(frame: Record<string, unknown>): void {
|
||||
const ws = this.ws;
|
||||
if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
// best-effort; the close handler handles teardown
|
||||
}
|
||||
}
|
||||
|
||||
private setState(next: WsSocketState): void {
|
||||
if (this.state === next) return;
|
||||
this.state = next;
|
||||
for (const listener of this.stateListeners) listener(next);
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive the `/api/v2/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */
|
||||
function toWsUrl(base: string): string {
|
||||
const url = new URL(base);
|
||||
if (url.protocol === 'http:') url.protocol = 'ws:';
|
||||
else if (url.protocol === 'https:') url.protocol = 'wss:';
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
||||
throw new Error(`unsupported URL scheme for WS transport: ${base}`);
|
||||
}
|
||||
if (!url.pathname.endsWith('/api/v2/ws')) {
|
||||
url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v2/ws`;
|
||||
}
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
333
apps/kimi-inspect/src/components/ChatView.tsx
Normal file
333
apps/kimi-inspect/src/components/ChatView.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* Main view — the conversation of the active session + agent.
|
||||
*
|
||||
* History comes from `IAgentContextMemoryService.get()` (the authoritative
|
||||
* context); live turns stream in through the agent `events` subscription
|
||||
* (`assistant.delta` / `thinking.delta` / `tool.*`) as ephemeral entries. On
|
||||
* `turn.ended` the history is refetched and the ephemeral layer is dropped, so
|
||||
* the view always converges to the authoritative context. Prompts go out via
|
||||
* `IAgentRPCService.prompt`; `cancel` aborts the running turn.
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory';
|
||||
import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc';
|
||||
|
||||
import { useConnection } from '../connection';
|
||||
import { eventType, payloadField, useLiveEvent } from '../live';
|
||||
import { ActionButton, Badge, ErrorLine } from '../ui';
|
||||
|
||||
interface ChatEntry {
|
||||
readonly id: string;
|
||||
readonly kind: 'user' | 'assistant' | 'thinking' | 'tool' | 'error';
|
||||
text: string;
|
||||
name?: string;
|
||||
args?: string;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface HistoryMessage {
|
||||
readonly role?: string;
|
||||
readonly content?: readonly { type?: string; text?: string; think?: string }[];
|
||||
readonly toolCalls?: readonly { id?: string; name?: string; arguments?: string | null }[];
|
||||
readonly isError?: boolean;
|
||||
}
|
||||
|
||||
function mapHistory(messages: readonly HistoryMessage[]): ChatEntry[] {
|
||||
const entries: ChatEntry[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const parts = m.content ?? [];
|
||||
const text = parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('\n');
|
||||
const think = parts
|
||||
.filter((p) => p.type === 'think')
|
||||
.map((p) => p.think ?? p.text ?? '')
|
||||
.join('\n');
|
||||
if (m.role === 'user') {
|
||||
entries.push({ id: `h${i}`, kind: 'user', text: text || '[non-text content]' });
|
||||
} else if (m.role === 'assistant') {
|
||||
if (think !== '') entries.push({ id: `h${i}t`, kind: 'thinking', text: think });
|
||||
if (text !== '') entries.push({ id: `h${i}a`, kind: 'assistant', text });
|
||||
for (const call of m.toolCalls ?? []) {
|
||||
entries.push({
|
||||
id: `h${i}c${call.id ?? ''}`,
|
||||
kind: 'tool',
|
||||
text: '',
|
||||
name: call.name ?? 'tool',
|
||||
args: call.arguments ?? undefined,
|
||||
});
|
||||
}
|
||||
} else if (m.role === 'tool') {
|
||||
entries.push({
|
||||
id: `h${i}r`,
|
||||
kind: 'tool',
|
||||
text: '',
|
||||
name: 'result',
|
||||
output: text,
|
||||
isError: m.isError,
|
||||
});
|
||||
}
|
||||
// system / developer messages (the system prompt) are not shown.
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
sessionId,
|
||||
agentId,
|
||||
ready,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
agentId: string;
|
||||
ready: boolean;
|
||||
}) {
|
||||
const { klient } = useConnection();
|
||||
const queryClient = useQueryClient();
|
||||
const [stream, setStream] = useState<ChatEntry[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [sendError, setSendError] = useState<unknown>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const enabled = sessionId !== null && ready;
|
||||
const history = useQuery({
|
||||
queryKey: ['history', sessionId, agentId],
|
||||
queryFn: () =>
|
||||
klient
|
||||
.session(sessionId as string)
|
||||
.agent(agentId)
|
||||
.service(IAgentContextMemoryService)
|
||||
.get(),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const refetchHistory = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const scheduleRefetch = () => {
|
||||
if (refetchHistory.current !== undefined) clearTimeout(refetchHistory.current);
|
||||
refetchHistory.current = setTimeout(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['history', sessionId, agentId] });
|
||||
setStream([]);
|
||||
setRunning(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
useLiveEvent((event) => {
|
||||
if (event.source !== 'agent') return;
|
||||
const type = eventType(event);
|
||||
const data = event.data as Record<string, unknown>;
|
||||
switch (type) {
|
||||
case 'turn.started':
|
||||
setRunning(true);
|
||||
return;
|
||||
case 'assistant.delta':
|
||||
case 'thinking.delta': {
|
||||
const turnId = payloadField(data, 'turnId', '?');
|
||||
const kind = type === 'assistant.delta' ? 'assistant' : 'thinking';
|
||||
const id = `stream:${turnId}:${kind}`;
|
||||
const delta = payloadField(data, 'delta', '');
|
||||
setStream((prev) => {
|
||||
const found = prev.find((e) => e.id === id);
|
||||
if (found === undefined) return [...prev, { id, kind, text: delta }];
|
||||
return prev.map((e) => (e.id === id ? { ...e, text: e.text + delta } : e));
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'tool.call.started': {
|
||||
const callId = payloadField(data, 'toolCallId', String(Math.random()));
|
||||
setStream((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `stream:tool:${callId}`,
|
||||
kind: 'tool',
|
||||
text: '',
|
||||
name: payloadField(data, 'name', 'tool'),
|
||||
args: typeof data['args'] === 'string' ? data['args'] : JSON.stringify(data['args'] ?? ''),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
case 'tool.result': {
|
||||
const callId = payloadField(data, 'toolCallId', '');
|
||||
const output = typeof data['output'] === 'string' ? data['output'] : JSON.stringify(data['output']);
|
||||
const id = `stream:tool:${callId}`;
|
||||
setStream((prev) => {
|
||||
const found = prev.find((e) => e.id === id);
|
||||
if (found === undefined) {
|
||||
return [...prev, { id, kind: 'tool', text: '', name: 'result', output, isError: Boolean(data['isError']) }];
|
||||
}
|
||||
return prev.map((e) => (e.id === id ? { ...e, output, isError: Boolean(data['isError']) } : e));
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'turn.ended':
|
||||
case 'prompt.completed':
|
||||
case 'prompt.aborted':
|
||||
case 'compaction.completed':
|
||||
scheduleRefetch();
|
||||
return;
|
||||
case 'error': {
|
||||
const message =
|
||||
typeof data['message'] === 'string'
|
||||
? data['message']
|
||||
: JSON.stringify(data).slice(0, 300);
|
||||
setStream((prev) => [
|
||||
...prev,
|
||||
{ id: `stream:err:${Date.now()}`, kind: 'error', text: message },
|
||||
]);
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const entries = useMemo(
|
||||
() => [...mapHistory((history.data ?? []) as readonly HistoryMessage[]), ...stream],
|
||||
[history.data, stream],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: 'end' });
|
||||
}, [entries.length, stream]);
|
||||
|
||||
const send = async () => {
|
||||
if (sessionId === null || input.trim() === '' || running) return;
|
||||
const text = input.trim();
|
||||
setInput('');
|
||||
setSendError(null);
|
||||
setStream((prev) => [...prev, { id: `optimistic:${Date.now()}`, kind: 'user', text }]);
|
||||
try {
|
||||
await klient
|
||||
.session(sessionId)
|
||||
.agent(agentId)
|
||||
.service(IAgentRPCService)
|
||||
.prompt({ input: [{ type: 'text', text }] });
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (sessionId === null) return;
|
||||
try {
|
||||
await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({});
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionId === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
|
||||
Select a session on the left to open its conversation.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
|
||||
Loading session…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
|
||||
<span className="font-mono text-[11px] text-neutral-400">{sessionId}</span>
|
||||
<Badge tone="sky">agent: {agentId}</Badge>
|
||||
{running ? <Badge tone="amber">turn running</Badge> : <Badge tone="green">idle</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{history.isError ? <ErrorLine error={history.error} /> : null}
|
||||
{entries.length === 0 && !history.isLoading ? (
|
||||
<div className="text-[12px] text-neutral-600 italic">Empty context — send a prompt below.</div>
|
||||
) : null}
|
||||
{entries.map((entry) => (
|
||||
<EntryView key={entry.id} entry={entry} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-800 p-3">
|
||||
{sendError !== null ? <div className="mb-2"><ErrorLine error={sendError} /></div> : null}
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
className="min-h-[40px] flex-1 resize-y rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
|
||||
placeholder="Send a prompt to the active agent… (Enter to send, Shift+Enter for newline)"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ActionButton onClick={() => void send()} disabled={running || input.trim() === ''}>
|
||||
Send
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => void cancel()} danger disabled={!running}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryView({ entry }: { entry: ChatEntry }) {
|
||||
if (entry.kind === 'user') {
|
||||
return (
|
||||
<div className="mb-3 flex justify-end">
|
||||
<div className="max-w-[80%] whitespace-pre-wrap rounded-lg bg-sky-900/40 px-3 py-2 text-[13px] text-neutral-100">
|
||||
{entry.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === 'thinking') {
|
||||
return (
|
||||
<div className="mb-3 max-w-[85%] whitespace-pre-wrap rounded-lg border border-dashed border-neutral-700 px-3 py-2 font-mono text-[11px] text-neutral-500">
|
||||
{entry.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === 'tool') {
|
||||
return (
|
||||
<div className="mb-3 max-w-[85%] rounded-lg border border-neutral-800 bg-neutral-900/50 px-3 py-2 font-mono text-[11px]">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge tone={entry.isError ? 'red' : 'neutral'}>tool</Badge>
|
||||
<span className="text-neutral-300">{entry.name}</span>
|
||||
</div>
|
||||
{entry.args !== undefined ? (
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">{entry.args}</pre>
|
||||
) : null}
|
||||
{entry.output !== undefined ? (
|
||||
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap ${entry.isError ? 'text-red-400' : 'text-neutral-400'}`}>
|
||||
{entry.output}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === 'error') {
|
||||
return (
|
||||
<div className="mb-3 max-w-[85%] rounded-lg bg-red-950/50 px-3 py-2 text-[12px] text-red-400">
|
||||
{entry.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mb-3 max-w-[85%] whitespace-pre-wrap rounded-lg bg-neutral-800/60 px-3 py-2 text-[13px] text-neutral-100">
|
||||
{entry.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
661
apps/kimi-inspect/src/components/Inspector.tsx
Normal file
661
apps/kimi-inspect/src/components/Inspector.tsx
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
/**
|
||||
* Right sidebar — access point for the Services of the server (app scope),
|
||||
* the active session, and the active agent. Hosts the agent switcher, four
|
||||
* tabs (app / session / agent / events), the live pending-interaction card
|
||||
* (driven by the session `interactions` stream), and the Service panels.
|
||||
*
|
||||
* The panel list is dynamic: `GET /api/v2/channels` describes every
|
||||
* wire-exposed Service with its methods, rendered by `DynamicServiceCard`;
|
||||
* the handwritten descriptors in `panels.ts` override individual Services
|
||||
* with curated cards (`ServiceCard`).
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
|
||||
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
|
||||
import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction';
|
||||
|
||||
import {
|
||||
fetchChannelDescriptors,
|
||||
serviceByName,
|
||||
type ChannelDescriptor,
|
||||
} from '../channel';
|
||||
import { useConnection } from '../connection';
|
||||
import { eventType, payloadField, useLiveEvent, useRecentEvents, type LiveEvent } from '../live';
|
||||
import {
|
||||
AGENT_PANELS,
|
||||
CORE_PANELS,
|
||||
SESSION_PANELS,
|
||||
call,
|
||||
type AnyService,
|
||||
type ServicePanelDef,
|
||||
} from '../panels';
|
||||
import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui';
|
||||
|
||||
type Tab = 'app' | 'session' | 'agent' | 'events';
|
||||
type Scope = 'app' | 'session' | 'agent';
|
||||
|
||||
const PANEL_OVERRIDES: ReadonlyMap<string, ServicePanelDef> = new Map(
|
||||
[...CORE_PANELS, ...SESSION_PANELS, ...AGENT_PANELS].map((def) => [def.id, def]),
|
||||
);
|
||||
|
||||
/** Load the full protocol list once per connection (every channel, 1:1). */
|
||||
function useChannels() {
|
||||
const { klient } = useConnection();
|
||||
return useQuery({
|
||||
queryKey: ['channels', klient.baseUrl, klient.rpcBasePath],
|
||||
queryFn: () => fetchChannelDescriptors(klient),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
sessionId,
|
||||
agentId,
|
||||
onAgentChange,
|
||||
ready,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
agentId: string;
|
||||
onAgentChange: (agentId: string) => void;
|
||||
ready: boolean;
|
||||
}) {
|
||||
const { klient } = useConnection();
|
||||
const [tab, setTab] = useState<Tab>('session');
|
||||
const channels = useChannels();
|
||||
|
||||
const meta = useQuery({
|
||||
queryKey: ['sessionMeta', sessionId],
|
||||
queryFn: () => klient.session(sessionId as string).service(ISessionMetadata).read(),
|
||||
enabled: sessionId !== null && ready,
|
||||
});
|
||||
|
||||
const agentIds = useMemo(() => {
|
||||
const ids = Object.keys(meta.data?.agents ?? {});
|
||||
if (ids.length === 0) return ['main'];
|
||||
return ['main', ...ids.filter((id) => id !== 'main')].filter(
|
||||
(id, i, all) => all.indexOf(id) === i,
|
||||
);
|
||||
}, [meta.data]);
|
||||
|
||||
// Keep the selected agent valid as the registry changes.
|
||||
const effectiveAgent = agentIds.includes(agentId) ? agentId : agentIds[0]!;
|
||||
useEffect(() => {
|
||||
if (effectiveAgent !== agentId) onAgentChange(effectiveAgent);
|
||||
}, [effectiveAgent, agentId, onAgentChange]);
|
||||
|
||||
// Subagents stay in the metadata registry even when their scope is not
|
||||
// materialized in this process (created before a restart, or disposed on
|
||||
// session close), so the switcher lists entries that cannot be called.
|
||||
// Mark one as "not loaded" when an agent-scope call comes back with
|
||||
// `agent.not_found` (message names the agent).
|
||||
const [stoppedAgents, setStoppedAgents] = useState<ReadonlySet<string>>(new Set());
|
||||
useEffect(() => setStoppedAgents(new Set()), [sessionId]);
|
||||
const noteAgentError = (agent: string, error: unknown) => {
|
||||
if (error instanceof Error && error.message.includes('not found in session')) {
|
||||
setStoppedAgents((prev) => (prev.has(agent) ? prev : new Set(prev).add(agent)));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve a Service proxy by channel name + scope, 1:1 with the channel
|
||||
// descriptor from `/api/v2/channels`. Returns null when the scope needs a
|
||||
// session that isn't selected/ready.
|
||||
const serviceProxy = useMemo(() => {
|
||||
return (name: string, scope: Scope): AnyService | null => {
|
||||
return serviceByName<AnyService>(klient, name, {
|
||||
scope,
|
||||
sessionId: sessionId !== null && ready ? sessionId : undefined,
|
||||
agentId: effectiveAgent,
|
||||
}) ?? null;
|
||||
};
|
||||
}, [klient, sessionId, effectiveAgent, ready]);
|
||||
|
||||
// Panels for one scope: the dynamic channel list merged with the handwritten
|
||||
// overrides. When the channels endpoint is unavailable (older server), fall
|
||||
// back to the handwritten panels only.
|
||||
const renderPanels = (scope: Scope) => {
|
||||
const byName = new Map<string, ChannelDescriptor | undefined>();
|
||||
if (channels.data !== undefined) {
|
||||
for (const c of channels.data) {
|
||||
if (c.scope === scope) byName.set(c.name, c);
|
||||
}
|
||||
// Keep overrides the introspection missed (e.g. server drift).
|
||||
for (const def of PANEL_OVERRIDES.values()) {
|
||||
if (def.scope === scope && !byName.has(def.id)) byName.set(def.id, undefined);
|
||||
}
|
||||
} else {
|
||||
for (const def of PANEL_OVERRIDES.values()) {
|
||||
if (def.scope === scope) byName.set(def.id, undefined);
|
||||
}
|
||||
}
|
||||
const list = [...byName.entries()];
|
||||
return (
|
||||
<>
|
||||
{channels.isError ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={channels.error} />
|
||||
<div className="mt-1 text-[10px] text-neutral-600">
|
||||
dynamic channel list unavailable — showing handwritten panels only
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{list.map(([name, channel]) => {
|
||||
const def = PANEL_OVERRIDES.get(name);
|
||||
const onError =
|
||||
scope === 'agent' ? (error: unknown) => noteAgentError(effectiveAgent, error) : undefined;
|
||||
if (def !== undefined) {
|
||||
return (
|
||||
<ServiceCard
|
||||
key={name}
|
||||
def={def}
|
||||
svc={serviceProxy(name, scope)}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (channel === undefined) return null;
|
||||
return (
|
||||
<DynamicServiceCard
|
||||
key={name}
|
||||
channel={channel}
|
||||
svc={serviceProxy(name, scope)}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const sessionBlocked = sessionId === null || !ready;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[420px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30">
|
||||
{/* Agent switcher */}
|
||||
{sessionId !== null ? (
|
||||
<div className="border-b border-neutral-800 px-3 py-2">
|
||||
<label className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
||||
Active agent
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-100 outline-none focus:border-sky-600"
|
||||
value={effectiveAgent}
|
||||
onChange={(e) => onAgentChange(e.target.value)}
|
||||
>
|
||||
{agentIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{stoppedAgents.has(id) ? `${id} (not loaded)` : id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{stoppedAgents.has(effectiveAgent) ? (
|
||||
<div className="mt-1 text-[10px] text-neutral-600">
|
||||
this agent is not materialized in the running server (e.g. created before a
|
||||
restart) — calls will fail; its persisted records remain on disk
|
||||
</div>
|
||||
) : null}
|
||||
{meta.isError ? <div className="mt-1"><ErrorLine error={meta.error} /></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-neutral-800 text-[11px]">
|
||||
{(['app', 'session', 'agent', 'events'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
|
||||
tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'app' ? 'App' : t === 'session' ? 'Session' : t === 'agent' ? 'Agent' : 'Events'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{tab === 'app' ? (
|
||||
renderPanels('app')
|
||||
) : tab === 'events' ? (
|
||||
<EventLog />
|
||||
) : sessionBlocked ? (
|
||||
<div className="text-[12px] text-neutral-600">
|
||||
{sessionId === null ? 'No session selected.' : 'Loading session…'}
|
||||
</div>
|
||||
) : tab === 'session' ? (
|
||||
<>
|
||||
<InteractionsCard sessionId={sessionId} />
|
||||
{renderPanels('session')}
|
||||
</>
|
||||
) : (
|
||||
renderPanels('agent')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic service card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ServiceCard({
|
||||
def,
|
||||
svc,
|
||||
onError,
|
||||
}: {
|
||||
def: ServicePanelDef;
|
||||
svc: AnyService | null;
|
||||
onError?: (error: unknown) => void;
|
||||
}) {
|
||||
const [data, setData] = useState<unknown>(undefined);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const refresh = async () => {
|
||||
if (svc === null || def.fetch === undefined) return;
|
||||
try {
|
||||
setError(null);
|
||||
const result = await def.fetch(svc);
|
||||
setData(result);
|
||||
setLoaded(true);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Refetch on matching live events (app-scope panels ride the `core` stream).
|
||||
useLiveEvent((event: LiveEvent) => {
|
||||
if (def.refreshOn === undefined || svc === null) return;
|
||||
const source = def.scope === 'app' ? 'core' : def.scope;
|
||||
if (event.source !== source) return;
|
||||
const type = eventType(event);
|
||||
if (def.refreshOn.some((prefix) => type.startsWith(prefix))) void refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
|
||||
<div className="flex items-center justify-between border-b border-neutral-800/60 px-3 py-2">
|
||||
<div>
|
||||
<span className="text-[12px] font-medium text-neutral-200">{def.label}</span>
|
||||
<span className="ml-2 font-mono text-[10px] text-neutral-600">{def.id}</span>
|
||||
</div>
|
||||
{def.fetch !== undefined ? (
|
||||
<ActionButton onClick={() => void refresh()} disabled={svc === null}>
|
||||
{loaded ? 'Refresh' : 'Load'}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{error !== null ? <div className="mb-2"><ErrorLine error={error} /></div> : null}
|
||||
{def.fetch !== undefined ? (
|
||||
loaded ? (
|
||||
<JsonView data={data} />
|
||||
) : (
|
||||
<div className="text-[11px] text-neutral-600 italic">click Load to read this Service</div>
|
||||
)
|
||||
) : null}
|
||||
{def.actions !== undefined && def.actions.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{def.actions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.label}
|
||||
danger={action.danger}
|
||||
disabled={svc === null || busy !== null}
|
||||
onClick={async () => {
|
||||
if (svc === null) return;
|
||||
let input: string | undefined;
|
||||
if (action.input !== undefined) {
|
||||
const raw = window.prompt(action.input);
|
||||
if (raw === null) return;
|
||||
input = raw;
|
||||
}
|
||||
setBusy(action.label);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await action.run(svc, input);
|
||||
if (result !== undefined && def.fetch === undefined) setData(result);
|
||||
if (def.fetch !== undefined) await refresh();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy === action.label ? '…' : action.label}
|
||||
</ActionButton>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{def.fetch === undefined && data !== undefined ? (
|
||||
<div className="mt-2"><JsonView data={data} /></div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic service card — generic renderer for channels without a handwritten
|
||||
// override. Every method gets a call button labeled with its declared
|
||||
// signature (JSON arg input when it takes parameters); getters become read
|
||||
// buttons. Results render inline.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DynamicServiceCard({
|
||||
channel,
|
||||
svc,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDescriptor;
|
||||
svc: AnyService | null;
|
||||
onError?: (error: unknown) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [args, setArgs] = useState<Record<string, string>>({});
|
||||
const [results, setResults] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<Record<string, unknown>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const invoke = async (method: ChannelDescriptor['methods'][number]) => {
|
||||
if (svc === null) return;
|
||||
let arg: unknown;
|
||||
if (method.kind === 'method' && method.params !== '') {
|
||||
const raw = (args[method.name] ?? '').trim();
|
||||
if (raw !== '') {
|
||||
try {
|
||||
arg = JSON.parse(raw);
|
||||
} catch {
|
||||
setErrors((prev) => ({ ...prev, [method.name]: new Error('arg is not valid JSON') }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setBusy(method.name);
|
||||
setErrors((prev) => ({ ...prev, [method.name]: null }));
|
||||
try {
|
||||
const result = await call(svc, method.name, arg);
|
||||
setResults((prev) => ({ ...prev, [method.name]: result ?? '(no result)' }));
|
||||
} catch (error) {
|
||||
setErrors((prev) => ({ ...prev, [method.name]: error }));
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between px-3 py-2 select-none"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<div>
|
||||
<span className="text-[12px] font-medium text-neutral-300">{channel.name}</span>
|
||||
<span className="ml-2 text-[10px] text-neutral-600">
|
||||
{channel.methods.length} methods · {channel.domain}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="border-t border-neutral-800/60 px-3 py-2">
|
||||
{channel.methods.length === 0 ? (
|
||||
<div className="text-[11px] text-neutral-600 italic">no callable members</div>
|
||||
) : null}
|
||||
{channel.methods.map((m) => (
|
||||
<div key={m.name} className="mb-1.5 last:mb-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ActionButton
|
||||
disabled={svc === null || busy !== null}
|
||||
onClick={() => void invoke(m)}
|
||||
>
|
||||
{busy === m.name ? '…' : `${m.name}(${m.params})`}
|
||||
</ActionButton>
|
||||
{m.kind === 'property' ? <Badge tone="neutral">get</Badge> : null}
|
||||
{m.kind === 'method' && m.params !== '' ? (
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
||||
placeholder="arg (JSON)"
|
||||
value={args[m.name] ?? ''}
|
||||
onChange={(e) =>
|
||||
setArgs((prev) => ({ ...prev, [m.name]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{errors[m.name] ? (
|
||||
<div className="mt-1">
|
||||
<ErrorLine error={errors[m.name]} />
|
||||
</div>
|
||||
) : null}
|
||||
{results[m.name] !== undefined ? (
|
||||
<div className="mt-1">
|
||||
<JsonView data={results[m.name]} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending interactions (approvals / questions), live via the session stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PendingInteraction {
|
||||
readonly id: string;
|
||||
/** Known kinds: 'approval' | 'question' | 'user_tool'; other kinds may appear. */
|
||||
readonly kind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
function InteractionsCard({ sessionId }: { sessionId: string }) {
|
||||
const { klient } = useConnection();
|
||||
const [pending, setPending] = useState<readonly PendingInteraction[]>([]);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const interaction = klient.session(sessionId).service(ISessionInteractionService);
|
||||
const approval = klient.session(sessionId).service(ISessionApprovalService);
|
||||
const question = klient.session(sessionId).service(ISessionQuestionService);
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setPending((await interaction.listPending()) as readonly PendingInteraction[]);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// The `interactions` stream pushes the full pending set on every change.
|
||||
useLiveEvent((event) => {
|
||||
if (event.source !== 'session') return;
|
||||
if (Array.isArray(event.data)) setPending(event.data as readonly PendingInteraction[]);
|
||||
});
|
||||
|
||||
const decide = async (id: string, decision: 'approved' | 'rejected') => {
|
||||
try {
|
||||
await approval.decide(id, { decision });
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
const answer = async (id: string, q: string, value: string) => {
|
||||
try {
|
||||
await question.answer(id, { answers: { [q]: value } });
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
const dismiss = async (id: string) => {
|
||||
try {
|
||||
await question.dismiss(id);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-amber-900/50 bg-amber-950/20">
|
||||
<div className="flex items-center justify-between border-b border-amber-900/40 px-3 py-2">
|
||||
<span className="text-[12px] font-medium text-amber-200">
|
||||
Pending interactions {pending.length > 0 ? `(${pending.length})` : ''}
|
||||
</span>
|
||||
<ActionButton onClick={() => void reload()}>Load</ActionButton>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{error !== null ? <div className="mb-2"><ErrorLine error={error} /></div> : null}
|
||||
{pending.length === 0 ? (
|
||||
<div className="text-[11px] text-neutral-600 italic">nothing pending</div>
|
||||
) : (
|
||||
pending.map((item) => (
|
||||
<div key={item.id} className="mb-2 rounded border border-neutral-800 bg-neutral-950/60 p-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge tone="amber">{item.kind}</Badge>
|
||||
<span className="font-mono text-[10px] text-neutral-500">{item.id}</span>
|
||||
<span className="text-[10px] text-neutral-600">{relTime(item.createdAt)}</span>
|
||||
</div>
|
||||
{item.kind === 'approval' ? (
|
||||
<>
|
||||
<div className="mb-1.5 text-[11px] text-neutral-300">
|
||||
<span className="text-neutral-500">tool </span>
|
||||
{payloadField(item.payload, 'toolName', '?')}
|
||||
<span className="text-neutral-500"> · </span>
|
||||
{payloadField(item.payload, 'action', '')}
|
||||
</div>
|
||||
<JsonView data={item.payload['display'] ?? item.payload} />
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<ActionButton onClick={() => void decide(item.id, 'approved')}>Approve</ActionButton>
|
||||
<ActionButton danger onClick={() => void decide(item.id, 'rejected')}>Reject</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
) : item.kind === 'question' ? (
|
||||
<QuestionView
|
||||
payload={item.payload}
|
||||
onAnswer={(q, v) => void answer(item.id, q, v)}
|
||||
onDismiss={() => void dismiss(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<JsonView data={item.payload} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionView({
|
||||
payload,
|
||||
onAnswer,
|
||||
onDismiss,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
onAnswer: (question: string, value: string) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const questions = (payload['questions'] ?? []) as readonly {
|
||||
question: string;
|
||||
options?: readonly { label: string }[];
|
||||
}[];
|
||||
return (
|
||||
<>
|
||||
{questions.map((q) => (
|
||||
<div key={q.question} className="mb-1.5">
|
||||
<div className="mb-1 text-[11px] text-neutral-300">{q.question}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(q.options ?? []).map((opt) => (
|
||||
<ActionButton key={opt.label} onClick={() => onAnswer(q.question, opt.label)}>
|
||||
{opt.label}
|
||||
</ActionButton>
|
||||
))}
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
const raw = window.prompt(q.question);
|
||||
if (raw !== null) onAnswer(q.question, raw);
|
||||
}}
|
||||
>
|
||||
Other…
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{questions.length === 0 ? <JsonView data={payload} /> : null}
|
||||
<div className="mt-1.5">
|
||||
<ActionButton danger onClick={onDismiss}>
|
||||
Dismiss
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event log — merged core/session/agent stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EventLog() {
|
||||
const recent = useRecentEvents();
|
||||
const [events, setEvents] = useState<readonly LiveEvent[]>(() => recent);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const pausedRef = useRefState(paused);
|
||||
|
||||
useLiveEvent((event) => {
|
||||
if (pausedRef.current) return;
|
||||
setEvents((prev) => [...prev.slice(-500), event]);
|
||||
});
|
||||
|
||||
const filtered = filter.trim() === ''
|
||||
? events
|
||||
: events.filter((e) => JSON.stringify(e.data).toLowerCase().includes(filter.trim().toLowerCase()));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
||||
placeholder="filter JSON…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<ActionButton onClick={() => setPaused((v) => !v)}>{paused ? 'Resume' : 'Pause'}</ActionButton>
|
||||
<ActionButton onClick={() => setEvents([])}>Clear</ActionButton>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-600">{filtered.length} events (newest first)</div>
|
||||
<div className="mt-2">
|
||||
{filtered.toReversed().map((event, i) => (
|
||||
<div key={`${event.at}-${i}`} className="mb-1.5 rounded border border-neutral-800/70 bg-neutral-950/50 p-2">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<Badge tone={event.source === 'core' ? 'sky' : event.source === 'session' ? 'amber' : 'green'}>
|
||||
{event.source}
|
||||
</Badge>
|
||||
<span className="font-mono text-[10px] text-neutral-400">{eventType(event) || '(payload)'}</span>
|
||||
<span className="text-[10px] text-neutral-600">{new Date(event.at).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<JsonView data={event.data} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirror a state value into a ref so event handlers see the latest without re-subscribing. */
|
||||
function useRefState<T>(value: T): { readonly current: T } {
|
||||
const ref = useRef(value);
|
||||
ref.current = value;
|
||||
return ref;
|
||||
}
|
||||
48
apps/kimi-inspect/src/components/ServerSwitcher.tsx
Normal file
48
apps/kimi-inspect/src/components/ServerSwitcher.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Header server switcher — lists the locally discovered kap-servers (dev
|
||||
* middleware `/__inspect/servers`) and switches the connection between them
|
||||
* without a reload. Discovered picks are not persisted as a full connection
|
||||
* config; only the picked URL is remembered so a reload re-picks it while the
|
||||
* instance is still alive. Falls back to a plain read-only URL when discovery
|
||||
* is unavailable (static hosting) or finds no local servers.
|
||||
*/
|
||||
|
||||
import { useConnection } from '../connection';
|
||||
import { useServerDiscovery } from '../servers';
|
||||
|
||||
export function ServerSwitcher() {
|
||||
const { baseUrl, connect } = useConnection();
|
||||
const discovery = useServerDiscovery();
|
||||
const data = discovery.data;
|
||||
if (data === null || data === undefined || data.servers.length === 0) {
|
||||
// No local discovery (static hosting / no servers): plain read-only URL.
|
||||
return <span className="font-mono text-[10px] text-neutral-500">{baseUrl}</span>;
|
||||
}
|
||||
const current = data.servers.find((s) => s.url === baseUrl);
|
||||
return (
|
||||
<select
|
||||
className="rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300 outline-none focus:border-sky-600"
|
||||
title={`Local kap-servers (${data.home})`}
|
||||
value={current?.id ?? '__custom'}
|
||||
onChange={(e) => {
|
||||
const target = data.servers.find((s) => s.id === e.target.value);
|
||||
if (target === undefined || target.url === baseUrl) return;
|
||||
connect(
|
||||
{ url: target.url, token: data.token ?? '' },
|
||||
{ persist: false, rememberServerUrl: target.url },
|
||||
);
|
||||
}}
|
||||
>
|
||||
{current === undefined ? (
|
||||
<option value="__custom">custom: {baseUrl.replace(/^https?:\/\//, '')}</option>
|
||||
) : null}
|
||||
{data.servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.url.replace(/^https?:\/\//, '')}
|
||||
{s.pid !== undefined ? ` · pid ${s.pid}` : ''}
|
||||
{s.source === 'proxy' ? ' · proxy' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
192
apps/kimi-inspect/src/components/Sidebar.tsx
Normal file
192
apps/kimi-inspect/src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Left sidebar — two columns: the workspace registry (`IWorkspaceRegistry`)
|
||||
* and the sessions of the selected workspace (`ISessionIndex`). Clicking a
|
||||
* session opens it in the main view. Lists refresh on core events (debounced)
|
||||
* and a slow poll as a safety net. Session creation goes through the v1 REST
|
||||
* endpoint (klient is v2-only).
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { ISessionIndex, type SessionSummary } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex';
|
||||
import { IWorkspaceRegistry, type Workspace } from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry';
|
||||
|
||||
import { useConnection } from '../connection';
|
||||
import { useLiveEvent } from '../live';
|
||||
import { Badge, ErrorLine, relTime } from '../ui';
|
||||
|
||||
export function Sidebar({
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
}: {
|
||||
activeSessionId: string | null;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
}) {
|
||||
const { klient, baseUrl, config } = useConnection();
|
||||
const queryClient = useQueryClient();
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
|
||||
const workspaces = useQuery({
|
||||
queryKey: ['workspaces'],
|
||||
queryFn: () => klient.core(IWorkspaceRegistry).list(),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const sessions = useQuery({
|
||||
queryKey: ['sessions', workspaceId],
|
||||
queryFn: () =>
|
||||
klient
|
||||
.core(ISessionIndex)
|
||||
.list({ workspaceId: workspaceId ?? undefined, includeArchived: true, limit: 200 }),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
// Core events (session archived, model catalog, …) → debounced list refresh.
|
||||
const refreshTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useLiveEvent((event) => {
|
||||
if (event.source !== 'core') return;
|
||||
if (refreshTimer.current !== undefined) clearTimeout(refreshTimer.current);
|
||||
refreshTimer.current = setTimeout(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||
}, 800);
|
||||
});
|
||||
|
||||
const sortedWorkspaces = (workspaces.data ?? []).toSorted((a, b) => b.lastOpenedAt - a.lastOpenedAt);
|
||||
const sortedSessions = (sessions.data?.items ?? []).toSorted((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const createSession = async (ws: Workspace | null) => {
|
||||
const cwd = window.prompt('Working directory for the new session:', ws?.root ?? '');
|
||||
if (cwd === null || cwd.trim() === '') return;
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||
if (config.token.trim() !== '') headers['authorization'] = `Bearer ${config.token.trim()}`;
|
||||
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ workspace_id: ws?.id, metadata: { cwd: cwd.trim() } }),
|
||||
});
|
||||
const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } };
|
||||
if (envelope.code !== 0) {
|
||||
window.alert(`create session failed: ${envelope.msg}`);
|
||||
return;
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||
onSelectSession(envelope.data.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[480px] shrink-0 border-r border-neutral-800">
|
||||
{/* Workspaces */}
|
||||
<div className="flex w-1/2 flex-col border-r border-neutral-800">
|
||||
<div className="flex items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
|
||||
<span>Workspaces</span>
|
||||
<button
|
||||
className="text-sky-500 hover:text-sky-400"
|
||||
title="New session (no workspace)"
|
||||
onClick={() => void createSession(null)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
|
||||
{sortedWorkspaces.map((ws) => (
|
||||
<WorkspaceRow
|
||||
key={ws.id}
|
||||
ws={ws}
|
||||
selected={ws.id === workspaceId}
|
||||
onClick={() => setWorkspaceId(ws.id)}
|
||||
onNew={() => void createSession(ws)}
|
||||
/>
|
||||
))}
|
||||
{workspaces.isLoading ? (
|
||||
<div className="px-3 py-2 text-[11px] text-neutral-600">loading…</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions */}
|
||||
<div className="flex w-1/2 flex-col">
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
|
||||
Sessions {workspaceId === null ? '(all)' : ''}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.isError ? <ErrorLine error={sessions.error} /> : null}
|
||||
{sortedSessions.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
s={s}
|
||||
active={s.id === activeSessionId}
|
||||
onClick={() => onSelectSession(s.id)}
|
||||
/>
|
||||
))}
|
||||
{sessions.isLoading ? (
|
||||
<div className="px-3 py-2 text-[11px] text-neutral-600">loading…</div>
|
||||
) : null}
|
||||
{!sessions.isLoading && sortedSessions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-neutral-600">no sessions</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRow({
|
||||
ws,
|
||||
selected,
|
||||
onClick,
|
||||
onNew,
|
||||
}: {
|
||||
ws: Workspace;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-neutral-800/60 ${
|
||||
selected ? 'bg-neutral-800' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] text-neutral-200">{ws.name}</div>
|
||||
<div className="truncate text-[10px] text-neutral-500" title={ws.root}>
|
||||
{ws.root}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="ml-2 hidden shrink-0 text-sky-500 hover:text-sky-400 group-hover:block"
|
||||
title="New session in this workspace"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNew();
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({ s, active, onClick }: { s: SessionSummary; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={`cursor-pointer px-3 py-2 hover:bg-neutral-800/60 ${active ? 'bg-sky-950/60' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-neutral-200">
|
||||
{s.title ?? s.lastPrompt ?? s.id}
|
||||
</span>
|
||||
{s.archived ? <Badge tone="neutral">archived</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-neutral-500">
|
||||
<span className="truncate font-mono">{s.id.slice(0, 12)}</span>
|
||||
<span className="shrink-0">{relTime(s.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
apps/kimi-inspect/src/connection.tsx
Normal file
310
apps/kimi-inspect/src/connection.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/**
|
||||
* Connection context — owns the inspect client (HTTP calls) and its WebSocket
|
||||
* (event streams) built from the selected server URL + token.
|
||||
*
|
||||
* Three ways to connect:
|
||||
* 1. deep link `?url=` / `?token=` or a previously saved manual config
|
||||
* (persisted in localStorage);
|
||||
* 2. zero-config discovery: on startup the dev middleware
|
||||
* (`/__inspect/servers`) lists every local kap-server with the home
|
||||
* token — the app connects straight to the remembered / proxy / first
|
||||
* instance, persisting nothing but the picked URL (so a reload re-picks
|
||||
* it while it is still alive, and never resurrects a stale port);
|
||||
* 3. the manual form or a discovered-server card on the connect screen.
|
||||
* `disconnect` suppresses the discovery bootstrap for the rest of the
|
||||
* session so it lands on the connect screen instead of reconnecting.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
createInspectClient,
|
||||
probeRpcBasePath,
|
||||
type InspectClient,
|
||||
type RpcBasePath,
|
||||
type WsSocketState,
|
||||
} from './channel';
|
||||
import {
|
||||
fetchServerDiscovery,
|
||||
pickDefaultServer,
|
||||
useServerDiscovery,
|
||||
} from './servers';
|
||||
|
||||
export interface ConnectionConfig {
|
||||
/** Server base URL; empty string means same-origin (the Vite dev proxy). */
|
||||
readonly url: string;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface ConnectOptions {
|
||||
/** Persist the whole config (manual form / deep link). Default `true`;
|
||||
* discovered connects pass `false` so a reload re-discovers fresh state. */
|
||||
readonly persist?: boolean;
|
||||
/** Remember just this URL as the preferred discovery pick (`kimi-inspect.server-url`). */
|
||||
readonly rememberServerUrl?: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'kimi-inspect.connection';
|
||||
const REMEMBERED_SERVER_KEY = 'kimi-inspect.server-url';
|
||||
|
||||
function readInitialConfig(): ConnectionConfig {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const qUrl = params.get('url');
|
||||
const qToken = params.get('token');
|
||||
if (qUrl !== null || qToken !== null) {
|
||||
return { url: qUrl ?? '', token: qToken ?? '' };
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw !== null) {
|
||||
const parsed = JSON.parse(raw) as ConnectionConfig;
|
||||
return { url: parsed.url ?? '', token: parsed.token ?? '' };
|
||||
}
|
||||
} catch {
|
||||
// corrupt storage — fall through to default
|
||||
}
|
||||
return { url: '', token: '' };
|
||||
}
|
||||
|
||||
/** Resolve the configured (possibly relative) URL to an absolute base for the client. */
|
||||
export function resolveBaseUrl(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/$/, '');
|
||||
if (trimmed === '') return window.location.origin;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
interface ConnectionValue {
|
||||
readonly config: ConnectionConfig;
|
||||
readonly baseUrl: string;
|
||||
readonly klient: InspectClient;
|
||||
readonly wsState: WsSocketState;
|
||||
readonly connect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
|
||||
readonly disconnect: () => void;
|
||||
}
|
||||
|
||||
const ConnectionContext = createContext<ConnectionValue | null>(null);
|
||||
|
||||
export function ConnectionProvider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<ConnectionConfig | null>(() => {
|
||||
const initial = readInitialConfig();
|
||||
// Auto-connect only when the user explicitly connected before (stored) or
|
||||
// deep-linked (query). First visit goes through the discovery bootstrap.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('url') || params.has('token')) return initial;
|
||||
return localStorage.getItem(STORAGE_KEY) !== null ? initial : null;
|
||||
});
|
||||
const [wsState, setWsState] = useState<WsSocketState>('connecting');
|
||||
const [discovering, setDiscovering] = useState(config === null);
|
||||
const [suppressDiscovery, setSuppressDiscovery] = useState(false);
|
||||
|
||||
// Discovery bootstrap: with nothing explicit configured, scan the local
|
||||
// kap-server instance registry (via the dev middleware) and auto-connect.
|
||||
useEffect(() => {
|
||||
if (config !== null || suppressDiscovery) return;
|
||||
let cancelled = false;
|
||||
setDiscovering(true);
|
||||
void fetchServerDiscovery().then((discovery) => {
|
||||
if (cancelled) return;
|
||||
setDiscovering(false);
|
||||
if (discovery === null) return;
|
||||
const pick = pickDefaultServer(discovery, localStorage.getItem(REMEMBERED_SERVER_KEY));
|
||||
if (pick === undefined) return;
|
||||
setConfig({ url: pick.url, token: discovery.token ?? '' });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [config, suppressDiscovery]);
|
||||
|
||||
// RPC surface probe: dev servers (`--debug-endpoints`) mount the
|
||||
// whitelist-free `/api/v1/debug` dispatcher; older/production servers fall
|
||||
// back to the `/api/v2` whitelist set. The client is built only after the
|
||||
// probe for this exact config has resolved.
|
||||
const [probe, setProbe] = useState<{ readonly key: string; readonly rpcBase: RpcBasePath } | null>(
|
||||
null,
|
||||
);
|
||||
const configKey =
|
||||
config === null ? null : `${resolveBaseUrl(config.url)}|${config.token.trim()}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (config === null || configKey === null) {
|
||||
setProbe(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const token = config.token.trim();
|
||||
void probeRpcBasePath({
|
||||
baseUrl: resolveBaseUrl(config.url),
|
||||
token: token === '' ? undefined : token,
|
||||
}).then((rpcBase) => {
|
||||
if (!cancelled) setProbe({ key: configKey, rpcBase });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [config, configKey]);
|
||||
|
||||
const klient = useMemo(() => {
|
||||
if (config === null || configKey === null) return null;
|
||||
if (probe === null || probe.key !== configKey) return null;
|
||||
const token = config.token.trim();
|
||||
return createInspectClient({
|
||||
url: resolveBaseUrl(config.url),
|
||||
token: token === '' ? undefined : token,
|
||||
rpcBasePath: probe.rpcBase,
|
||||
});
|
||||
}, [config, configKey, probe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (klient === null) return;
|
||||
const ws = klient.ws();
|
||||
setWsState(ws.state);
|
||||
const sub = ws.onDidChangeState(setWsState);
|
||||
return () => {
|
||||
sub.dispose();
|
||||
ws.close();
|
||||
};
|
||||
}, [klient]);
|
||||
|
||||
const connect = useCallback((next: ConnectionConfig, opts: ConnectOptions = {}) => {
|
||||
if (opts.persist ?? true) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
if (opts.rememberServerUrl !== undefined) {
|
||||
localStorage.setItem(REMEMBERED_SERVER_KEY, opts.rememberServerUrl);
|
||||
}
|
||||
setSuppressDiscovery(false);
|
||||
setConfig(next);
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(REMEMBERED_SERVER_KEY);
|
||||
setSuppressDiscovery(true);
|
||||
setConfig(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ConnectionValue | null>(() => {
|
||||
if (klient === null || config === null) return null;
|
||||
return { config, baseUrl: resolveBaseUrl(config.url), klient, wsState, connect, disconnect };
|
||||
}, [klient, config, wsState, connect, disconnect]);
|
||||
|
||||
return (
|
||||
<ConnectionContext.Provider value={value}>
|
||||
{value !== null ? (
|
||||
children
|
||||
) : config !== null ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-sm text-neutral-500">
|
||||
Connecting to {resolveBaseUrl(config.url)}…
|
||||
</div>
|
||||
</div>
|
||||
) : discovering && !suppressDiscovery ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-sm text-neutral-500">Discovering local kap-servers…</div>
|
||||
</div>
|
||||
) : (
|
||||
<ConnectScreen onConnect={connect} initial={readInitialConfig()} />
|
||||
)}
|
||||
</ConnectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConnection(): ConnectionValue {
|
||||
const value = useContext(ConnectionContext);
|
||||
if (value === null) {
|
||||
throw new Error('useConnection used before connecting');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ConnectScreen({
|
||||
onConnect,
|
||||
initial,
|
||||
}: {
|
||||
onConnect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
|
||||
initial: ConnectionConfig;
|
||||
}) {
|
||||
const [url, setUrl] = useState(initial.url);
|
||||
const [token, setToken] = useState(initial.token);
|
||||
const discovery = useServerDiscovery();
|
||||
const servers = discovery.data?.servers ?? [];
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<form
|
||||
className="w-[420px] rounded-lg border border-neutral-800 bg-neutral-900 p-6 shadow-xl"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onConnect({ url, token });
|
||||
}}
|
||||
>
|
||||
<h1 className="mb-1 text-lg font-semibold text-neutral-100">Kimi Inspect</h1>
|
||||
<p className="mb-5 text-xs text-neutral-500">
|
||||
Connect to a kap-server (<code className="text-neutral-400">/api/v2</code>). Leave the
|
||||
URL empty to use the same-origin dev proxy
|
||||
{` (${__KIMI_INSPECT_PROXY_TARGET__})`}.
|
||||
</p>
|
||||
{servers.length > 0 ? (
|
||||
<div className="mb-5">
|
||||
<div className="mb-1 text-xs text-neutral-400">
|
||||
Discovered on this machine{discovery.data?.home ? ` (${discovery.data.home})` : ''}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{servers.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-left text-[12px] text-neutral-200 hover:border-sky-600"
|
||||
onClick={() =>
|
||||
onConnect(
|
||||
{ url: s.url, token: discovery.data?.token ?? '' },
|
||||
{ persist: false, rememberServerUrl: s.url },
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="font-mono">{s.url.replace(/^https?:\/\//, '')}</span>
|
||||
{s.pid !== undefined ? (
|
||||
<span className="text-[10px] text-neutral-500">pid {s.pid}</span>
|
||||
) : null}
|
||||
<span className="ml-auto text-[10px] uppercase text-neutral-600">{s.source}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="mb-1 block text-xs text-neutral-400">Server URL</label>
|
||||
<input
|
||||
className="mb-4 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
|
||||
placeholder="http://127.0.0.1:58627 (empty = dev proxy)"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<label className="mb-1 block text-xs text-neutral-400">Bearer token (optional)</label>
|
||||
<input
|
||||
className="mb-5 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
|
||||
placeholder="~/.kimi-code/server.token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
declare const __KIMI_INSPECT_PROXY_TARGET__: string;
|
||||
33
apps/kimi-inspect/src/index.css
Normal file
33
apps/kimi-inspect/src/index.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0b0d10;
|
||||
color: #d6dae0;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #2b313a transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2b313a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
92
apps/kimi-inspect/src/live.tsx
Normal file
92
apps/kimi-inspect/src/live.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Live event bus — fans the three WS event streams (core `events`, session
|
||||
* `interactions`, agent `events`) out to every UI consumer through one React
|
||||
* context. Subscriptions are registered in `App`; panels and the chat view
|
||||
* subscribe here instead of opening their own sockets.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useRef } from 'react';
|
||||
|
||||
export interface LiveEvent {
|
||||
readonly source: 'core' | 'session' | 'agent';
|
||||
readonly data: unknown;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
type LiveHandler = (event: LiveEvent) => void;
|
||||
export type Emit = (event: LiveEvent) => void;
|
||||
|
||||
interface LiveBus {
|
||||
readonly subscribe: (handler: LiveHandler) => () => void;
|
||||
/** Last N events recorded since connect (for the event log's initial view). */
|
||||
readonly getRecent: () => readonly LiveEvent[];
|
||||
}
|
||||
|
||||
const LiveBusContext = createContext<LiveBus>({ subscribe: () => () => {}, getRecent: () => [] });
|
||||
|
||||
/** Extract the discriminant `type` of an agent/core event payload, if any. */
|
||||
export function eventType(event: LiveEvent): string {
|
||||
const data = event.data as { type?: unknown } | null;
|
||||
return typeof data?.type === 'string' ? data.type : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a wire payload field as display text: strings pass through,
|
||||
* numbers/booleans are stringified, anything else (or missing) falls back —
|
||||
* never "[object Object]".
|
||||
*/
|
||||
export function payloadField(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const value = payload[key];
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider assigns its `emit` to `busRef` so the WS subscription wiring in
|
||||
* `App` (which lives above the React tree's hooks it needs) can publish.
|
||||
*/
|
||||
const RECENT_LIMIT = 500;
|
||||
|
||||
export function LiveBusProvider({
|
||||
busRef,
|
||||
children,
|
||||
}: {
|
||||
busRef: React.MutableRefObject<Emit | null>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const handlers = useRef(new Set<LiveHandler>());
|
||||
const recent = useRef<LiveEvent[]>([]);
|
||||
const bus = useRef<LiveBus>({
|
||||
subscribe: (handler) => {
|
||||
handlers.current.add(handler);
|
||||
return () => handlers.current.delete(handler);
|
||||
},
|
||||
getRecent: () => recent.current,
|
||||
});
|
||||
busRef.current = (event) => {
|
||||
const buf = recent.current;
|
||||
if (buf.length >= RECENT_LIMIT) buf.splice(0, buf.length - RECENT_LIMIT + 1);
|
||||
buf.push(event);
|
||||
for (const handler of handlers.current) handler(event);
|
||||
};
|
||||
return <LiveBusContext.Provider value={bus.current}>{children}</LiveBusContext.Provider>;
|
||||
}
|
||||
|
||||
/** Subscribe to the merged live stream. */
|
||||
export function useLiveEvent(handler: LiveHandler): void {
|
||||
const { subscribe } = useContext(LiveBusContext);
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
useEffect(() => subscribe((event) => handlerRef.current(event)), [subscribe]);
|
||||
}
|
||||
|
||||
/** Read the buffered recent events (for initial log rendering). */
|
||||
export function useRecentEvents(): readonly LiveEvent[] {
|
||||
const { getRecent } = useContext(LiveBusContext);
|
||||
return getRecent();
|
||||
}
|
||||
23
apps/kimi-inspect/src/main.tsx
Normal file
23
apps/kimi-inspect/src/main.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { App } from './App';
|
||||
import { ConnectionProvider } from './connection';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, staleTime: 0, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.querySelector('#root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConnectionProvider>
|
||||
<App />
|
||||
</ConnectionProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
287
apps/kimi-inspect/src/panels.ts
Normal file
287
apps/kimi-inspect/src/panels.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* Service panel descriptors — the handwritten *override* layer of the right
|
||||
* sidebar. The sidebar's baseline is the dynamic channel list served by
|
||||
* `GET /api/v2/channels` (every wire-exposed Service with its methods); a
|
||||
* descriptor here replaces the generic card for its Service with a curated
|
||||
* one: a `fetch` that reads its inspectable state, optional `actions` that
|
||||
* trigger its methods, and live-event `refreshOn` prefixes. The generic
|
||||
* `ServiceCard` renders them; adding a curated panel is one entry here, no
|
||||
* component code.
|
||||
*
|
||||
* The proxies are typed by the real `agent-core-v2` contracts at the call
|
||||
* site, but panels treat them as `AnyService` so one descriptor shape covers
|
||||
* every Service.
|
||||
*/
|
||||
|
||||
import { IAuthSummaryService } from '@moonshot-ai/agent-core-v2/app/auth/auth';
|
||||
import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config';
|
||||
import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag';
|
||||
import { IProviderService } from '@moonshot-ai/agent-core-v2/app/provider/provider';
|
||||
|
||||
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
|
||||
import { ISessionInitService } from '@moonshot-ai/agent-core-v2/session/sessionInit/sessionInit';
|
||||
import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction';
|
||||
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
|
||||
import { ISessionWorkspaceContext } from '@moonshot-ai/agent-core-v2/session/workspaceContext/workspaceContext';
|
||||
import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView';
|
||||
import { IAgentContextSizeService } from '@moonshot-ai/agent-core-v2/agent/contextSize/contextSize';
|
||||
import { IAgentGoalService } from '@moonshot-ai/agent-core-v2/agent/goal/goal';
|
||||
import { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp';
|
||||
import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode';
|
||||
import { IAgentPermissionRulesService } from '@moonshot-ai/agent-core-v2/agent/permissionRules/permissionRules';
|
||||
import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan';
|
||||
import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
|
||||
import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc';
|
||||
import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/agent/swarm/swarm';
|
||||
import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task';
|
||||
import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/toolRegistry/toolRegistry';
|
||||
import { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage';
|
||||
|
||||
/** Loosely-typed view of a scoped service proxy (every member is a remote call). */
|
||||
export type AnyService = Record<string, (arg?: unknown) => Promise<unknown>>;
|
||||
|
||||
/** Invoke a method on a loose proxy; the proxy materializes every member. */
|
||||
export function call(svc: AnyService, method: string, arg?: unknown): Promise<unknown> {
|
||||
const fn = svc[method];
|
||||
if (fn === undefined) {
|
||||
return Promise.reject(new Error(`no such method on proxy: ${method}`));
|
||||
}
|
||||
return fn(arg);
|
||||
}
|
||||
|
||||
export interface PanelAction {
|
||||
readonly label: string;
|
||||
/** Prompt for one string input before running (raw string passed to `run`). */
|
||||
readonly input?: string;
|
||||
readonly danger?: boolean;
|
||||
readonly run: (svc: AnyService, input?: string) => unknown;
|
||||
}
|
||||
|
||||
export interface ServicePanelDef {
|
||||
/** Decorator id / wire channel name, e.g. `sessionMetadata`. */
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
/** Wire scope the Service is called on (`app` maps to the `core` route). */
|
||||
readonly scope: 'app' | 'session' | 'agent';
|
||||
readonly fetch?: (svc: AnyService) => Promise<unknown>;
|
||||
readonly actions?: readonly PanelAction[];
|
||||
/** Live-event `type` prefixes that refetch this panel. */
|
||||
readonly refreshOn?: readonly string[];
|
||||
}
|
||||
|
||||
const setModeModes = ['manual', 'auto', 'yolo'];
|
||||
|
||||
export const CORE_PANELS: readonly ServicePanelDef[] = [
|
||||
{
|
||||
id: String(IConfigService),
|
||||
label: 'ConfigService',
|
||||
scope: 'app',
|
||||
fetch: async (svc) => ({
|
||||
config: await call(svc, 'getAll'),
|
||||
diagnostics: await call(svc, 'diagnostics'),
|
||||
}),
|
||||
actions: [{ label: 'reload', run: (svc) => call(svc, 'reload') }],
|
||||
},
|
||||
{
|
||||
id: String(IProviderService),
|
||||
label: 'ProviderService',
|
||||
scope: 'app',
|
||||
fetch: (svc) => call(svc, 'list'),
|
||||
},
|
||||
{
|
||||
id: String(IAuthSummaryService),
|
||||
label: 'AuthSummaryService',
|
||||
scope: 'app',
|
||||
fetch: (svc) => call(svc, 'summarize'),
|
||||
},
|
||||
{
|
||||
id: String(IFlagService),
|
||||
label: 'FlagService',
|
||||
scope: 'app',
|
||||
fetch: (svc) => call(svc, 'explainAll'),
|
||||
},
|
||||
];
|
||||
|
||||
export const SESSION_PANELS: readonly ServicePanelDef[] = [
|
||||
{
|
||||
id: String(ISessionMetadata),
|
||||
label: 'SessionMetadata',
|
||||
scope: 'session',
|
||||
fetch: (svc) => call(svc, 'read'),
|
||||
actions: [
|
||||
{ label: 'Set title', input: 'New title', run: (svc, title) => call(svc, 'setTitle', title) },
|
||||
{ label: 'Archive', danger: true, run: (svc) => call(svc, 'setArchived', true) },
|
||||
{ label: 'Unarchive', run: (svc) => call(svc, 'setArchived', false) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: String(ISessionApprovalService),
|
||||
label: 'SessionApprovalService',
|
||||
scope: 'session',
|
||||
fetch: (svc) => call(svc, 'listPending'),
|
||||
},
|
||||
{
|
||||
id: String(ISessionQuestionService),
|
||||
label: 'SessionQuestionService',
|
||||
scope: 'session',
|
||||
fetch: (svc) => call(svc, 'listPending'),
|
||||
},
|
||||
{
|
||||
id: String(ISessionInteractionService),
|
||||
label: 'SessionInteractionService',
|
||||
scope: 'session',
|
||||
fetch: (svc) => call(svc, 'listPending'),
|
||||
},
|
||||
{
|
||||
id: String(ISessionWorkspaceContext),
|
||||
label: 'SessionWorkspaceContext',
|
||||
scope: 'session',
|
||||
fetch: async (svc) => ({
|
||||
workDir: await call(svc, 'workDir'),
|
||||
additionalDirs: await call(svc, 'additionalDirs'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: String(ISessionInitService),
|
||||
label: 'SessionInitService',
|
||||
scope: 'session',
|
||||
actions: [{ label: 'generateAgentsMd (/init)', run: (svc) => call(svc, 'generateAgentsMd') }],
|
||||
},
|
||||
];
|
||||
|
||||
export const AGENT_PANELS: readonly ServicePanelDef[] = [
|
||||
{
|
||||
id: String(IAgentActivityView),
|
||||
label: 'AgentActivityView',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'state'),
|
||||
refreshOn: ['agent.activity.'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentProfileService),
|
||||
label: 'AgentProfileService',
|
||||
scope: 'agent',
|
||||
fetch: async (svc) => ({
|
||||
model: await call(svc, 'getModel'),
|
||||
hasModel: await call(svc, 'hasModel'),
|
||||
isRunnable: await call(svc, 'isRunnable'),
|
||||
data: await call(svc, 'data'),
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Set model', input: 'Model id', run: (svc, model) => call(svc, 'setModel', model) },
|
||||
{ label: 'Refresh system prompt', run: (svc) => call(svc, 'refreshSystemPrompt') },
|
||||
],
|
||||
refreshOn: ['agent.status.updated'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentUsageService),
|
||||
label: 'AgentUsageService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'status'),
|
||||
refreshOn: ['turn.step.completed', 'agent.status.updated', 'turn.ended'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentContextSizeService),
|
||||
label: 'AgentContextSizeService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'get'),
|
||||
refreshOn: ['turn.', 'context.', 'compaction.'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentPermissionModeService),
|
||||
label: 'AgentPermissionModeService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'mode'),
|
||||
actions: setModeModes.map((mode) => ({
|
||||
label: `setMode('${mode}')`,
|
||||
run: (svc) => call(svc, 'setMode', mode),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: String(IAgentPermissionRulesService),
|
||||
label: 'AgentPermissionRulesService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'rules'),
|
||||
},
|
||||
{
|
||||
id: String(IAgentPlanService),
|
||||
label: 'AgentPlanService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'status'),
|
||||
actions: [
|
||||
{ label: 'enter', run: (svc) => call(svc, 'enter') },
|
||||
{ label: 'cancel', run: (svc) => call(svc, 'cancel') },
|
||||
{ label: 'clear', run: (svc) => call(svc, 'clear') },
|
||||
],
|
||||
refreshOn: ['turn.ended'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentGoalService),
|
||||
label: 'AgentGoalService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'getGoal'),
|
||||
actions: [
|
||||
{ label: 'pause', run: (svc) => call(svc, 'pauseGoal', {}) },
|
||||
{ label: 'resume', run: (svc) => call(svc, 'resumeGoal', {}) },
|
||||
{ label: 'cancel', danger: true, run: (svc) => call(svc, 'cancelGoal', {}) },
|
||||
],
|
||||
refreshOn: ['goal.updated'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentTaskService),
|
||||
label: 'AgentTaskService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'list'),
|
||||
actions: [
|
||||
{ label: 'Stop task', input: 'Task id', danger: true, run: (svc, id) => call(svc, 'stop', id) },
|
||||
{ label: 'stopAll', danger: true, run: (svc) => call(svc, 'stopAll') },
|
||||
],
|
||||
refreshOn: ['task.', 'subagent.'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentToolRegistryService),
|
||||
label: 'AgentToolRegistryService',
|
||||
scope: 'agent',
|
||||
fetch: async (svc) => {
|
||||
const tools = (await call(svc, 'list')) as readonly { name?: string }[];
|
||||
return { count: tools.length, names: tools.map((t) => t.name) };
|
||||
},
|
||||
refreshOn: ['tool.list.updated'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentMcpService),
|
||||
label: 'AgentMcpService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'list'),
|
||||
actions: [
|
||||
{ label: 'Reconnect server', input: 'Server name', run: (svc, name) => call(svc, 'reconnect', name) },
|
||||
],
|
||||
refreshOn: ['mcp.server.status'],
|
||||
},
|
||||
{
|
||||
id: String(IAgentSwarmService),
|
||||
label: 'AgentSwarmService',
|
||||
scope: 'agent',
|
||||
fetch: (svc) => call(svc, 'isActive'),
|
||||
actions: [
|
||||
{ label: 'enter (manual)', run: (svc) => call(svc, 'enter', 'manual') },
|
||||
{ label: 'exit', run: (svc) => call(svc, 'exit') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: String(IAgentRPCService),
|
||||
label: 'AgentRPCService',
|
||||
scope: 'agent',
|
||||
actions: [
|
||||
{ label: 'cancel turn', run: (svc) => call(svc, 'cancel', {}) },
|
||||
{
|
||||
label: 'undoHistory',
|
||||
input: 'Steps',
|
||||
run: (svc, n) => call(svc, 'undoHistory', { count: Number(n) }),
|
||||
},
|
||||
{ label: 'beginCompaction', run: (svc) => call(svc, 'beginCompaction', {}) },
|
||||
{ label: 'clearContext', danger: true, run: (svc) => call(svc, 'clearContext', {}) },
|
||||
],
|
||||
},
|
||||
];
|
||||
70
apps/kimi-inspect/src/servers.ts
Normal file
70
apps/kimi-inspect/src/servers.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Local server discovery (browser side) — reads the dev/preview middleware at
|
||||
* `/__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local
|
||||
* kap-server instance registry and reads the home token. Powers the
|
||||
* zero-config startup connect, the header server switcher, and the discovered
|
||||
* list on the connect screen. Returns `null` outside dev/preview (no such
|
||||
* endpoint on a static host) so the app falls back to the manual flow.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export type DiscoveredServerSource = 'instance' | 'lock' | 'proxy';
|
||||
|
||||
export interface DiscoveredServer {
|
||||
readonly id: string;
|
||||
readonly url: string;
|
||||
readonly pid?: number;
|
||||
readonly startedAt?: number;
|
||||
readonly hostVersion?: string;
|
||||
readonly source: DiscoveredServerSource;
|
||||
}
|
||||
|
||||
export interface ServerDiscoveryResult {
|
||||
readonly home: string;
|
||||
readonly token?: string;
|
||||
readonly servers: readonly DiscoveredServer[];
|
||||
}
|
||||
|
||||
export async function fetchServerDiscovery(): Promise<ServerDiscoveryResult | null> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch('/__inspect/servers');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
try {
|
||||
const parsed = (await res.json()) as ServerDiscoveryResult;
|
||||
return Array.isArray(parsed.servers) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll discovery: instances come and go (the server-side heartbeat is 15 s). */
|
||||
export function useServerDiscovery() {
|
||||
return useQuery({
|
||||
queryKey: ['local-server-discovery'],
|
||||
queryFn: fetchServerDiscovery,
|
||||
refetchInterval: 10_000,
|
||||
retry: false,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Pick the auto-connect target: the remembered pick when it is still alive,
|
||||
* else the dev-proxy target (the standard `pnpm dev:v2` flow), else the
|
||||
* longest-running instance. */
|
||||
export function pickDefaultServer(
|
||||
discovery: ServerDiscoveryResult,
|
||||
rememberedUrl?: string | null,
|
||||
): DiscoveredServer | undefined {
|
||||
const { servers } = discovery;
|
||||
if (servers.length === 0) return undefined;
|
||||
if (rememberedUrl !== undefined && rememberedUrl !== null && rememberedUrl !== '') {
|
||||
const remembered = servers.find((s) => s.url === rememberedUrl);
|
||||
if (remembered !== undefined) return remembered;
|
||||
}
|
||||
return servers.find((s) => s.source === 'proxy') ?? servers[0];
|
||||
}
|
||||
96
apps/kimi-inspect/src/ui.tsx
Normal file
96
apps/kimi-inspect/src/ui.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Small shared UI primitives for the inspector: JSON dump, badges, buttons,
|
||||
* relative time. Deliberately minimal — this is an internal devtool.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function JsonView({ data, empty }: { data: unknown; empty?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (data === undefined || data === null) {
|
||||
return <div className="text-[11px] text-neutral-600 italic">{empty ?? 'no data'}</div>;
|
||||
}
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
const long = text.length > 500;
|
||||
return (
|
||||
<pre
|
||||
className={`cursor-text overflow-auto rounded bg-neutral-950/70 p-2 font-mono text-[11px] leading-relaxed text-neutral-300 ${
|
||||
long && !open ? 'max-h-48' : 'max-h-[28rem]'
|
||||
}`}
|
||||
onClick={() => long && setOpen((v) => !v)}
|
||||
title={long ? 'click to expand / collapse' : undefined}
|
||||
>
|
||||
{long && !open ? `${text.slice(0, 500)}\n… (${text.length} chars, click to expand)` : text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tone?: 'neutral' | 'green' | 'amber' | 'red' | 'sky';
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: 'bg-neutral-800 text-neutral-300',
|
||||
green: 'bg-emerald-900/60 text-emerald-300',
|
||||
amber: 'bg-amber-900/60 text-amber-300',
|
||||
red: 'bg-red-900/60 text-red-300',
|
||||
sky: 'bg-sky-900/60 text-sky-300',
|
||||
};
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${tones[tone]}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
children,
|
||||
onClick,
|
||||
danger,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void | Promise<void>;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`rounded border px-2 py-1 text-[11px] transition-colors disabled:opacity-40 ${
|
||||
danger
|
||||
? 'border-red-900/70 text-red-400 hover:bg-red-950/60'
|
||||
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
void onClick();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function relTime(epochMs: number | undefined): string {
|
||||
if (epochMs === undefined) return '';
|
||||
const diff = Date.now() - epochMs;
|
||||
if (diff < 60_000) return `${Math.max(0, Math.round(diff / 1000))}s ago`;
|
||||
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
|
||||
return new Date(epochMs).toLocaleDateString();
|
||||
}
|
||||
|
||||
/** Render an unknown thrown value as display text (never "[object Object]"). */
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
if (error === null || typeof error !== 'object') return String(error);
|
||||
return JSON.stringify(error) ?? 'unknown error';
|
||||
}
|
||||
|
||||
export function ErrorLine({ error }: { error: unknown }) {
|
||||
if (error === null || error === undefined) return null;
|
||||
const msg = errorMessage(error);
|
||||
return <div className="rounded bg-red-950/50 px-2 py-1 text-[11px] text-red-400">{msg}</div>;
|
||||
}
|
||||
21
apps/kimi-inspect/tsconfig.json
Normal file
21
apps/kimi-inspect/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
// Mirror the repo root tsconfig: agent-core-v2 is consumed through source
|
||||
// `exports`, so its sources are type-checked here too, and the strictness
|
||||
// flags must match the root exactly. The src includes are required (not
|
||||
// just for speed): agent-core-v2 fills interfaces like `AgentTaskInfoByKind`
|
||||
// via cross-module `declare module` augmentation, and those collapse to
|
||||
// `never` when the augmenting files are not in the program. Only the
|
||||
// environment (DOM + vite client types) differs.
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["node", "vite/client"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"vite",
|
||||
"vite.config.ts",
|
||||
"../../packages/agent-core-v2/src"
|
||||
]
|
||||
}
|
||||
37
apps/kimi-inspect/vite.config.ts
Normal file
37
apps/kimi-inspect/vite.config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
import { serverDiscoveryPlugin } from './vite/serverDiscovery';
|
||||
|
||||
const webPort = Number(process.env['INSPECT_PORT']) || 5176;
|
||||
// Where the dev proxy forwards server traffic. The app can also connect to an
|
||||
// arbitrary server URL typed into the connect screen (loopback cross-origin is
|
||||
// allowed by kap-server), but the default connection is same-origin through
|
||||
// this proxy so no CORS / Origin handling is involved.
|
||||
const serverTarget = process.env['KIMI_SERVER_URL'] || 'http://127.0.0.1:58627';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), serverDiscoveryPlugin({ proxyTarget: serverTarget })],
|
||||
define: {
|
||||
__KIMI_INSPECT_PROXY_TARGET__: JSON.stringify(serverTarget),
|
||||
},
|
||||
server: {
|
||||
port: webPort,
|
||||
strictPort: false,
|
||||
proxy: {
|
||||
'/api': { target: serverTarget, changeOrigin: true, ws: true },
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: Number(process.env['INSPECT_PREVIEW_PORT']) || 4176,
|
||||
proxy: {
|
||||
'/api': { target: serverTarget, changeOrigin: true, ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
target: 'es2022',
|
||||
},
|
||||
});
|
||||
137
apps/kimi-inspect/vite/serverDiscovery.test.ts
Normal file
137
apps/kimi-inspect/vite/serverDiscovery.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Unit tests for the local server discovery middleware helpers
|
||||
* (`vite/serverDiscovery.ts`): instance/lock/token file reading, pid-liveness
|
||||
* filtering, URL normalization and dedupe. Runs in Node (mkdtemp homes).
|
||||
*/
|
||||
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
discoverLocalServers,
|
||||
pidAlive,
|
||||
readLiveInstances,
|
||||
readLiveLock,
|
||||
readServerToken,
|
||||
resolveKimiHomeDir,
|
||||
} from './serverDiscovery';
|
||||
|
||||
const ALIVE_PID = process.pid;
|
||||
// Far above any realistic pid_max; must not collide with a live process.
|
||||
const DEAD_PID = 999_999_999;
|
||||
|
||||
let home: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-inspect-discovery-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 });
|
||||
});
|
||||
|
||||
async function writeInstance(
|
||||
id: string,
|
||||
disk: { pid: number; host?: string; port: number; started_at?: number; host_version?: string },
|
||||
): Promise<void> {
|
||||
const dir = join(home, 'server', 'instances');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, `${id}.json`), JSON.stringify({ server_id: id, ...disk }));
|
||||
}
|
||||
|
||||
describe('pidAlive', () => {
|
||||
it('probes the current process as alive and a bogus pid as dead', () => {
|
||||
expect(pidAlive(ALIVE_PID)).toBe(true);
|
||||
expect(pidAlive(DEAD_PID)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLiveInstances', () => {
|
||||
it('lists live instances sorted by started_at, dropping dead pids and bad files', async () => {
|
||||
await writeInstance('b-older', { pid: ALIVE_PID, port: 58628, started_at: 100 });
|
||||
await writeInstance('a-newer', { pid: ALIVE_PID, port: 58629, started_at: 200 });
|
||||
await writeInstance('x-dead', { pid: DEAD_PID, port: 58630, started_at: 50 });
|
||||
await mkdir(join(home, 'server', 'instances'), { recursive: true });
|
||||
await writeFile(join(home, 'server', 'instances', 'garbage.json'), '{not json');
|
||||
|
||||
const instances = await readLiveInstances(home);
|
||||
expect(instances.map((i) => i.id)).toEqual(['b-older', 'a-newer']);
|
||||
expect(instances[0]!.url).toBe('http://127.0.0.1:58628');
|
||||
});
|
||||
|
||||
it('normalizes wildcard hosts to loopback', async () => {
|
||||
await writeInstance('wild', { pid: ALIVE_PID, host: '0.0.0.0', port: 58627, started_at: 1 });
|
||||
const instances = await readLiveInstances(home);
|
||||
expect(instances[0]!.url).toBe('http://127.0.0.1:58627');
|
||||
});
|
||||
|
||||
it('returns [] when the instances directory does not exist', async () => {
|
||||
await expect(readLiveInstances(home)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLiveLock / readServerToken', () => {
|
||||
it('reads the legacy lock only when its pid is alive', async () => {
|
||||
await mkdir(join(home, 'server'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'server', 'lock'),
|
||||
JSON.stringify({ pid: ALIVE_PID, port: 58627, started_at: 42 }),
|
||||
);
|
||||
expect((await readLiveLock(home))?.url).toBe('http://127.0.0.1:58627');
|
||||
|
||||
await writeFile(join(home, 'server', 'lock'), JSON.stringify({ pid: DEAD_PID, port: 58627 }));
|
||||
await expect(readLiveLock(home)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads the home token, undefined when missing or empty', async () => {
|
||||
await expect(readServerToken(home)).resolves.toBeUndefined();
|
||||
await mkdir(join(home, 'server'), { recursive: true });
|
||||
await writeFile(join(home, 'server.token'), ' tok-123\n');
|
||||
await expect(readServerToken(home)).resolves.toBe('tok-123');
|
||||
await writeFile(join(home, 'server.token'), '\n');
|
||||
await expect(readServerToken(home)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverLocalServers', () => {
|
||||
it('merges instances + lock + proxy target, deduped by url, with the home token', async () => {
|
||||
await writeInstance('inst', { pid: ALIVE_PID, port: 58627, started_at: 1 });
|
||||
await mkdir(join(home, 'server'), { recursive: true });
|
||||
// Lock points at another (also live) server on a different port.
|
||||
await writeFile(join(home, 'server', 'lock'), JSON.stringify({ pid: ALIVE_PID, port: 59000 }));
|
||||
await writeFile(join(home, 'server.token'), 'tok-xyz');
|
||||
|
||||
const payload = await discoverLocalServers({
|
||||
homeDir: home,
|
||||
// Proxy target collides with the instance → dedupe keeps the instance.
|
||||
proxyTarget: 'http://127.0.0.1:58627/',
|
||||
});
|
||||
expect(payload.home).toBe(home);
|
||||
expect(payload.token).toBe('tok-xyz');
|
||||
expect(payload.servers.map((s) => [s.url, s.source])).toEqual([
|
||||
['http://127.0.0.1:58627', 'instance'],
|
||||
['http://127.0.0.1:59000', 'lock'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the proxy entry when nothing else is discovered', async () => {
|
||||
const payload = await discoverLocalServers({
|
||||
homeDir: home,
|
||||
proxyTarget: 'http://127.0.0.1:58627',
|
||||
});
|
||||
expect(payload.servers).toEqual([
|
||||
{ id: 'proxy', url: 'http://127.0.0.1:58627', source: 'proxy' },
|
||||
]);
|
||||
expect(payload.token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveKimiHomeDir', () => {
|
||||
it('honors KIMI_CODE_HOME, else falls back to ~/.kimi-code', () => {
|
||||
expect(resolveKimiHomeDir({ KIMI_CODE_HOME: '/tmp/kh' })).toBe('/tmp/kh');
|
||||
expect(resolveKimiHomeDir({})).toBe(join(process.env['HOME'] ?? '', '.kimi-code'));
|
||||
});
|
||||
});
|
||||
226
apps/kimi-inspect/vite/serverDiscovery.ts
Normal file
226
apps/kimi-inspect/vite/serverDiscovery.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* Local kap-server discovery — a dev/preview middleware that lets the browser
|
||||
* see and reach every kap-server running on this machine without typing a URL
|
||||
* or a token.
|
||||
*
|
||||
* kap-server already self-registers for peer discovery
|
||||
* (`packages/kap-server/src/instanceRegistry.ts`):
|
||||
* multi_server `<kimi home>/server/instances/<serverId>.json`
|
||||
* single-server `<kimi home>/server/lock`
|
||||
* and persists the bearer token at `<kimi home>/server.token` (one token per
|
||||
* home, shared by every instance). The browser cannot read those files, but
|
||||
* this Vite process can, so `GET /__inspect/servers` answers with the live
|
||||
* instances (pid-liveness filtered), the dev-proxy target, and the token.
|
||||
*
|
||||
* The registry/lock file formats are deliberately reimplemented here (~100
|
||||
* lines) instead of importing kap-server: the inspector must stay free of
|
||||
* server-side dependencies.
|
||||
*
|
||||
* Security: dev/preview only, bound to loopback by Vite defaults. It hands
|
||||
* out the same token the user would otherwise paste from
|
||||
* `~/.kimi-code/server.token` by hand — no new exposure beyond the local dev
|
||||
* session.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
export const SERVER_DISCOVERY_ENDPOINT = '/__inspect/servers';
|
||||
|
||||
export type DiscoveredServerSource = 'instance' | 'lock' | 'proxy';
|
||||
|
||||
export interface DiscoveredServerInfo {
|
||||
readonly id: string;
|
||||
readonly url: string;
|
||||
readonly pid?: number;
|
||||
readonly startedAt?: number;
|
||||
readonly hostVersion?: string;
|
||||
readonly source: DiscoveredServerSource;
|
||||
}
|
||||
|
||||
export interface ServerDiscoveryPayload {
|
||||
readonly home: string;
|
||||
readonly token?: string;
|
||||
readonly servers: readonly DiscoveredServerInfo[];
|
||||
}
|
||||
|
||||
/** Mirror of the on-disk instance file (`server_id` …, snake_case). */
|
||||
interface ServerInstanceDisk {
|
||||
server_id?: string;
|
||||
pid?: number;
|
||||
host?: string;
|
||||
port?: number;
|
||||
started_at?: number;
|
||||
host_version?: string;
|
||||
}
|
||||
|
||||
/** Mirror of `LockContents` (`packages/kap-server/src/lock.ts`). */
|
||||
interface ServerLockDisk {
|
||||
pid?: number;
|
||||
host?: string;
|
||||
port?: number;
|
||||
started_at?: number;
|
||||
host_version?: string;
|
||||
}
|
||||
|
||||
/** home resolution per request: `KIMI_CODE_HOME` env, else `~/.kimi-code`. */
|
||||
export function resolveKimiHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const fromEnv = env['KIMI_CODE_HOME'];
|
||||
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
|
||||
return join(homedir(), '.kimi-code');
|
||||
}
|
||||
|
||||
/** `process.kill(pid, 0)` probe — same semantics as the server's registry:
|
||||
* ESRCH = dead, EPERM/anything else = alive (never clobber a live entry). */
|
||||
export function pidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser-reachable host: wildcard binds advertise as loopback. */
|
||||
function normalizeHost(host: string | undefined): string {
|
||||
if (host === undefined || host === '' || host === '0.0.0.0' || host === '::' || host === '[::]') {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function toUrl(host: string | undefined, port: number): string {
|
||||
return `http://${normalizeHost(host)}:${port}`;
|
||||
}
|
||||
|
||||
async function readJson<T>(filePath: string): Promise<T | undefined> {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8')) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Live instances under `<home>/server/instances`, sorted by `started_at`
|
||||
* ascending (longest-running first, matching the server's own ordering). */
|
||||
export async function readLiveInstances(homeDir: string): Promise<readonly DiscoveredServerInfo[]> {
|
||||
const instancesDir = join(homeDir, 'server', 'instances');
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(instancesDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const live: { started_at: number; info: DiscoveredServerInfo }[] = [];
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.map(async (name) => {
|
||||
const disk = await readJson<ServerInstanceDisk>(join(instancesDir, name));
|
||||
if (
|
||||
disk === undefined ||
|
||||
typeof disk.server_id !== 'string' ||
|
||||
typeof disk.pid !== 'number' ||
|
||||
typeof disk.port !== 'number' ||
|
||||
!pidAlive(disk.pid)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
live.push({
|
||||
started_at: typeof disk.started_at === 'number' ? disk.started_at : 0,
|
||||
info: {
|
||||
id: disk.server_id,
|
||||
url: toUrl(disk.host, disk.port),
|
||||
pid: disk.pid,
|
||||
startedAt: typeof disk.started_at === 'number' ? disk.started_at : undefined,
|
||||
hostVersion: typeof disk.host_version === 'string' ? disk.host_version : undefined,
|
||||
source: 'instance',
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
live.sort((a, b) => a.started_at - b.started_at);
|
||||
return live.map((entry) => entry.info);
|
||||
}
|
||||
|
||||
/** The legacy single-server lock (`<home>/server/lock`), when its pid is alive. */
|
||||
export async function readLiveLock(homeDir: string): Promise<DiscoveredServerInfo | undefined> {
|
||||
const disk = await readJson<ServerLockDisk>(join(homeDir, 'server', 'lock'));
|
||||
if (disk === undefined || typeof disk.pid !== 'number' || typeof disk.port !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
if (!pidAlive(disk.pid)) return undefined;
|
||||
return {
|
||||
id: 'lock',
|
||||
url: toUrl(disk.host, disk.port),
|
||||
pid: disk.pid,
|
||||
startedAt: typeof disk.started_at === 'number' ? disk.started_at : undefined,
|
||||
hostVersion: typeof disk.host_version === 'string' ? disk.host_version : undefined,
|
||||
source: 'lock',
|
||||
};
|
||||
}
|
||||
|
||||
/** The home-wide bearer token (`<home>/server.token`); undefined when absent/unreadable. */
|
||||
export async function readServerToken(homeDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const token = (await readFile(join(homeDir, 'server.token'), 'utf8')).trim();
|
||||
return token.length > 0 ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscoverOptions {
|
||||
/** The dev-proxy target (`KIMI_SERVER_URL`); merged as a `proxy` entry. */
|
||||
readonly proxyTarget?: string;
|
||||
/** home override; defaults to the request-time env resolution. */
|
||||
readonly homeDir?: string;
|
||||
}
|
||||
|
||||
/** Assemble the full discovery payload: instances (oldest first) + lock +
|
||||
* proxy target, deduped by normalized URL, plus the home token. */
|
||||
export async function discoverLocalServers(
|
||||
options: DiscoverOptions = {},
|
||||
): Promise<ServerDiscoveryPayload> {
|
||||
const home = options.homeDir ?? resolveKimiHomeDir();
|
||||
const [instances, lock, token] = await Promise.all([
|
||||
readLiveInstances(home),
|
||||
readLiveLock(home),
|
||||
readServerToken(home),
|
||||
]);
|
||||
const byUrl = new Map<string, DiscoveredServerInfo>();
|
||||
for (const info of instances) byUrl.set(info.url, info);
|
||||
if (lock !== undefined && !byUrl.has(lock.url)) byUrl.set(lock.url, lock);
|
||||
const proxyUrl = options.proxyTarget?.replace(/\/$/, '');
|
||||
if (proxyUrl !== undefined && proxyUrl !== '' && !byUrl.has(proxyUrl)) {
|
||||
byUrl.set(proxyUrl, { id: 'proxy', url: proxyUrl, source: 'proxy' });
|
||||
}
|
||||
return { home, token, servers: [...byUrl.values()] };
|
||||
}
|
||||
|
||||
/** Vite plugin exposing `GET /__inspect/servers` on dev and preview servers. */
|
||||
export function serverDiscoveryPlugin(options: { proxyTarget: string }): Plugin {
|
||||
const handler = (_req: unknown, res: { setHeader(name: string, value: string): void; end(data: string): void }): void => {
|
||||
void discoverLocalServers({ proxyTarget: options.proxyTarget })
|
||||
.then((payload) => {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify(payload));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ error: String(error), servers: [] }));
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'kimi-inspect-server-discovery',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(SERVER_DISCOVERY_ENDPOINT, handler);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(SERVER_DISCOVERY_ENDPOINT, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -79,6 +79,7 @@
|
|||
./apps/kimi-code
|
||||
./apps/kimi-desktop
|
||||
./apps/vscode
|
||||
./apps/kimi-inspect
|
||||
./apps/kimi-web
|
||||
./apps/vis
|
||||
./apps/vis/server
|
||||
|
|
@ -104,6 +105,7 @@
|
|||
"@moonshot-ai/kimi-code"
|
||||
"@moonshot-ai/kimi-desktop"
|
||||
"kimi-code"
|
||||
"@moonshot-ai/kimi-inspect"
|
||||
"@moonshot-ai/kimi-web"
|
||||
"@moonshot-ai/vis"
|
||||
"@moonshot-ai/vis-server"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { registerGuiStoreRoutes } from './guiStore';
|
|||
import { registerMessagesRoutes } from './messages';
|
||||
import type { IGuiStoreService } from '../services/guiStore/guiStore';
|
||||
import type { ISnapshotReader } from '../services/snapshot';
|
||||
import { registerDebugRoutes } from '../transport/registerDebugRoutes';
|
||||
import { registerMetaRoute } from './meta';
|
||||
import { registerModelCatalogRoutes } from './modelCatalog';
|
||||
import { registerOAuthRoutes } from './oauth';
|
||||
|
|
@ -83,6 +84,12 @@ export async function registerApiV1Routes(
|
|||
async (apiV1) => {
|
||||
registerHealthRoute(apiV1);
|
||||
|
||||
// Dev-only debug RPC surface (`--debug-endpoints`, loopback-gated in
|
||||
// `start.ts`): every scoped Service reachable, no `/api/v2` whitelist.
|
||||
if (opts.debugEndpoints === true) {
|
||||
registerDebugRoutes(apiV1 as unknown as Parameters<typeof registerDebugRoutes>[0], core);
|
||||
}
|
||||
|
||||
registerMetaRoute(apiV1, {
|
||||
serverVersion: opts.serverVersion,
|
||||
serverId: ulid(),
|
||||
|
|
|
|||
|
|
@ -246,3 +246,61 @@ const EXPOSED_SERVICES: readonly ServiceIdentifier<unknown>[] = [
|
|||
for (const id of EXPOSED_SERVICES) {
|
||||
registerChannel(id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debug surface — every scoped Service, no whitelist (dev-only `/api/v1/debug`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let serviceNameIndex: Map<string, ServiceIdentifier<unknown>> | undefined;
|
||||
|
||||
/**
|
||||
* Wire name → identifier index over the ENTIRE scoped DI registry. The
|
||||
* decorator registry de-dupes by name, so a wire name maps to exactly one
|
||||
* identifier; a Service registered at several scopes (e.g. `logService`
|
||||
* at App + Session) resolves at its minimal scope, reachable from every
|
||||
* route form.
|
||||
*/
|
||||
function scopedServiceNameIndex(): Map<string, ServiceIdentifier<unknown>> {
|
||||
serviceNameIndex ??= (() => {
|
||||
const map = new Map<string, ServiceIdentifier<unknown>>();
|
||||
for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) {
|
||||
for (const entry of getScopedServiceDescriptors(scope)) {
|
||||
const name = entry.id.toString();
|
||||
if (!map.has(name)) map.set(name, entry.id);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
return serviceNameIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a wire name to its `ServiceIdentifier` without the `/api/v2`
|
||||
* whitelist — dev-only (`/api/v1/debug`) consumers.
|
||||
*/
|
||||
export function resolveAnyScopedServiceId(name: string): ServiceIdentifier<unknown> | undefined {
|
||||
return scopedServiceNameIndex().get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe EVERY registered scoped Service — served by
|
||||
* `GET /api/v1/debug/channels` so dev tooling (kimi-inspect) can load the
|
||||
* full protocol surface 1:1 instead of the whitelist subset.
|
||||
*/
|
||||
export function describeAllChannels(): readonly ChannelDescriptor[] {
|
||||
const byName = new Map<string, ScopedEntry>();
|
||||
for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) {
|
||||
for (const entry of getScopedServiceDescriptors(scope)) {
|
||||
const name = entry.id.toString();
|
||||
if (!byName.has(name)) byName.set(name, entry);
|
||||
}
|
||||
}
|
||||
return [...byName.entries()]
|
||||
.map(([name, entry]) => ({
|
||||
name,
|
||||
scope: SCOPE_NAME[entry.scope],
|
||||
domain: entry.domain,
|
||||
methods: describeMethods(entry.descriptor.ctor),
|
||||
}))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Error2,
|
||||
type IScopeHandle,
|
||||
type Scope,
|
||||
type ServiceIdentifier,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
import type { ScopeKind } from './channel';
|
||||
|
|
@ -20,6 +21,13 @@ import { resolveChannel } from './channelRegistry';
|
|||
import { assertSerializable } from './errors';
|
||||
import { MAIN_AGENT_ID, ensureMainAgent } from './mainAgent';
|
||||
|
||||
/**
|
||||
* Channel name → identifier resolution used to gate which Services are
|
||||
* reachable. `/api/v2` passes the whitelist registry (default);
|
||||
* `/api/v1/debug` passes the full scoped-registry lookup (dev only).
|
||||
*/
|
||||
export type ChannelLookup = (name: string) => ServiceIdentifier<unknown> | undefined;
|
||||
|
||||
/**
|
||||
* Resolve the scope a request targets. Throws `Error2` when the referenced
|
||||
* session or agent does not exist — `session.not_found` for a missing session,
|
||||
|
|
@ -74,6 +82,7 @@ export async function resolveService(
|
|||
scopeKind: ScopeKind,
|
||||
params: Record<string, string>,
|
||||
serviceName: string,
|
||||
lookup: ChannelLookup = resolveChannel,
|
||||
): Promise<object> {
|
||||
const scope = await resolveScope(core, scopeKind, params);
|
||||
if (scope === undefined) {
|
||||
|
|
@ -82,7 +91,7 @@ export async function resolveService(
|
|||
`session ${params['session_id'] ?? ''} not found`,
|
||||
);
|
||||
}
|
||||
const id = resolveChannel(serviceName);
|
||||
const id = lookup(serviceName);
|
||||
if (id === undefined) {
|
||||
throw new Error2(ErrorCodes.REQUEST_INVALID, `unknown service: ${serviceName}`);
|
||||
}
|
||||
|
|
@ -114,8 +123,9 @@ export async function dispatch(
|
|||
serviceName: string,
|
||||
method: string,
|
||||
arg: unknown,
|
||||
lookup: ChannelLookup = resolveChannel,
|
||||
): Promise<unknown> {
|
||||
const service = await resolveService(core, scopeKind, params, serviceName);
|
||||
const service = await resolveService(core, scopeKind, params, serviceName, lookup);
|
||||
const member = (service as Record<string, unknown>)[method];
|
||||
if (member === undefined) {
|
||||
throw new Error2(ErrorCodes.REQUEST_INVALID, `method not found: ${serviceName}.${method}`);
|
||||
|
|
|
|||
29
packages/kap-server/src/transport/registerDebugRoutes.ts
Normal file
29
packages/kap-server/src/transport/registerDebugRoutes.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* `/api/v1/debug` route registration — the dev-only, whitelist-free RPC
|
||||
* surface.
|
||||
*
|
||||
* Same dispatcher and envelope semantics as `/api/v2`
|
||||
* (`registerServiceDispatcherRoutes`), but the channel lookup spans the whole
|
||||
* scoped DI registry, so EVERY Service (App/Session/Agent scope) is callable,
|
||||
* not just the `/api/v2` whitelist. Intended for dev tooling (kimi-inspect),
|
||||
* never for production clients:
|
||||
*
|
||||
* - mounted only when `--debug-endpoints` is passed AND the bind is loopback
|
||||
* (the AND happens in `start.ts`; this module trusts that gate);
|
||||
* - still behind the global bearer-auth hook like every `/api/*` route.
|
||||
*
|
||||
* Called from `registerApiV1Routes` with the prefixed `/api/v1` route host,
|
||||
* so the base path here is relative: `/debug`.
|
||||
*/
|
||||
|
||||
import type { Scope } from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
import { describeAllChannels, resolveAnyScopedServiceId } from './channelRegistry';
|
||||
import { type RouteHost, registerServiceDispatcherRoutes } from './registerRpcRoutes';
|
||||
|
||||
export function registerDebugRoutes(app: RouteHost, core: Scope): void {
|
||||
registerServiceDispatcherRoutes(app, core, '/debug', {
|
||||
lookup: resolveAnyScopedServiceId,
|
||||
describe: describeAllChannels,
|
||||
});
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@
|
|||
* Body (POST) or `?arg=<json>` (GET) is the method's single argument. Responses
|
||||
* are always the project envelope (HTTP 200; business outcome in `code`). Body
|
||||
* size, connection timeout, and graceful close are Fastify's.
|
||||
*
|
||||
* `registerServiceDispatcherRoutes` is the shared, path-agnostic core: the
|
||||
* dev-only `/api/v1/debug` surface (`registerDebugRoutes.ts`) mounts the same
|
||||
* dispatcher with a different base path and a whitelist-free channel lookup.
|
||||
*/
|
||||
|
||||
import type { Scope } from '@moonshot-ai/agent-core-v2';
|
||||
|
|
@ -20,8 +24,8 @@ import { requestLog } from '../lib/requestLog';
|
|||
import { okEnvelope } from '../protocol/envelope';
|
||||
import { ErrorCode } from '../protocol/error-codes';
|
||||
import type { ScopeKind } from './channel';
|
||||
import { describeChannels } from './channelRegistry';
|
||||
import { dispatch } from './dispatcher';
|
||||
import { type ChannelDescriptor, describeChannels, resolveChannel } from './channelRegistry';
|
||||
import { type ChannelLookup, dispatch } from './dispatcher';
|
||||
import { mapError, validationEnvelope, withTimeout } from './errors';
|
||||
|
||||
interface RpcRequest {
|
||||
|
|
@ -38,7 +42,7 @@ interface RpcReply {
|
|||
send(payload: unknown): unknown;
|
||||
}
|
||||
|
||||
interface RouteHost {
|
||||
export interface RouteHost {
|
||||
get(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
|
||||
post(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
|
||||
}
|
||||
|
|
@ -55,34 +59,63 @@ export interface RegisterRpcRoutesOptions {
|
|||
readonly callTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const SCOPE_ROUTES: { path: string; scopeKind: ScopeKind }[] = [
|
||||
{ path: '/api/v2/:service/:method', scopeKind: 'core' },
|
||||
{ path: '/api/v2/session/:session_id/:service/:method', scopeKind: 'session' },
|
||||
{ path: '/api/v2/session/:session_id/agent/:agent_id/:service/:method', scopeKind: 'agent' },
|
||||
];
|
||||
export interface ServiceDispatcherRouteOptions {
|
||||
/** Per-call deadline in ms. Default 30s. */
|
||||
readonly callTimeoutMs?: number;
|
||||
/** Channel name → identifier resolution. Default: the `/api/v2` whitelist registry. */
|
||||
readonly lookup?: ChannelLookup;
|
||||
/** Descriptor source for `GET {basePath}/channels`. Default: the whitelist set. */
|
||||
readonly describe?: () => readonly ChannelDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the reflection dispatcher under `basePath` (e.g. `/api/v2`, or
|
||||
* `/debug` inside the prefixed `/api/v1` plugin): the three scope routes plus
|
||||
* `GET {basePath}/channels` for introspection. `channels` is a single segment,
|
||||
* so it cannot collide with `:service/:method`.
|
||||
*/
|
||||
export function registerServiceDispatcherRoutes(
|
||||
app: RouteHost,
|
||||
core: Scope,
|
||||
basePath: string,
|
||||
opts: ServiceDispatcherRouteOptions = {},
|
||||
): void {
|
||||
const lookup = opts.lookup ?? resolveChannel;
|
||||
const scopeRoutes: { path: string; scopeKind: ScopeKind }[] = [
|
||||
{ path: `${basePath}/:service/:method`, scopeKind: 'core' },
|
||||
{ path: `${basePath}/session/:session_id/:service/:method`, scopeKind: 'session' },
|
||||
{
|
||||
path: `${basePath}/session/:session_id/agent/:agent_id/:service/:method`,
|
||||
scopeKind: 'agent',
|
||||
},
|
||||
];
|
||||
for (const { path, scopeKind } of scopeRoutes) {
|
||||
const handler = makeHandler(core, scopeKind, opts, lookup);
|
||||
app.get(path, handler);
|
||||
app.post(path, handler);
|
||||
}
|
||||
|
||||
// Introspection: the dynamic service browser (kimi-inspect) reads this once
|
||||
// per connection.
|
||||
const describe = opts.describe ?? describeChannels;
|
||||
app.get(`${basePath}/channels`, async (req, reply) =>
|
||||
reply.send(okEnvelope(describe(), req.id)),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerRpcRoutes(
|
||||
app: RouteHost,
|
||||
core: Scope,
|
||||
opts: RegisterRpcRoutesOptions = {},
|
||||
): void {
|
||||
for (const { path, scopeKind } of SCOPE_ROUTES) {
|
||||
const handler = makeHandler(core, scopeKind, opts);
|
||||
app.get(path, handler);
|
||||
app.post(path, handler);
|
||||
}
|
||||
|
||||
// Introspection: the dynamic service browser (kimi-inspect) reads this once
|
||||
// per connection. Single segment, so it cannot collide with `:service/:method`.
|
||||
app.get('/api/v2/channels', async (req, reply) =>
|
||||
reply.send(okEnvelope(describeChannels(), req.id)),
|
||||
);
|
||||
registerServiceDispatcherRoutes(app, core, '/api/v2', opts);
|
||||
}
|
||||
|
||||
function makeHandler(
|
||||
core: Scope,
|
||||
scopeKind: ScopeKind,
|
||||
opts: RegisterRpcRoutesOptions,
|
||||
opts: ServiceDispatcherRouteOptions,
|
||||
lookup: ChannelLookup,
|
||||
): (req: RpcRequest, reply: RpcReply) => Promise<unknown> {
|
||||
return async (req, reply) => {
|
||||
const requestId = req.id;
|
||||
|
|
@ -113,6 +146,7 @@ function makeHandler(
|
|||
service,
|
||||
method,
|
||||
arg,
|
||||
lookup,
|
||||
),
|
||||
opts.callTimeoutMs ?? 30_000,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* Debug-route suppression (port of v1 `debug-nonloopback.e2e.test.ts`).
|
||||
* Debug-route gating (port of v1 `debug-nonloopback.e2e.test.ts`).
|
||||
*
|
||||
* Security property: `/api/v1/debug/*` introspection/mutation endpoints must
|
||||
* NOT be reachable on a non-loopback bind. server-v2 does not register the
|
||||
* debug routes at all (the `debugEndpoints` option is currently a no-op), so
|
||||
* the property holds trivially — the routes are 404 on every bind. This test
|
||||
* pins that guarantee on a public bind and documents the current (not-yet-
|
||||
* implemented) loopback behavior.
|
||||
* Security property: `/api/v1/debug/*` — the dev-only, whitelist-free RPC
|
||||
* surface (`--debug-endpoints`) — must NOT be reachable on a non-loopback
|
||||
* bind (suppressed in `start.ts` regardless of the option), and must stay
|
||||
* unmounted by default on loopback too. Pinned here: 404 on a public bind
|
||||
* even with `debugEndpoints: true`, 404 on loopback without the option, and
|
||||
* the mounted surface on loopback with the option.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
|
|
@ -51,10 +51,9 @@ async function tmpHome(): Promise<string> {
|
|||
|
||||
async function probeDebug(server: RunningServer): Promise<number> {
|
||||
const token = server.authTokenService.getToken();
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${server.port}/api/v1/debug/prompts/some-session/state`,
|
||||
{ headers: { authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const res = await fetch(`http://127.0.0.1:${server.port}/api/v1/debug/channels`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +74,19 @@ describe('debug endpoints are not exposed on a non-loopback bind', () => {
|
|||
expect(await probeDebug(server)).toBe(404);
|
||||
});
|
||||
|
||||
it('is not mounted on loopback either (server-v2 does not implement debug routes yet)', async () => {
|
||||
it('is not mounted on loopback by default (without the option)', async () => {
|
||||
const home = await tmpHome();
|
||||
const server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
running.push(server);
|
||||
expect(await probeDebug(server)).toBe(404);
|
||||
});
|
||||
|
||||
it('mounts the whitelist-free RPC surface on loopback when requested', async () => {
|
||||
const home = await tmpHome();
|
||||
const server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
|
|
@ -85,6 +96,6 @@ describe('debug endpoints are not exposed on a non-loopback bind', () => {
|
|||
debugEndpoints: true,
|
||||
});
|
||||
running.push(server);
|
||||
expect(await probeDebug(server)).toBe(404);
|
||||
expect(await probeDebug(server)).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
IAgentGoalService,
|
||||
IAgentLifecycleService,
|
||||
IAgentRPCService,
|
||||
IAppendLogStore,
|
||||
IEventService,
|
||||
IPluginService,
|
||||
ISessionIndex,
|
||||
|
|
@ -657,3 +658,99 @@ describe('server-v2 /api/v2 RPC auth', () => {
|
|||
expect(body.code).toBe(40101);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-debug-rpc-'));
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
debugEndpoints: true,
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never);
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function call<T>(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
arg?: unknown,
|
||||
): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${(server as RunningServer).authTokenService.getToken()}`,
|
||||
};
|
||||
const init: { method: string; headers: Record<string, string>; body?: string } = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
if (arg !== undefined) {
|
||||
headers['content-type'] = 'application/json';
|
||||
init.body = JSON.stringify(arg);
|
||||
}
|
||||
const res = await fetch(`${base}${path}`, init);
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
it('describes every scoped Service via GET /api/v1/debug/channels', async () => {
|
||||
const { status, body } = await call<readonly { name: string; scope: string }[]>(
|
||||
'GET',
|
||||
'/api/v1/debug/channels',
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(body.code).toBe(0);
|
||||
// Well past the /api/v2 whitelist size: the debug surface spans the whole
|
||||
// scoped DI registry (App + Session + Agent).
|
||||
expect(body.data.length).toBeGreaterThan(50);
|
||||
const names = body.data.map((c) => c.name);
|
||||
// Internal (non-whitelisted) Services are included...
|
||||
expect(names).toContain(String(IAppendLogStore));
|
||||
// ...alongside the regular whitelisted ones.
|
||||
expect(names).toContain(String(ISessionIndex));
|
||||
});
|
||||
|
||||
it('calls a non-whitelisted Service method', async () => {
|
||||
const { status, body } = await call(
|
||||
'POST',
|
||||
`/api/v1/debug/${String(IAppendLogStore)}/flush`,
|
||||
[],
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(body.code).toBe(0);
|
||||
});
|
||||
|
||||
it('also reaches whitelisted Services by the same wire names', async () => {
|
||||
const { body } = await call<{ items: unknown[] }>(
|
||||
'POST',
|
||||
`/api/v1/debug/${String(ISessionIndex)}/list`,
|
||||
[{ limit: 1 }],
|
||||
);
|
||||
expect(body.code).toBe(0);
|
||||
expect(Array.isArray(body.data.items)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown service with 40001', async () => {
|
||||
const { body } = await call('POST', '/api/v1/debug/noSuchService/whatever', []);
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('is gated by the same bearer auth as the rest of /api/*', async () => {
|
||||
const res = await fetch(`${base}/api/v1/debug/channels`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
112
pnpm-lock.yaml
generated
112
pnpm-lock.yaml
generated
|
|
@ -61,7 +61,7 @@ importers:
|
|||
version: 2.13.1
|
||||
tsdown:
|
||||
specifier: 0.22.0
|
||||
version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2))
|
||||
version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(vue-tsc@3.2.9(typescript@6.0.2))
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
|
@ -169,6 +169,46 @@ importers:
|
|||
specifier: 6.0.2
|
||||
version: 6.0.2
|
||||
|
||||
apps/kimi-inspect:
|
||||
dependencies:
|
||||
'@moonshot-ai/agent-core-v2':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/agent-core-v2
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.74.4
|
||||
version: 5.99.2(react@19.2.5)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.5
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.5(react@19.2.5)
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.18(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@types/react':
|
||||
specifier: ^19.1.2
|
||||
version: 19.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^19.1.2
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.4.1
|
||||
version: 4.7.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
tailwindcss:
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.18
|
||||
typescript:
|
||||
specifier: 6.0.2
|
||||
version: 6.0.2
|
||||
vite:
|
||||
specifier: ^6.3.3
|
||||
version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitest:
|
||||
specifier: 4.1.4
|
||||
version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
apps/kimi-web:
|
||||
dependencies:
|
||||
'@chenglou/pretext':
|
||||
|
|
@ -13545,14 +13585,6 @@ snapshots:
|
|||
'@rolldown/binding-openharmony-arm64@1.0.1':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
|
|
@ -19716,31 +19748,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- oxc-resolver
|
||||
|
||||
rolldown@1.0.0-rc.12:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.122.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.12
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.12
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.12
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.12
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.12
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.122.0
|
||||
|
|
@ -20707,35 +20714,6 @@ snapshots:
|
|||
- oxc-resolver
|
||||
- vue-tsc
|
||||
|
||||
tsdown@0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2)):
|
||||
dependencies:
|
||||
ansis: 4.2.0
|
||||
cac: 7.0.0
|
||||
defu: 6.1.7
|
||||
empathic: 2.0.0
|
||||
hookable: 6.1.1
|
||||
import-without-cache: 0.4.0
|
||||
obug: 2.1.1
|
||||
picomatch: 4.0.4
|
||||
rolldown: 1.0.1
|
||||
rolldown-plugin-dts: 0.25.1(rolldown@1.0.1)(typescript@6.0.2)(vue-tsc@3.2.9(typescript@6.0.2))
|
||||
semver: 7.7.4
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.16
|
||||
tree-kill: 1.2.2
|
||||
unconfig-core: 7.5.0
|
||||
optionalDependencies:
|
||||
'@arethetypeswrong/core': 0.18.2
|
||||
publint: 0.3.18
|
||||
tsx: 4.21.0
|
||||
typescript: 6.0.2
|
||||
unrun: 0.2.34
|
||||
transitivePeerDependencies:
|
||||
- '@ts-macro/tsc'
|
||||
- '@typescript/native-preview'
|
||||
- oxc-resolver
|
||||
- vue-tsc
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
|
|
@ -20936,14 +20914,6 @@ snapshots:
|
|||
picomatch: 4.0.4
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
unrun@0.2.34:
|
||||
dependencies:
|
||||
rolldown: 1.0.0-rc.12
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
dependencies:
|
||||
rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue