From 6e9dd59c15aa609cb31d7552aa91b346d8217065 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 16 Jul 2026 18:43:28 +0800 Subject: [PATCH 1/4] 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 --- AGENTS.md | 1 + apps/kimi-inspect/index.html | 12 + apps/kimi-inspect/package.json | 41 ++ apps/kimi-inspect/src/App.tsx | 117 +++ apps/kimi-inspect/src/components/ChatView.tsx | 333 +++++++++ .../kimi-inspect/src/components/Inspector.tsx | 684 ++++++++++++++++++ apps/kimi-inspect/src/components/Sidebar.tsx | 192 +++++ apps/kimi-inspect/src/connection.tsx | 182 +++++ apps/kimi-inspect/src/genericKlient.ts | 115 +++ apps/kimi-inspect/src/index.css | 33 + apps/kimi-inspect/src/live.tsx | 92 +++ apps/kimi-inspect/src/main.tsx | 23 + apps/kimi-inspect/src/panels.ts | 287 ++++++++ apps/kimi-inspect/src/ui.tsx | 96 +++ apps/kimi-inspect/tsconfig.json | 20 + apps/kimi-inspect/vite.config.ts | 35 + flake.nix | 2 + pnpm-lock.yaml | 43 ++ 18 files changed, 2308 insertions(+) create mode 100644 apps/kimi-inspect/index.html create mode 100644 apps/kimi-inspect/package.json create mode 100644 apps/kimi-inspect/src/App.tsx create mode 100644 apps/kimi-inspect/src/components/ChatView.tsx create mode 100644 apps/kimi-inspect/src/components/Inspector.tsx create mode 100644 apps/kimi-inspect/src/components/Sidebar.tsx create mode 100644 apps/kimi-inspect/src/connection.tsx create mode 100644 apps/kimi-inspect/src/genericKlient.ts create mode 100644 apps/kimi-inspect/src/index.css create mode 100644 apps/kimi-inspect/src/live.tsx create mode 100644 apps/kimi-inspect/src/main.tsx create mode 100644 apps/kimi-inspect/src/panels.ts create mode 100644 apps/kimi-inspect/src/ui.tsx create mode 100644 apps/kimi-inspect/tsconfig.json create mode 100644 apps/kimi-inspect/vite.config.ts diff --git a/AGENTS.md b/AGENTS.md index df1b117c71..30f0acb011 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ 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 `@moonshot-ai/klient` (HTTP for calls, `/api/v2/ws` for events); the Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). - `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. diff --git a/apps/kimi-inspect/index.html b/apps/kimi-inspect/index.html new file mode 100644 index 0000000000..3a9b3b5e59 --- /dev/null +++ b/apps/kimi-inspect/index.html @@ -0,0 +1,12 @@ + + + + + + Kimi Inspect + + +
+ + + diff --git a/apps/kimi-inspect/package.json b/apps/kimi-inspect/package.json new file mode 100644 index 0000000000..027e0ada22 --- /dev/null +++ b/apps/kimi-inspect/package.json @@ -0,0 +1,41 @@ +{ + "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:^", + "@moonshot-ai/klient": "workspace:^", + "@tanstack/react-query": "^5.99.2", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.2.2", + "typescript": "6.0.2", + "vite": "^6.4.2", + "vitest": "4.1.4" + } +} diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx new file mode 100644 index 0000000000..846891a6e1 --- /dev/null +++ b/apps/kimi-inspect/src/App.tsx @@ -0,0 +1,117 @@ +/** + * 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 { 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(null); + const [agentId, setAgentId] = useState('main'); + const [ready, setReady] = useState(false); + const [resumeError, setResumeError] = useState(null); + const emitRef = useRef(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]); + + return ( + +
+
+ KIMI INSPECT + {baseUrl} + + ws: {wsState} + +
+ +
+
+ + {resumeError !== null ? ( +
+ Failed to open session: {errorMessage(resumeError)} +
+ ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx new file mode 100644 index 0000000000..ac6169b8a5 --- /dev/null +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [running, setRunning] = useState(false); + const [sendError, setSendError] = useState(null); + const bottomRef = useRef(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 | 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; + 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 ( +
+ Select a session on the left to open its conversation. +
+ ); + } + if (!ready) { + return ( +
+ Loading session… +
+ ); + } + + return ( +
+
+ {sessionId} + agent: {agentId} + {running ? turn running : idle} +
+ +
+ {history.isError ? : null} + {entries.length === 0 && !history.isLoading ? ( +
Empty context — send a prompt below.
+ ) : null} + {entries.map((entry) => ( + + ))} +
+
+ +
+ {sendError !== null ?
: null} +
+