diff --git a/.changeset/add-session-compact-endpoint.md b/.changeset/add-session-compact-endpoint.md new file mode 100644 index 000000000..a95afa2e7 --- /dev/null +++ b/.changeset/add-session-compact-endpoint.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": patch +"@moonshot-ai/kimi-code": patch +--- + +Add server endpoint to start session context compaction. diff --git a/.changeset/add-session-status-endpoint.md b/.changeset/add-session-status-endpoint.md new file mode 100644 index 000000000..7ba7247dc --- /dev/null +++ b/.changeset/add-session-status-endpoint.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": patch +"@moonshot-ai/kimi-code": patch +--- + +Add server endpoint to query realtime session runtime status. diff --git a/.changeset/align-service-registry-bootstrap.md b/.changeset/align-service-registry-bootstrap.md new file mode 100644 index 000000000..7dd406a1d --- /dev/null +++ b/.changeset/align-service-registry-bootstrap.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/services": patch +"@moonshot-ai/server": patch +--- + +Use singleton service descriptors directly for server service bootstrap. diff --git a/.changeset/context-meter-hover-normalize.md b/.changeset/context-meter-hover-normalize.md new file mode 100644 index 000000000..50df8b3a7 --- /dev/null +++ b/.changeset/context-meter-hover-normalize.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-web": patch +--- + +Remove the hover padding and help cursor from the context meter so it behaves like plain text. diff --git a/.changeset/embed-native-web-assets.md b/.changeset/embed-native-web-assets.md new file mode 100644 index 000000000..1eb18b428 --- /dev/null +++ b/.changeset/embed-native-web-assets.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/server": patch +--- + +Embed server web assets in the native binary and make installed server lifecycle output show the web URL and service state. diff --git a/.changeset/esc-interrupt-abort-toast.md b/.changeset/esc-interrupt-abort-toast.md new file mode 100644 index 000000000..5a095775a --- /dev/null +++ b/.changeset/esc-interrupt-abort-toast.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-web": minor +--- + +Add Escape key support to interrupt running prompts and show a transient "manually stopped" toast. diff --git a/.changeset/expose-asyncapi-json.md b/.changeset/expose-asyncapi-json.md new file mode 100644 index 000000000..801eb2791 --- /dev/null +++ b/.changeset/expose-asyncapi-json.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/server": minor +"@moonshot-ai/kimi-code": minor +--- + +Expose OpenAPI and AsyncAPI JSON documents, with Swagger UI gated behind an explicit flag. diff --git a/.changeset/expose-model-provider-catalog.md b/.changeset/expose-model-provider-catalog.md new file mode 100644 index 000000000..6080e4647 --- /dev/null +++ b/.changeset/expose-model-provider-catalog.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": minor +"@moonshot-ai/kimi-code": minor +--- + +Expose configured models and providers through the local server API. diff --git a/.changeset/expose-session-children-server.md b/.changeset/expose-session-children-server.md new file mode 100644 index 000000000..60c2b5052 --- /dev/null +++ b/.changeset/expose-session-children-server.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": minor +"@moonshot-ai/kimi-code": minor +--- + +Expose persistent child sessions through the local server API. diff --git a/.changeset/expose-session-fork-server.md b/.changeset/expose-session-fork-server.md new file mode 100644 index 000000000..7e6a9f9b2 --- /dev/null +++ b/.changeset/expose-session-fork-server.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": minor +"@moonshot-ai/kimi-code": minor +--- + +Expose session forking through the local server API and reject forks while the source session has a running turn. diff --git a/.changeset/expose-session-undo-server.md b/.changeset/expose-session-undo-server.md new file mode 100644 index 000000000..a6268aa96 --- /dev/null +++ b/.changeset/expose-session-undo-server.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": minor +"@moonshot-ai/kimi-code": minor +--- + +Expose a session undo action through the local server API that returns refreshed messages and session status. diff --git a/.changeset/filepath-link-branch-filter.md b/.changeset/filepath-link-branch-filter.md new file mode 100644 index 000000000..e111c8f19 --- /dev/null +++ b/.changeset/filepath-link-branch-filter.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-web": patch +--- + +Ignore branch-like slash names without file extensions when detecting file path links. diff --git a/.changeset/fix-di-instantiation-types.md b/.changeset/fix-di-instantiation-types.md new file mode 100644 index 000000000..a1a6ebde2 --- /dev/null +++ b/.changeset/fix-di-instantiation-types.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix descriptor-based dependency graph typing for DI service construction. diff --git a/.changeset/fix-pino-pretty-runtime.md b/.changeset/fix-pino-pretty-runtime.md new file mode 100644 index 000000000..416296da5 --- /dev/null +++ b/.changeset/fix-pino-pretty-runtime.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/server": patch +--- + +Fix server startup from npm-installed CLI packages when pretty logging is enabled. diff --git a/.changeset/invert-protocol-core-dependency.md b/.changeset/invert-protocol-core-dependency.md new file mode 100644 index 000000000..f7d928ccd --- /dev/null +++ b/.changeset/invert-protocol-core-dependency.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/protocol": patch +"@moonshot-ai/kimi-code": patch +--- + +Move shared event and tool display contracts into the protocol package. diff --git a/.changeset/kimi-web-server.md b/.changeset/kimi-web-server.md new file mode 100644 index 000000000..7a20ae40c --- /dev/null +++ b/.changeset/kimi-web-server.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the server-hosted web UI command and background server startup flow. diff --git a/.changeset/logo-prevent-doubleclick-selection.md b/.changeset/logo-prevent-doubleclick-selection.md new file mode 100644 index 000000000..ed6f7fb60 --- /dev/null +++ b/.changeset/logo-prevent-doubleclick-selection.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-web": patch +--- + +Prevent text selection when double-clicking the Kimi Code logo in the sidebar. diff --git a/.changeset/mobile-ui-polish.md b/.changeset/mobile-ui-polish.md new file mode 100644 index 000000000..c97cde243 --- /dev/null +++ b/.changeset/mobile-ui-polish.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-web": patch +--- + +Polish mobile UI: remove underline border from tab bar and collapse composer toolbar controls into the model dropdown. diff --git a/.changeset/move-server-services.md b/.changeset/move-server-services.md new file mode 100644 index 000000000..5d11c69c5 --- /dev/null +++ b/.changeset/move-server-services.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/services": patch +"@moonshot-ai/server": patch +"@moonshot-ai/kimi-code": patch +--- + +Move server filesystem and workspace service implementations into the shared service package. diff --git a/.changeset/polite-prompts-steer.md b/.changeset/polite-prompts-steer.md new file mode 100644 index 000000000..c4f3a60ed --- /dev/null +++ b/.changeset/polite-prompts-steer.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": minor +"@moonshot-ai/kimi-code": minor +--- + +Add server prompt queue listing and TUI-style prompt steering. diff --git a/.changeset/quiet-sdk-boundary.md b/.changeset/quiet-sdk-boundary.md new file mode 100644 index 000000000..0e7c5320d --- /dev/null +++ b/.changeset/quiet-sdk-boundary.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/services": patch +"@moonshot-ai/protocol": patch +"@moonshot-ai/kimi-code": patch +--- + +Remove server service and protocol package dependencies on the node SDK. diff --git a/.changeset/rename-session-profile-endpoint.md b/.changeset/rename-session-profile-endpoint.md new file mode 100644 index 000000000..2751344e0 --- /dev/null +++ b/.changeset/rename-session-profile-endpoint.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/protocol": major +"@moonshot-ai/server": major +"@moonshot-ai/services": patch +"@moonshot-ai/server-e2e": patch +"@moonshot-ai/kimi-code": major +--- + +Replace the session meta endpoint with a profile endpoint and add read-only profile fetch. diff --git a/.changeset/run-existing-server-url.md b/.changeset/run-existing-server-url.md new file mode 100644 index 000000000..4bfe2ed19 --- /dev/null +++ b/.changeset/run-existing-server-url.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/server": patch +--- + +Open the active server URL when a foreground server run finds an existing local server, with foreground and background stop guidance. diff --git a/.changeset/server-e2e-html-report.md b/.changeset/server-e2e-html-report.md new file mode 100644 index 000000000..5a0794a28 --- /dev/null +++ b/.changeset/server-e2e-html-report.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/server-e2e": patch +--- + +Add a readable HTML trace report for server end-to-end scenarios. diff --git a/.changeset/server-e2e-recent-api-scenarios.md b/.changeset/server-e2e-recent-api-scenarios.md new file mode 100644 index 000000000..245817834 --- /dev/null +++ b/.changeset/server-e2e-recent-api-scenarios.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/server-e2e": patch +--- + +Cover recent server catalog, child-session, reverse-RPC recovery, and image-file prompt flows in server end-to-end scenarios. diff --git a/.changeset/server-image-prompts.md b/.changeset/server-image-prompts.md new file mode 100644 index 000000000..55fad3ecf --- /dev/null +++ b/.changeset/server-image-prompts.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/server": minor +"@moonshot-ai/services": minor +"@moonshot-ai/kimi-code": minor +--- + +Allow server prompts to include uploaded image files for model vision input. diff --git a/.changeset/server-managed-terminals.md b/.changeset/server-managed-terminals.md new file mode 100644 index 000000000..c2d8cb85d --- /dev/null +++ b/.changeset/server-managed-terminals.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/protocol": minor +"@moonshot-ai/server": minor +"@moonshot-ai/services": minor +--- + +Add server-managed PTY terminal APIs and WebSocket controls. diff --git a/.changeset/session-row-hover-layout.md b/.changeset/session-row-hover-layout.md new file mode 100644 index 000000000..7546af197 --- /dev/null +++ b/.changeset/session-row-hover-layout.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-web": patch +"@moonshot-ai/kimi-code": patch +--- + +Hide session timestamp on hover and move the kebab menu button to the rightmost position in the session list row. diff --git a/.changeset/sidebar-header-border-fix.md b/.changeset/sidebar-header-border-fix.md new file mode 100644 index 000000000..dbdc8bfaf --- /dev/null +++ b/.changeset/sidebar-header-border-fix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-web": patch +--- + +Remove the white background override on the sidebar header in Modern theme so the right border extends seamlessly from top to bottom. diff --git a/.changeset/throw-collected-disposal-errors.md b/.changeset/throw-collected-disposal-errors.md new file mode 100644 index 000000000..b4af9ff82 --- /dev/null +++ b/.changeset/throw-collected-disposal-errors.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": major +"@moonshot-ai/server": patch +"@moonshot-ai/kimi-code": major +--- + +Throw collected disposal errors instead of routing disposal failures through unexpected-error handlers. diff --git a/.changeset/web-file-preview.md b/.changeset/web-file-preview.md new file mode 100644 index 000000000..5a5cc22a2 --- /dev/null +++ b/.changeset/web-file-preview.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kimi-web": minor +"@moonshot-ai/protocol": minor +"@moonshot-ai/services": minor +"@moonshot-ai/server": minor +"@moonshot-ai/kimi-code": minor +--- + +Add clickable workspace file previews in the web UI with open, reveal, and download actions. diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 86de76975..fcce360b5 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -36,6 +36,9 @@ jobs: - name: Build package dependencies run: pnpm run build:packages + - name: Build Kimi web assets + run: pnpm --filter @moonshot-ai/kimi-web run build + - name: Generate Kimi Code built-in catalog shell: bash run: | diff --git a/.gitignore b/.gitignore index 691c675b7..378d8f49d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-web/ dist-native/ .tmp-api-extractor/ coverage/ @@ -13,3 +14,8 @@ coverage/ plugins/cdn/ superpowers .worktrees/ + +Dockerfile +!packages/daemon-e2e/Dockerfile +docker-compose.yml +.dockerignore diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index ff223543a..82905db9f 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -27,6 +27,7 @@ }, "files": [ "dist", + "dist-web", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" @@ -34,6 +35,8 @@ "type": "module", "imports": { "#/tui/theme": "./src/tui/theme/index.ts", + "#/cli/sub/server": "./src/cli/sub/server/index.ts", + "#/cli/sub/server/*": "./src/cli/sub/server/*.ts", "#/*": [ "./src/*.ts", "./src/*/index.ts" @@ -44,7 +47,7 @@ "provenance": true }, "scripts": { - "build": "tsdown", + "build": "tsdown && node scripts/copy-web-assets.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", "build:native:js": "node scripts/native/01-bundle.mjs", @@ -56,6 +59,8 @@ "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", + "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", "dev:prod": "node dist/main.mjs", @@ -73,6 +78,7 @@ "cli-highlight": "^2.1.11", "commander": "^13.1.0", "pathe": "^2.0.3", + "pino-pretty": "^13.0.0", "semver": "^7.7.4", "smol-toml": "^1.6.1", "zod": "^4.3.6" @@ -82,7 +88,9 @@ "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", + "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/server": "workspace:^", "@types/semver": "^7.7.0", "@types/yazl": "^2.4.6", "postject": "1.0.0-alpha.6", diff --git a/apps/kimi-code/scripts/copy-web-assets.mjs b/apps/kimi-code/scripts/copy-web-assets.mjs new file mode 100644 index 000000000..eb9470b84 --- /dev/null +++ b/apps/kimi-code/scripts/copy-web-assets.mjs @@ -0,0 +1,39 @@ +import { cp, rm, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const require = createRequire(import.meta.url); +const source = resolve(repoRoot, 'apps/kimi-web/dist'); +const target = resolve(appRoot, 'dist-web'); +const swaggerUiSource = resolve( + dirname(require.resolve('@fastify/swagger-ui/package.json', { + paths: [resolve(repoRoot, 'packages/server')], + })), + 'static', +); +const swaggerUiTarget = resolve(appRoot, 'dist/static'); + +async function assertBuiltWeb() { + try { + const info = await stat(resolve(source, 'index.html')); + if (!info.isFile()) { + throw new Error('index.html is not a file'); + } + } catch { + throw new Error( + `Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`, + ); + } +} + +await assertBuiltWeb(); +await rm(target, { recursive: true, force: true }); +await cp(source, target, { recursive: true }); +await rm(swaggerUiTarget, { recursive: true, force: true }); +await cp(swaggerUiSource, swaggerUiTarget, { recursive: true }); + +console.log(`Copied Kimi web assets to ${target}`); +console.log(`Copied Swagger UI assets to ${swaggerUiTarget}`); diff --git a/apps/kimi-code/scripts/dev-server-restart.mjs b/apps/kimi-code/scripts/dev-server-restart.mjs new file mode 100644 index 000000000..4657573a4 --- /dev/null +++ b/apps/kimi-code/scripts/dev-server-restart.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Press-Enter-to-restart wrapper for the local server. No file watcher. +// +// Spawns `tsx ./src/main.ts server run …extraArgs` once, then on each newline +// read from stdin SIGTERMs the child and respawns after it has cleanly exited. +// SIGTERM triggers the server's own `shutdown()` handler +// (apps/kimi-code/src/cli/sub/server/run.ts) which releases the port lock and +// closes WS conns before exit, so a fresh start can re-acquire 7878 without a +// stale-lock fight. +// +// CLI args after `--` (or any extras) are passed straight through, so: +// pnpm dev:server:restart -- --host 0.0.0.0 --port 7878 --log-level debug +// is equivalent to `pnpm dev:server` with that arg list, but with the restart +// loop on top. + +import { spawn } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = resolve(SCRIPT_DIR, '..'); + +const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'; + +const cliArgs = process.argv.slice(2); +if (cliArgs[0] === '--') cliArgs.shift(); + +const tsxArgs = [ + '--tsconfig', + './tsconfig.dev.json', + '--import', + '../../build/register-raw-text-loader.mjs', + './src/main.ts', + 'server', + 'run', + ...cliArgs, +]; + +let child = null; +let restarting = false; +let shuttingDown = false; +let killTimer = null; + +function start() { + console.error('[dev:server:restart] starting server…'); + child = spawn(tsxBin, tsxArgs, { + cwd: APP_ROOT, + env: process.env, + // Server does not read stdin; keep ours free for the Enter trigger. + stdio: ['ignore', 'inherit', 'inherit'], + }); + + child.on('error', (err) => { + console.error(`[dev:server:restart] spawn error: ${err.message}`); + }); + + child.on('exit', (code, signal) => { + if (killTimer !== null) { + clearTimeout(killTimer); + killTimer = null; + } + const prev = child; + child = null; + if (shuttingDown) { + process.exit(code ?? 0); + return; + } + if (restarting) { + restarting = false; + start(); + return; + } + // Server died on its own (port conflict, runtime error, etc.). Stay alive + // so the user can fix the issue and press Enter to retry. + const tag = signal !== null ? `signal=${signal}` : `code=${code}`; + console.error( + `[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`, + ); + void prev; // silence unused warning + }); +} + +function restart() { + if (shuttingDown) return; + if (child === null) { + // Previous run already exited; just spin up a new one. + start(); + return; + } + if (restarting) return; // debounce — multiple Enters during shutdown collapse + restarting = true; + console.error('[dev:server:restart] restarting…'); + child.kill('SIGTERM'); + // Safety net: if the child ignores SIGTERM, force-kill after 5s so the + // restart loop doesn't wedge. + killTimer = setTimeout(() => { + if (child !== null && child.exitCode === null && child.signalCode === null) { + console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL'); + child.kill('SIGKILL'); + } + }, 5000); +} + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + // Any newline (Enter on most terminals) triggers a restart. Empty Enter is + // the canonical signal; typing `r` works too. + if (chunk.includes('\n') || chunk.includes('\r')) { + restart(); + } +}); + +const onShutdownSignal = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + if (child !== null) { + child.kill(signal); + // Give the server a moment to flush logs / release the lock. + setTimeout(() => process.exit(0), 1000).unref(); + } else { + process.exit(0); + } +}; +process.on('SIGINT', () => onShutdownSignal('SIGINT')); +process.on('SIGTERM', () => onShutdownSignal('SIGTERM')); + +start(); diff --git a/apps/kimi-code/scripts/native/02-sea-blob.mjs b/apps/kimi-code/scripts/native/02-sea-blob.mjs index 8bac05a84..434a7861c 100644 --- a/apps/kimi-code/scripts/native/02-sea-blob.mjs +++ b/apps/kimi-code/scripts/native/02-sea-blob.mjs @@ -16,6 +16,7 @@ import { nativeSeaConfigPath, targetTriple, } from './paths.mjs'; +import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs'; async function ensureBundleExists() { try { @@ -31,13 +32,19 @@ async function writeSeaConfig(target) { appRoot, target, }); + const web = await collectWebAssets({ appRoot, target }); const manifestPath = resolve(nativeManifestDir(target), 'manifest.json'); + const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json'); await mkdir(dirname(manifestPath), { recursive: true }); + await mkdir(dirname(webManifestPath), { recursive: true }); await writeFile(manifestPath, manifestJson); + await writeFile(webManifestPath, web.manifestJson); const seaAssets = { [nativeAssetManifestKey(target)]: manifestPath, + [webAssetManifestKey(target)]: webManifestPath, ...assets, + ...web.assets, }; const config = { main: nativeJsBundlePath(), @@ -55,6 +62,9 @@ async function writeSeaConfig(target) { for (const line of nativeAssetSummary(manifest)) { console.log(`- ${line}`); } + console.log( + `Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`, + ); } export async function runSeaBlobStep() { diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index a0479f209..6920ac597 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -18,10 +18,13 @@ const optionalRuntimeRequires = new Set([ 'canvas', 'chokidar', 'cpu-features', + 'fast-json-stringify/lib/serializer', + 'fast-json-stringify/lib/validator', 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); const handledNativeRuntimeRequires = new Set(['koffi']); +const disabledProductionDynamicImports = new Set(['@fastify/swagger', '@fastify/swagger-ui']); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; @@ -62,6 +65,7 @@ for (const line of executableLines()) { errors.push(`relative dynamic import remains: ${specifier}`); continue; } + if (disabledProductionDynamicImports.has(specifier)) continue; if (!isAllowedSpecifier(specifier)) { errors.push(`external dynamic import remains: ${specifier}`); } diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs index 910738943..30d5e9da3 100644 --- a/apps/kimi-code/scripts/native/manifest.mjs +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -1,4 +1,5 @@ export const NATIVE_ASSET_MANIFEST_VERSION = 1; +export const WEB_ASSET_MANIFEST_VERSION = 1; export function buildManifestKey(target) { return `native/${target}/manifest.json`; @@ -11,3 +12,11 @@ export function isManifestVersionSupported(version) { export function buildAssetKey(target, packageRoot, relativePath) { return `native/${target}/${packageRoot}/${relativePath}`; } + +export function buildWebManifestKey(target) { + return `web/${target}/manifest.json`; +} + +export function buildWebAssetKey(target, relativePath) { + return `web/${target}/dist-web/${relativePath}`; +} diff --git a/apps/kimi-code/scripts/native/web-assets.mjs b/apps/kimi-code/scripts/native/web-assets.mjs new file mode 100644 index 000000000..8f8a893c5 --- /dev/null +++ b/apps/kimi-code/scripts/native/web-assets.mjs @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import { + WEB_ASSET_MANIFEST_VERSION, + buildWebAssetKey, + buildWebManifestKey, +} from './manifest.mjs'; + +export { WEB_ASSET_MANIFEST_VERSION }; + +const WEB_ASSETS_DIR = 'dist-web'; + +function toPosixPath(path) { + return path.split('\\').join('/'); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function listFiles(root) { + const files = []; + + async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (entry.isFile()) { + files.push(path); + } + } + } + + await walk(root); + return files; +} + +async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) { + const requiredPath = join(assetRoot, requiredFile); + try { + const info = await stat(requiredPath); + if (!info.isFile()) { + throw new Error(`${requiredFile} is not a file`); + } + } catch { + throw new Error(message); + } +} + +export function webAssetManifestKey(target) { + return buildWebManifestKey(target); +} + +export function webAssetKey(target, relativePath) { + return buildWebAssetKey(target, relativePath); +} + +async function collectAssetRoot({ + appRoot, + target, + root, + requiredFile, + missingMessage, + assetKey, +}) { + const assetRoot = resolve(appRoot, ...root.split('/')); + await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage }); + + const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b)); + const manifestFiles = []; + const assets = {}; + + for (const file of files) { + if (!existsSync(file)) continue; + const bytes = await readFile(file); + const relativePath = toPosixPath(relative(assetRoot, file)); + const key = assetKey(target, relativePath); + manifestFiles.push({ + assetKey: key, + relativePath, + sha256: sha256(bytes), + }); + assets[key] = file; + } + + const manifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target, + root, + files: manifestFiles, + }; + + return { + manifest, + manifestJson: `${JSON.stringify(manifest, null, 2)}\n`, + assets, + }; +} + +export async function collectWebAssets({ appRoot, target }) { + const buildCommand = + 'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build'; + return collectAssetRoot({ + appRoot, + target, + root: WEB_ASSETS_DIR, + requiredFile: 'index.html', + missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`, + assetKey: webAssetKey, + }); +} diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs index 9c64dd80d..8f0f0631a 100644 --- a/apps/kimi-code/scripts/smoke.mjs +++ b/apps/kimi-code/scripts/smoke.mjs @@ -7,6 +7,8 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const bundlePath = resolve(appRoot, 'dist', 'main.mjs'); +const swaggerUiLogoPath = resolve(appRoot, 'dist', 'static', 'logo.svg'); +const webIndexPath = resolve(appRoot, 'dist-web', 'index.html'); const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); const expectedVersion = packageJson.version; @@ -23,6 +25,16 @@ async function ensureBundleExists() { } } +async function ensureRuntimeAssetsExist() { + for (const filePath of [swaggerUiLogoPath, webIndexPath]) { + try { + await stat(filePath); + } catch { + fail(`Runtime asset not found at ${filePath}. Run \`pnpm build\` first.`); + } + } +} + async function runBundle(args) { try { const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], { @@ -45,6 +57,7 @@ function assertIncludes(output, expected, command) { } await ensureBundleExists(); +await ensureRuntimeAssetsExist(); const versionOutput = await runBundle(['--version']); assertIncludes(versionOutput, expectedVersion, '--version'); @@ -55,4 +68,7 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help'); const exportHelpOutput = await runBundle(['export', '--help']); assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); +const webHelpOutput = await runBundle(['web', '--help']); +assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help'); + console.log(`Bundle smoke passed: ${bundlePath}`); diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..9fa34c5c0 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; +import { registerServerCommand } from './sub/server'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; @@ -78,6 +79,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); + registerServerCommand(program); registerLoginCommand(program); registerDoctorCommand(program); registerMigrateCommand(program, onMigrate); diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts new file mode 100644 index 000000000..f61862beb --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/index.ts @@ -0,0 +1,31 @@ +/** + * `kimi server` parent command. Mounts: + * - `server run` (foreground) + * - `server install/uninstall/start/stop/restart/status` (OS service) + * + * The top-level `kimi web` alias is registered separately via + * `registerWebAliasCommand` so it stays at the program root. + */ + +import type { Command } from 'commander'; + +import { addLifecycleCommands } from './lifecycle'; +import { buildRunCommand } from './run'; +import { registerWebAliasCommand } from './web-alias'; + +export function registerServerCommand(program: Command): void { + const server = program + .command('server') + .description('Run, install, and manage the local Kimi server (REST + WebSocket + web UI).'); + + buildRunCommand( + server.command('run').description('Run the Kimi server in the foreground.'), + { defaultOpen: false }, + ); + + addLifecycleCommands(server); + + registerWebAliasCommand(program); +} + +export { registerWebAliasCommand }; diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts new file mode 100644 index 000000000..eb1349c99 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/lifecycle.ts @@ -0,0 +1,253 @@ +/** + * `kimi server install/uninstall/start/stop/restart/status`. + * + * Phase 2 lands the CLI shape; the lifecycle calls into the platform service + * manager from `@moonshot-ai/server`, which is filled in by Phase 3+. + * + * The Commander wiring here mirrors `addGatewayServiceCommands` from + * `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`. + */ + +import type { Command } from 'commander'; + +import { + ServiceUnavailableError, + ServiceUnsupportedError, + resolveServiceManager, + type InstallArgs, + type ServiceManager, + type ServiceStatus, +} from '@moonshot-ai/server'; + +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; + +import { + DEFAULT_LOG_LEVEL, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + parseLogLevel, + parsePort, + serverOrigin, + VALID_LOG_LEVELS, +} from './shared'; + +export interface InstallCliOptions { + port?: string; + logLevel?: string; + force?: boolean; + open?: boolean; + json?: boolean; +} + +export interface JsonCliOptions { + json?: boolean; +} + +export interface LifecycleCommandDeps { + resolveManager(): ServiceManager; + openUrl(url: string): void; + stdout: Pick; + stderr: Pick; +} + +const DEFAULT_DEPS: LifecycleCommandDeps = { + resolveManager: resolveServiceManager, + openUrl: defaultOpenUrl, + stdout: process.stdout, + stderr: process.stderr, +}; + +/** Mount install/uninstall/start/stop/restart/status under a parent command. */ +export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void { + parent + .command('install') + .description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).') + .option('--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT)) + .option( + '--log-level ', + `Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`, + DEFAULT_LOG_LEVEL, + ) + .option('--force', 'Reinstall and overwrite if already installed', false) + .option('--no-open', 'Do not open the web UI after install.', true) + .option('--json', 'Output JSON', false) + .action(async (opts: InstallCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const args: InstallArgs = { + host: DEFAULT_SERVER_HOST, + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel), + force: opts.force === true, + }; + const result = await mgr.install(args); + const status = await readStatus(mgr); + const enriched = withStatusDetails({ + ok: true, + action: 'install', + status: result.status, + plistPath: result.plistPath, + unitPath: result.unitPath, + taskName: result.taskName, + message: result.message, + }, status, args); + if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') { + deps.openUrl(enriched.url); + } + return enriched; + }); + }); + + parent + .command('uninstall') + .description('Uninstall the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.uninstall(); + return { ok: result.ok, action: 'uninstall', message: result.message }; + }); + }); + + parent + .command('start') + .description('Start the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.start(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status); + }); + }); + + parent + .command('stop') + .description('Stop the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.stop(); + return { ok: result.ok, action: 'stop', message: result.message }; + }); + }); + + parent + .command('restart') + .description('Restart the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.restart(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status); + }); + }); + + parent + .command('status') + .description('Show Kimi server service status and connectivity.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const status: ServiceStatus = await mgr.status(); + return withStatusDetails({ ok: true, action: 'status', ...status }, status); + }); + }); +} + +async function runLifecycle( + deps: LifecycleCommandDeps, + json: boolean, + body: (mgr: ServiceManager) => Promise>, +): Promise { + try { + const mgr = deps.resolveManager(); + const result = await body(mgr); + if (json) { + deps.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + deps.stdout.write(formatHuman(result)); + } catch (error) { + if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) { + const payload = { + ok: false, + action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported', + platform: error.platform, + message: error.message, + }; + if (json) { + deps.stdout.write(`${JSON.stringify(payload)}\n`); + } else { + deps.stderr.write(`${error.message}\n`); + } + process.exit(2); + return; + } + if (json) { + deps.stdout.write( + `${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`, + ); + } else { + deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + } + process.exit(1); + } +} + +function formatHuman(result: Record): string { + const rawAction = result['action']; + const action = typeof rawAction === 'string' ? rawAction : 'action'; + const rawMessage = result['message']; + const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : ''; + const lines = [`${action}${message}`]; + + const url = result['url']; + if (typeof url === 'string') lines.push(`URL: ${url}`); + + const running = result['running']; + if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`); + + const logPath = result['logPath']; + if (typeof logPath === 'string') lines.push(`Log: ${logPath}`); + + const notes = result['notes']; + if (Array.isArray(notes)) { + for (const note of notes) { + if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`); + } + } + + return `${lines.join('\n')}\n`; +} + +async function readStatus(mgr: ServiceManager): Promise { + try { + return await mgr.status(); + } catch { + return undefined; + } +} + +function withStatusDetails( + result: Record, + status: ServiceStatus | undefined, + fallback?: { host: string; port: number }, +): Record & { url?: string; running?: boolean } { + const host = status?.host ?? fallback?.host; + const port = status?.port ?? fallback?.port; + const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined; + return { + ...result, + url, + running: status?.running, + host, + port, + logPath: status?.logPath, + notes: status?.notes, + }; +} + +function formatServiceUrl(host: string, port: number): string { + return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port); +} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts new file mode 100644 index 000000000..f46bc7a6a --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -0,0 +1,288 @@ +/** + * `kimi server run` — runs the local server in the foreground. + * + * Background ("daemonized") operation is not handled here. Use + * `kimi server install` + `kimi server start` to register the server as an + * OS-managed service (launchd / systemd / schtasks) instead. + * + * `kimi web` is an alias of this command with `--open` defaulted to `true`, + * registered in `./web-alias.ts`. + */ + +import chalk from 'chalk'; +import type { Command } from 'commander'; +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; + +import { join } from 'node:path'; + +import { + ServerLockedError, + resolveServiceManager, + startServer, + type ServiceStatus, +} from '@moonshot-ai/server'; + +import { getNativeWebAssetsDir } from '#/native/web-assets'; +import { darkColors } from '#/tui/theme/colors'; +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; + +import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; +import { + DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + parseServerOptions, + serverOrigin, + VALID_LOG_LEVELS, + type ParsedServerOptions, + type ServerCliOptions, +} from './shared'; + +const WEB_ASSETS_DIR = 'dist-web'; +const READY_PANEL_WIDTH = 72; + +export interface RunCliOptions extends ServerCliOptions { + open?: boolean; +} + +export interface RunCommandDeps { + startServerForeground(options: ParsedServerOptions): Promise<{ origin: string }>; + getServiceStatus(): Promise; + openUrl(url: string): void; + stdout: Pick; + stderr: Pick; +} + +/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ +export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { + return cmd + .option( + '--port ', + `Bind port (default ${DEFAULT_SERVER_PORT})`, + String(DEFAULT_SERVER_PORT), + ) + .option( + '--log-level ', + `Enable foreground logs at level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, + ) + .option( + '--debug-endpoints', + 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', + false, + ) + .option( + '--swagger', + 'Mount the Swagger UI at /documentation. OpenAPI JSON remains available at /openapi.json.', + false, + ) + .option( + options.defaultOpen ? '--no-open' : '--open', + options.defaultOpen + ? 'Do not open the web UI in the default browser.' + : 'Open the web UI in the default browser once the server is healthy.', + options.defaultOpen, + ) + .action(async (opts: RunCliOptions) => { + try { + await handleRunCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleRunCommand( + opts: RunCliOptions, + deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS, +): Promise { + const parsed = parseServerOptions(opts); + let outcome: { origin: string }; + const startedAt = Date.now(); + try { + outcome = await deps.startServerForeground(parsed); + } catch (error) { + if (error instanceof ServerLockedError) { + const status = await deps.getServiceStatus(); + const alreadyRunning = describeAlreadyRunning(error.existing, status); + deps.stdout.write(formatAlreadyRunning(alreadyRunning)); + deps.openUrl(alreadyRunning.url); + return; + } + throw error; + } + const readyMs = Date.now() - startedAt; + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatForegroundReadyBanner(outcome.origin, readyMs) + : `Kimi server: ${outcome.origin}\n`, + ); + if (opts.open === true) { + deps.openUrl(outcome.origin); + } +} + +export async function startServerForeground( + options: ParsedServerOptions, +): Promise<{ origin: string }> { + const version = getVersion(); + const running = await startServer({ + host: options.host, + port: options.port, + logLevel: options.logLevel, + debugEndpoints: options.debugEndpoints, + swagger: options.swagger, + webAssetsDir: serverWebAssetsDir(), + coreProcessOptions: { + identity: createKimiCodeHostIdentity(version), + }, + }); + + const shutdown = async (signal: NodeJS.Signals): Promise => { + running.logger.info({ signal }, 'server shutting down'); + try { + await running.close(); + process.exit(0); + } catch (error) { + running.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'server shutdown error', + ); + process.exit(1); + } + }; + const handleSignal = (signal: NodeJS.Signals): void => { + void shutdown(signal); + }; + process.once('SIGINT', handleSignal); + process.once('SIGTERM', handleSignal); + + return { origin: serverOrigin(options.host, options.port) }; +} + +function serverWebAssetsDir(): string { + return resolveServerWebAssetsDir(); +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); +} + +interface AlreadyRunningDetails { + readonly mode: 'background' | 'foreground'; + readonly pid: number; + readonly url: string; + readonly stopCommand: string; +} + +function describeAlreadyRunning( + existing: { readonly pid: number; readonly port: number; readonly host?: string }, + status: ServiceStatus | undefined, +): AlreadyRunningDetails { + const mode = isBackgroundServer(existing, status) ? 'background' : 'foreground'; + const host = status?.host ?? existing.host ?? DEFAULT_SERVER_HOST; + return { + mode, + pid: existing.pid, + url: serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, status?.port ?? existing.port), + stopCommand: mode === 'background' ? 'kimi server stop' : formatForegroundStopCommand(existing.pid), + }; +} + +function isBackgroundServer( + existing: { readonly pid: number; readonly port: number }, + status: ServiceStatus | undefined, +): boolean { + if (status?.running !== true) return false; + if (status.pid !== undefined) return status.pid === existing.pid; + if (status.port !== undefined) return status.port === existing.port; + return status.installed; +} + +export function formatForegroundStopCommand( + pid: number, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === 'win32') return `taskkill /PID ${String(pid)} /T /F`; + return `kill -TERM ${String(pid)}`; +} + +function formatAlreadyRunning(details: AlreadyRunningDetails): string { + return [ + `Kimi server already running in ${details.mode} (pid ${String(details.pid)}).`, + `URL: ${details.url}`, + `Stop: ${details.stopCommand}`, + '', + ].join('\n'); +} + +function formatForegroundReadyBanner(origin: string, readyMs: number): string { + const primary = (text: string): string => chalk.hex(darkColors.primary)(text); + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const url = chalk.hex(darkColors.accent)(displayOrigin(origin)); + const width = READY_PANEL_WIDTH; + const innerWidth = width - 4; + const pad = ' '; + + const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; + const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); + const gap = ' '; + const textWidth = innerWidth - logoWidth - gap.length; + const headerLines = [ + primary(logo[0].padEnd(logoWidth)) + + gap + + truncateToWidth(title('Kimi server ready'), textWidth, '…'), + primary(logo[1].padEnd(logoWidth)) + + gap + + truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'), + ]; + const infoLines = [ + label('URL: ') + url, + label('Network: ') + muted('local only'), + label('Logs: ') + muted('off') + dim(' use --log-level info to enable'), + label('Stop: ') + muted('Ctrl+C'), + label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`), + label('Version: ') + muted(getVersion()), + ]; + const contentLines = [...headerLines, '', ...infoLines]; + + const lines = [ + '', + primary('╭' + '─'.repeat(width - 2) + '╮'), + primary('│') + ' '.repeat(width - 2) + primary('│'), + ]; + + for (const content of contentLines) { + const truncated = truncateToWidth(content, innerWidth, '…'); + const rightPad = Math.max(0, innerWidth - visibleWidth(truncated)); + lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); + } + + lines.push(primary('│') + ' '.repeat(width - 2) + primary('│')); + lines.push(primary('╰' + '─'.repeat(width - 2) + '╯')); + lines.push(''); + return lines.join('\n'); +} + +function displayOrigin(origin: string): string { + return origin.endsWith('/') ? origin : `${origin}/`; +} + +const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { + startServerForeground, + getServiceStatus: async () => { + try { + return await resolveServiceManager().status(); + } catch { + return undefined; + } + }, + openUrl: defaultOpenUrl, + stdout: process.stdout, + stderr: process.stderr, +}; diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts new file mode 100644 index 000000000..6127b98d7 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -0,0 +1,153 @@ +/** + * Shared helpers for `kimi server …` subcommands. + * + * Owns the default host/port, option parsers, and health/readiness probes that + * `run`, `web`, and `status` all use. + */ + +import type { ServerLogLevel } from '@moonshot-ai/server'; + +export const DEFAULT_SERVER_HOST = '127.0.0.1'; +export const DEFAULT_SERVER_PORT = 7878; +export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); + +export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; +export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; + +export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ + 'fatal', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'silent', +]; + +export interface ParsedServerOptions { + host: string; + port: number; + logLevel: ServerLogLevel; + debugEndpoints: boolean; + swagger: boolean; +} + +export interface ServerCliOptions { + host?: string; + port?: string; + logLevel?: string; + debugEndpoints?: boolean; + swagger?: boolean; +} + +export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { + return { + host: opts.host ?? DEFAULT_SERVER_HOST, + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), + debugEndpoints: opts.debugEndpoints === true, + swagger: opts.swagger === true, + }; +} + +export function parsePort(raw: string | undefined, label: string, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + throw new Error(`error: invalid ${label} value: ${raw}`); + } + return n; +} + +export function parseLogLevel(raw: string | undefined): ServerLogLevel { + if (raw === undefined) return DEFAULT_LOG_LEVEL; + if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { + return raw as ServerLogLevel; + } + throw new Error( + `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`, + ); +} + +export function serverOrigin(host: string, port: number): string { + return `http://${host}:${port}`; +} + +/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ +export function normalizeServerOrigin(value: string): string { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */ +export async function isServerHealthy(origin: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const response = await fetch(`${origin}/api/v1/healthz`, { + signal: controller.signal, + }); + if (!response.ok) return false; + const body = (await response.json()) as { code?: unknown }; + return body.code === 0; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */ +export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + if (await isServerHealthy(origin, 500)) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + } while (Date.now() < deadline); + return false; +} + +/** + * Probe `/` and confirm the bundled web UI is being served. + * + * A different build that runs on the same port serves its own bundle — opening + * a browser at that origin lands on stale code. Catching that here lets the + * caller surface a clear "stop the running server" message instead of silently + * handing the user the wrong UI. + */ +export async function ensureServerWebReady(origin: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + try { + const response = await fetch(`${origin}/`, { + headers: { accept: 'text/html' }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const body = await response.text(); + if (!body.includes('
& { + readonly version: number; + readonly root: string; +}; + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + relativePath.startsWith('/') || + relativePath.includes('\\') || + relativePath.split('/').includes('..') || + /^[A-Za-z]:/.test(relativePath) + ) { + throw new Error(`Invalid web asset relative path: ${relativePath}`); + } +} + +export function webAssetManifestKey(target: string = currentTarget()): string { + return buildWebManifestKey(target); +} + +export function getEmbeddedWebAssetManifest( + source: WebAssetSource | null = getSeaAssetSource(), + target = currentTarget(), +): WebAssetManifest | null { + if (source === null) return null; + const key = webAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest; + if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported web asset manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`); + } + if (manifest.root !== 'dist-web') { + throw new Error(`Unsupported web asset root: ${manifest.root}`); + } + return manifest as WebAssetManifest; +} + +export function getWebAssetCacheRoot( + manifest: WebAssetManifest, + options: WebAssetOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase({ + cacheBase: options.cacheBase, + env: options.env, + platform: options.platform, + homeDir: options.homeDir, + }), + 'web', + version, + sanitizeSegment(manifest.target), + manifestHash, + manifest.root, + ); +} + +export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getWebAssetCacheRoot(manifest, options); + for (const file of manifest.files) { + assertSafeRelativePath(file.relativePath); + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256); + } + return cacheRoot; +} diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 90fb53ecf..5ff2dd9a2 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -289,6 +289,8 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', + 'server', + 'web', 'login', 'doctor', 'migrate', diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts new file mode 100644 index 000000000..7820dbb7f --- /dev/null +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -0,0 +1,552 @@ +/** + * Tests for `kimi server run` and `kimi web` Commander wiring. + * + * These tests don't actually start the server — they verify the parsed shape + * (option flags, --open default) and that the `web` alias defers to the same + * underlying handler with `defaultOpen` flipped to true. + * + * Foreground startup behavior is exercised end-to-end in `server-e2e/`. + */ + +import { readFileSync } from 'node:fs'; + +import chalk, { Chalk } from 'chalk'; +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +import { registerServerCommand } from '#/cli/sub/server'; +import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; +import { darkColors } from '#/tui/theme/colors'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function makeProgram(): Command { + // `commander` exitOverride avoids killing the test runner when --help/error fires. + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + return program; +} + +describe('kimi server', () => { + it('declares pino-pretty as a CLI runtime dependency', () => { + const packageJson = JSON.parse( + readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8'), + ) as { dependencies?: Record }; + + expect(packageJson.dependencies).toHaveProperty('pino-pretty'); + }); + + it('registers `server` with all six lifecycle subcommands plus `run`', () => { + const program = makeProgram(); + const server = program.commands.find((c) => c.name() === 'server'); + expect(server).toBeDefined(); + const subs = server?.commands.map((c) => c.name()).toSorted(); + expect(subs).toEqual(['install', 'restart', 'run', 'start', 'status', 'stop', 'uninstall']); + }); + + it('`server run` exposes local-only foreground options', () => { + const program = makeProgram(); + const run = program.commands + .find((c) => c.name() === 'server') + ?.commands.find((c) => c.name() === 'run'); + expect(run).toBeDefined(); + const longs = run!.options.map((o) => o.long).filter(Boolean); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--swagger'); + // run defaults to NOT opening the browser → option is the positive --open + expect(longs).toContain('--open'); + }); + + it('`server install` exposes local-only service options', () => { + const program = makeProgram(); + const install = program.commands + .find((c) => c.name() === 'server') + ?.commands.find((c) => c.name() === 'install'); + expect(install).toBeDefined(); + const longs = install!.options.map((o) => o.long).filter(Boolean); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--force'); + expect(longs).toContain('--no-open'); + expect(longs).toContain('--json'); + }); + + it('the top-level `kimi web` alias is registered and defaults to opening the browser', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const longs = web!.options.map((o) => o.long).filter(Boolean); + // web defaults to opening → the option is the negative form --no-open + expect(longs).toContain('--no-open'); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + }); +}); + +describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported platforms', () => { + it('the dispatcher returns a friendly error manager for unknown platforms', async () => { + // darwin / linux / win32 have real backends (launchd / systemd / schtasks). + // The remaining platforms fall through to the stub that throws + // `ServiceUnsupportedError` — pin that contract so a future addition + // (freebsd, etc.) needs a deliberate decision instead of silently working. + const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/server'); + const mgr = resolveServiceManager('freebsd'); + await expect( + mgr.install({ host: '127.0.0.1', port: 7878, logLevel: 'info' }), + ).rejects.toBeInstanceOf(ServiceUnsupportedError); + await expect(mgr.status()).rejects.toBeInstanceOf(ServiceUnsupportedError); + }); +}); + +describe('`kimi server` lifecycle handles unavailable service managers', () => { + it('prints a friendly JSON error and exits 2', async () => { + const { ServiceUnavailableError } = await import('@moonshot-ai/server'); + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => { + throw new ServiceUnavailableError( + 'linux', + 'systemd --user is not available in this environment.', + ); + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ platform: 'linux', installed: false, running: false }), + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await expect( + program.parseAsync(['node', 'kimi', 'server', 'install', '--json']), + ).rejects.toThrow('process.exit(2)'); + + exit.mockRestore(); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + action: 'unavailable', + platform: 'linux', + message: expect.stringContaining('server run --port '), + }); + }); +}); + +describe('`kimi server` lifecycle output', () => { + it('install passes --force/--port, prints the URL, and opens it when running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + let installArgs: unknown; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async (args) => { + installArgs = args; + return { + status: 'replaced', + message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).', + plistPath: '/tmp/kimi.plist', + }; + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: true, + host: '127.0.0.1', + port: 9999, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await program.parseAsync([ + 'node', + 'kimi', + 'server', + 'install', + '--force', + '--port', + '9999', + ]); + + expect(stderr).toBe(''); + expect(installArgs).toMatchObject({ port: 9999, force: true }); + expect(stdout).toContain('URL: http://127.0.0.1:9999'); + expect(stdout).toContain('Status: running'); + expect(stdout).toContain('Log: /tmp/server.log'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); + }); + + it('start prints URL and diagnostics when launchd did not keep the service running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => ({ status: 'installed', message: 'unused' }), + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: false, + host: '127.0.0.1', + port: 7878, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'], + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }); + + await program.parseAsync(['node', 'kimi', 'server', 'start']); + + expect(stdout).toContain('URL: http://127.0.0.1:7878'); + expect(stdout).toContain('Status: not running'); + expect(stdout).toContain('launchd state: spawn scheduled'); + expect(stdout).toContain('last exit code: 78 EX_CONFIG'); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + +describe('`kimi server run` already-running handling', () => { + it('defaults foreground logs off and passes --swagger through to startup options', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '7878', swagger: true }, + { + startServerForeground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:7878' }; + }, + getServiceStatus: async () => undefined, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'silent', swagger: true }); + }); + + it('enables foreground logs only when --log-level is provided', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '7878', logLevel: 'debug' }, + { + startServerForeground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:7878' }; + }, + getServiceStatus: async () => undefined, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'debug' }); + }); + + it('prints a TUI-style welcome panel when foreground logs are off', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { port: '7878' }, + { + startServerForeground: async () => ({ origin: 'http://127.0.0.1:7878' }), + getServiceStatus: async () => undefined, + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('╭'); + expect(plain).toContain('╰'); + expect(plain).toContain('▐█▛█▛█▌'); + expect(plain).toContain('▐█████▌'); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('URL:'); + expect(plain).toContain('http://127.0.0.1:7878/'); + expect(plain).toContain('Network:'); + expect(plain).toContain('local only'); + expect(plain).toContain('Logs:'); + expect(plain).toContain('off'); + expect(plain).toContain('Stop:'); + expect(plain).toContain('Ctrl+C'); + expect(plain).not.toContain('➜'); + expect(plain).not.toContain('Kimi server:'); + }); + + it('uses the TUI dark palette for the foreground ready banner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleRunCommand( + { port: '7878' }, + { + startServerForeground: async () => ({ origin: 'http://127.0.0.1:7878' }), + getServiceStatus: async () => undefined, + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); + expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready')); + expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:7878/')); + expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: ')); + expect(stdout).toContain(color.hex(darkColors.textMuted)('local only')); + }); + + it('reports a background service conflict, suggests server stop, and opens the existing URL', async () => { + const { ServerLockedError } = await import('@moonshot-ai/server'); + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '7878' }, + { + startServerForeground: async () => { + throw new ServerLockedError('locked', { + pid: 1234, + started_at: '2026-06-11T00:00:00.000Z', + host: '127.0.0.1', + port: 9999, + }); + }, + getServiceStatus: async () => ({ + platform: 'darwin', + installed: true, + running: true, + pid: 1234, + host: '127.0.0.1', + port: 9999, + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(stdout).toContain('already running in background'); + expect(stdout).toContain('URL: http://127.0.0.1:9999'); + expect(stdout).toContain('Stop: kimi server stop'); + expect(stdout).not.toContain('pkill'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); + }); + + it('reports a foreground process conflict, suggests a pid-based stop command, and opens the existing URL', async () => { + const { ServerLockedError } = await import('@moonshot-ai/server'); + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '7878' }, + { + startServerForeground: async () => { + throw new ServerLockedError('locked', { + pid: 5678, + started_at: '2026-06-11T00:00:00.000Z', + port: 10001, + }); + }, + getServiceStatus: async () => ({ + platform: 'darwin', + installed: false, + running: false, + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(stdout).toContain('already running in foreground'); + expect(stdout).toContain('URL: http://127.0.0.1:10001'); + expect(stdout).toContain('Stop: kill -TERM 5678'); + expect(stdout).not.toContain('kimi server stop'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:10001'); + }); + + it('formats foreground stop commands by platform and pid', async () => { + const { formatForegroundStopCommand } = await import('#/cli/sub/server/run'); + + expect(formatForegroundStopCommand(1234, 'darwin')).toBe('kill -TERM 1234'); + expect(formatForegroundStopCommand(1234, 'linux')).toBe('kill -TERM 1234'); + expect(formatForegroundStopCommand(1234, 'win32')).toBe('taskkill /PID 1234 /T /F'); + }); +}); + +describe('`kimi server` does not register a legacy `daemon` command', () => { + it('hard-deletes the old name', () => { + const program = makeProgram(); + const daemon = program.commands.find((c) => c.name() === 'daemon'); + expect(daemon).toBeUndefined(); + }); +}); + +describe('shared parsers stay strict', () => { + it('rejects out-of-range --port', async () => { + const { parsePort } = await import('#/cli/sub/server/shared'); + expect(() => parsePort('99999', '--port', 7878)).toThrow(/invalid --port/); + expect(() => parsePort('-1', '--port', 7878)).toThrow(/invalid --port/); + expect(parsePort(undefined, '--port', 7878)).toBe(7878); + expect(parsePort('8080', '--port', 7878)).toBe(8080); + }); + + it('rejects unknown --log-level values', async () => { + const { parseLogLevel } = await import('#/cli/sub/server/shared'); + expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); + expect(parseLogLevel(undefined)).toBe('info'); + expect(parseLogLevel('debug')).toBe('debug'); + }); +}); + +describe('server web asset directory resolution', () => { + it('uses extracted SEA web assets when available', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web'); + }); + + it('falls back to package dist-web outside SEA mode', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); + }); +}); + +// Silence vi import for cases where the file is built before tests reference vi. +void vi; diff --git a/apps/kimi-code/test/native/web-assets.test.ts b/apps/kimi-code/test/native/web-assets.test.ts new file mode 100644 index 000000000..abe3801fe --- /dev/null +++ b/apps/kimi-code/test/native/web-assets.test.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + getNativeWebAssetsDir, + getWebAssetCacheRoot, + WEB_ASSET_MANIFEST_VERSION, + type WebAssetManifest, + type WebAssetSource, +} from '#/native/web-assets'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fakeWebAssets(files: Record): { + manifest: WebAssetManifest; + source: WebAssetSource; +} { + const manifest: WebAssetManifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: Object.entries(files).map(([relativePath, content]) => ({ + assetKey: `web/test-target/dist-web/${relativePath}`, + relativePath, + sha256: sha256(content), + })), + }; + const assets = new Map([ + ['web/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))], + ...Object.entries(files).map(([relativePath, content]) => [ + `web/test-target/dist-web/${relativePath}`, + Buffer.from(content), + ] as const), + ]); + return { + manifest, + source: { + getAssetKeys: () => [...assets.keys()], + getRawAsset: (assetKey) => { + const asset = assets.get(assetKey); + if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`); + return asset; + }, + }, + }; +} + +describe('web assets', () => { + it('extracts embedded web assets into a dist-web cache directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-runtime-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '
\n', + 'assets/app.js': 'console.log("ok");\n', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(webDir).toBe(getWebAssetCacheRoot(manifest, { cacheBase: dir, version: 'test' })); + expect(readFileSync(join(webDir ?? '', 'index.html'), 'utf-8')).toBe('
\n'); + expect(readFileSync(join(webDir ?? '', 'assets', 'app.js'), 'utf-8')).toBe( + 'console.log("ok");\n', + ); + expect(existsSync(join(dir, 'web', 'test', 'test-target'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs corrupted extracted files on the next lookup', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-repair-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + writeFileSync(join(webDir ?? '', 'index.html'), 'broken'); + + const repairedDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(repairedDir).toBe(webDir); + expect(readFileSync(join(repairedDir ?? '', 'index.html'), 'utf-8')).toBe(''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null when no SEA web asset source is available', () => { + expect(getNativeWebAssetsDir({ source: null })).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/web-assets.test.ts b/apps/kimi-code/test/scripts/native/web-assets.test.ts new file mode 100644 index 000000000..2b5bfa920 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/web-assets.test.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + collectWebAssets, + webAssetManifestKey, + WEB_ASSET_MANIFEST_VERSION, +} from '../../../scripts/native/web-assets.mjs'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +describe('collectWebAssets', () => { + it('collects dist-web files into deterministic SEA asset keys', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-build-')); + try { + mkdirSync(join(appRoot, 'dist-web', 'assets'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), '
\n'); + writeFileSync(join(appRoot, 'dist-web', 'assets', 'app.js'), 'console.log("ok");\n'); + + const { manifest, manifestJson, assets } = await collectWebAssets({ + appRoot, + target: 'test-target', + }); + + expect(webAssetManifestKey('test-target')).toBe('web/test-target/manifest.json'); + expect(manifest).toEqual({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: [ + { + assetKey: 'web/test-target/dist-web/assets/app.js', + relativePath: 'assets/app.js', + sha256: sha256('console.log("ok");\n'), + }, + { + assetKey: 'web/test-target/dist-web/index.html', + relativePath: 'index.html', + sha256: sha256('
\n'), + }, + ], + }); + expect(JSON.parse(manifestJson) as unknown).toEqual(manifest); + expect(assets).toEqual({ + 'web/test-target/dist-web/assets/app.js': join(appRoot, 'dist-web', 'assets', 'app.js'), + 'web/test-target/dist-web/index.html': join(appRoot, 'dist-web', 'index.html'), + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when dist-web has not been built', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-missing-')); + try { + await expect(collectWebAssets({ appRoot, target: 'test-target' })).rejects.toThrow( + /Kimi web build output was not found/, + ); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('keeps manifest JSON parseable and stable', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-json-')); + try { + mkdirSync(join(appRoot, 'dist-web'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), ''); + + const { manifestJson } = await collectWebAssets({ appRoot, target: 'test-target' }); + + expect(readFileSync(join(appRoot, 'dist-web', 'index.html'), 'utf-8')).toBe(''); + expect(manifestJson.endsWith('\n')).toBe(true); + expect(JSON.parse(manifestJson)).toMatchObject({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/tsconfig.dev.json b/apps/kimi-code/tsconfig.dev.json new file mode 100644 index 000000000..9e4df279a --- /dev/null +++ b/apps/kimi-code/tsconfig.dev.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": [ + "src", + "test", + "../../packages/*/src/**/*.ts", + "../../packages/*/src/**/*.tsx", + "../../packages/*/test/**/*.ts", + "../../packages/agent-core/src/prompt-modules.d.ts" + ] +} diff --git a/apps/kimi-code/tsconfig.json b/apps/kimi-code/tsconfig.json index ea6828176..10388dd08 100644 --- a/apps/kimi-code/tsconfig.json +++ b/apps/kimi-code/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowJs": true, + "experimentalDecorators": true, "paths": { "@/*": ["./src/*"] } diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index bf4e16fe9..ea0a939fb 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -17,10 +17,15 @@ const builtins = new Set([ ...builtinModules.map((name) => `node:${name}`), ]); const optionalNativeDependencies = new Set(['cpu-features']); +const nativeExternalDependencies = new Set([ + ...optionalNativeDependencies, + '@fastify/swagger', + '@fastify/swagger-ui', +]); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; - if (optionalNativeDependencies.has(id)) return false; + if (nativeExternalDependencies.has(id)) return false; return true; } @@ -53,7 +58,7 @@ export default defineConfig({ }, deps: { alwaysBundle: shouldAlwaysBundle, - neverBundle: [...optionalNativeDependencies], + neverBundle: [...nativeExternalDependencies], onlyBundle: false, }, outputOptions: { diff --git a/apps/kimi-web/README.md b/apps/kimi-web/README.md new file mode 100644 index 000000000..971052944 --- /dev/null +++ b/apps/kimi-web/README.md @@ -0,0 +1,118 @@ +# Kimi Web + +A browser client for Kimi Code — a peer to the TUI (`apps/kimi-code`) that talks +to a local **server** over REST + WebSocket. Vue 3 + Vite + TypeScript. + +--- + +## Quick start + +```bash +# 1) Against a REAL server (the server must be running and reachable) +WEB_PORT=5197 KIMI_SERVER_URL=http://192.168.97.91:7878 pnpm -C apps/kimi-web run dev +# …or from the repo root: pnpm dev:web (uses the defaults below) + +# 2) Offline / no server — a stub that fakes the server API + event stream +pnpm -C apps/kimi-web run dev:stub # then run dev in another shell + +# checks +pnpm -C apps/kimi-web run typecheck # vue-tsc --noEmit +pnpm -C apps/kimi-web run test # vitest +pnpm -C apps/kimi-web run build # vite build +``` + +### How it connects to the server + +The browser cannot reach the server cross-origin (no CORS), so Vite **same-origin +proxies** `/api/v1` (HTTP + WS) to the server (`vite.config.ts`): + +| env var | default | meaning | +| ----------------- | ------------------------ | ---------------------------------------- | +| `WEB_PORT` | `5175` | port the dev server listens on | +| `KIMI_SERVER_URL` | `http://127.0.0.1:7878` | where `/api/v1` (and `/api/v1/ws`) is forwarded | + +> Behind a corporate HTTP proxy, also set `NO_PROXY=` (e.g. +> `NO_PROXY=192.168.97.91`) so the proxy forward reaches the server directly. + +--- + +## Architecture + +A strict one-direction data flow; components never touch the network or the +reducer — they consume computed view props and call actions. + +``` +server (REST + WS) + └─ src/api/daemon/client.ts REST adapter (envelope → AppX types) + └─ src/api/daemon/ws.ts WS frames → classify → projector/reducer + └─ agentEventProjector.ts RAW agent-core events → AppEvent[] + └─ eventReducer.ts AppEvent[] → state + └─ src/composables/useKimiWebClient.ts the ONLY place that imports api + state; + exposes computed view props + actions + └─ src/components/*.vue render props, emit intents (no api access) +``` + +> The directory name `src/api/daemon/` is historical and kept to minimise +> diff churn; conceptually it is the **server** adapter. + +- **Adapter** (`src/api/`): wire types are snake_case; `AppX` types are camelCase. + `config.ts` builds `/api/v1` URLs. +- **Event projector** (`agentEventProjector.ts`): the server streams **raw + agent-core events** (no `event.` prefix). `classifyFrame` routes raw vs + protocol (`event.*`) frames; the projector converts them to `AppEvent`s. +- **i18n** (`src/i18n/`): vue-i18n, en/zh, per-namespace flat camelCase keys. + Detect order: `localStorage('kimi-locale')` → `navigator.language` → `en`. +- **Tests**: Vitest + @vue/test-utils + jsdom, colocated under `__tests__/`. + +--- + +## Server contract — non-obvious notes + +The server's wire protocol has a few things that will bite you if forgotten: + +- **Envelope:** every response is `{ code, msg, data, request_id }` and the HTTP + status is **always 200** — check `code` (0 = ok), not the status. +- **Prompts require five fields.** `POST /sessions/{id}/prompts` must carry + `{ content, model, thinking, permission_mode, plan_mode }`. The web fills these + from settings (model ← session/`default_model`, thinking/permission/plan ← the + StatusLine controls). Sending only `{ content }` → `40001 model …`. +- **Creating a session needs a *registered* workspace.** `workspace_id` must be a + `wd__` id that exists in the server's registry. Sessions get one + auto-assigned by cwd, but it isn't *registered* until you `POST /workspaces + { root }` (idempotent). The web registers on demand before `createSession` + (otherwise: `workspace not found: wd_…`). +- **Persisted sessions are directly promptable** — selecting an old session and + sending a message just works; there is **no `:activate` step**. +- **Workspaces** = real folders. `GET/POST/PATCH/DELETE /workspaces`, + `GET /fs:browse?path=`, `GET /fs:home` back the rail + folder picker. + +--- + +## What's still missing / blocked on the server + +See **`docs/main-flow-gaps.md`** (the main-flow gap audit) and +**`docs/backend-workspace-session-asks.md`** (the endpoint asks for the backend). + +Server endpoints that are **not live yet** (probed; the web degrades gracefully): + +- `/sessions/{id}:compact`, `:fork`, `:steer`, `/undo` → `400` (no such action) +- line-by-line `diff` → `404` +- `GET /sessions/{id}/status` → `404` (the `/status` panel is rendered from + client state instead) +- `/goal`, `/btw`, `/mcp`, `/init`, `/reload`, `/settings`, `/plugins` → absent + +Everything client-side (workspace rail, sessions, chat/stream, approvals, +tools/diff/files, model/provider/login, thinking/plan/permission controls, +`/status`, queue edit, syntax highlighting, i18n) is implemented. + +--- + +## Design docs + +Living under `docs/` (design rationale + plans): + +- `docs/workspace-session-design.html` — workspace ⇄ session model + flows +- `docs/dual-sidebar-exploration.html` — sidebar layout options (Variant B shipped) +- `docs/kimi-web-final-form.html` — target-state UI mockup +- `docs/main-flow-gaps.md` — feature gap audit (what to build next) +- `docs/backend-workspace-session-asks.md` — endpoints the server still needs diff --git a/apps/kimi-web/dev/stub-daemon.mjs b/apps/kimi-web/dev/stub-daemon.mjs new file mode 100644 index 000000000..1651939e4 --- /dev/null +++ b/apps/kimi-web/dev/stub-daemon.mjs @@ -0,0 +1,1872 @@ +// Local stub daemon for Kimi Web development. +// +// This is NOT the real backend. It is a throwaway dev server that speaks the +// daemon REST + WS wire protocol (envelope, snake_case, event frames) closely +// enough for the Web UI to be fully clickable before the real daemon exists. +// When the real daemon ships, point VITE_KIMI_SERVER_HTTP_URL at it instead and +// stop running this. +// +// node dev/stub-daemon.mjs # listens on 127.0.0.1:7878 +// PORT=9000 node dev/stub-daemon.mjs +// +import http from 'node:http'; +import { WebSocketServer } from 'ws'; + +const PORT = Number(process.env.PORT) || 7878; +const STARTED_AT = new Date().toISOString(); + +const now = () => new Date().toISOString(); +const expires60 = () => new Date(Date.now() + 60_000).toISOString(); + +// Simple ULID-ish: time-prefix + random. Good enough for a stub. +function ulid(prefix = '') { + const t = Date.now().toString(36).padStart(10, '0'); + const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0'); + return `${prefix}${t}${r}`; +} + +const ok = (data) => + JSON.stringify({ code: 0, msg: 'success', data, request_id: ulid('req_') }); +const fail = (code, msg, data = null) => + JSON.stringify({ code, msg, data, request_id: ulid('req_') }); + +// ---- PRESUMED: in-memory models + providers ---- +// PRESUMED — not in current daemon docs; endpoints isolated here, swap when backend defines them. + +const seedProviders = [ + { + id: 'prov_moonshot', + type: 'moonshot', + base_url: undefined, + default_model: 'moonshot-v1-128k', + has_api_key: true, + status: 'connected', + models: ['moonshot-v1-128k', 'moonshot-v1-32k'], + }, + { + id: 'prov_anthropic', + type: 'anthropic', + base_url: undefined, + default_model: undefined, + has_api_key: false, + status: 'unconfigured', + models: [], + }, + { + id: 'prov_openai', + type: 'openai', + base_url: undefined, + default_model: undefined, + has_api_key: false, + status: 'unconfigured', + models: [], + }, +]; + +const seedModels = [ + { provider: 'prov_moonshot', model: 'moonshot-v1-128k', display_name: 'Moonshot 128K', max_context_size: 131072, capabilities: [] }, + { provider: 'prov_moonshot', model: 'moonshot-v1-32k', display_name: 'Moonshot 32K', max_context_size: 32768, capabilities: [] }, + { provider: 'prov_moonshot', model: 'moonshot-v1-8k', display_name: 'Moonshot 8K', max_context_size: 8192, capabilities: [] }, + { provider: 'prov_anthropic', model: 'claude-sonnet-4-6', display_name: 'Claude Sonnet 4.6', max_context_size: 200000, capabilities: ['thinking'] }, + { provider: 'prov_anthropic', model: 'claude-opus-4-5', display_name: 'Claude Opus 4.5', max_context_size: 200000, capabilities: ['thinking'] }, + { provider: 'prov_openai', model: 'gpt-4o', display_name: 'GPT-4o', max_context_size: 128000, capabilities: [] }, +]; + +// Mutable arrays so POST/DELETE update them live +const providers = [...seedProviders]; +const models = [...seedModels]; + +// ---- Real OAuth singleton state ---- +let loggedIn = false; +let currentFlow = null; // { flow_id, provider, status, user_code, expires_in, interval, ... } + +// ---- in-memory state ---- + +function mkUsage(ctx = 38000, turns = 2) { + return { + input_tokens: 1200 + ctx * 2, + output_tokens: 600, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: +(ctx * 0.000002).toFixed(4), + context_tokens: ctx, + context_limit: 200000, + turn_count: turns, + }; +} + +function mkSession(id, title, status = 'idle', ctx = 38000, turns = 2) { + return { + id, + title, + created_at: now(), + updated_at: now(), + status, + metadata: { cwd: '/Users/moonshot/code/kimi-code-web' }, + agent_config: { model: 'moonshot-v1-128k', tools: ['read', 'bash', 'edit', 'write'] }, + usage: mkUsage(ctx, turns), + permission_rules: [], + message_count: 0, + last_seq: 0, + }; +} + +// ---- seed sessions ---- + +const ses1 = mkSession('ses_1', '重构 API client 超时配置', 'idle', 52000, 4); +const ses2 = mkSession('ses_2', '修复 TUI 渲染抖动', 'idle', 29000, 2); +const ses3 = mkSession('ses_3', '登录态错误归一化', 'idle', 18000, 1); +const ses4 = mkSession('ses_4', '新功能:文件搜索高亮', 'idle', 8000, 0); + +const sessions = [ses1, ses2, ses3, ses4]; + +// ---- seed workspaces + folder browser (demo) ---- +// A wd__ id, matching the real daemon's workspace id shape. +function wdId(root) { + const slug = (root.split('/').filter(Boolean).pop() || 'root') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'root'; + let h = 0; + for (let i = 0; i < root.length; i++) h = (h * 31 + root.charCodeAt(i)) >>> 0; + const hash = (h.toString(36) + '000000000000').slice(0, 12); + return `wd_${slug}_${hash}`; +} + +function mkWorkspace(root, name) { + return { + id: wdId(root), + root, + name: name || root.split('/').filter(Boolean).pop() || root, + is_git_repo: true, + branch: 'main', + created_at: now(), + last_opened_at: now(), + session_count: sessions.filter((s) => s.metadata?.cwd === root).length, + }; +} + +// Derive one workspace from the seeded session cwd, plus a couple of demo ones. +const workspaces = [ + mkWorkspace('/Users/moonshot/code/kimi-code-web', 'kimi-code-web'), + mkWorkspace('/Users/moonshot/code/kimi-cli', 'kimi-cli'), + mkWorkspace('/Users/moonshot/code/paseo', 'paseo'), +]; + +const FS_HOME = '/Users/moonshot'; +const FS_RECENT = [ + '/Users/moonshot/code/kimi-code-web', + '/Users/moonshot/code/kimi-cli', +]; + +// A tiny in-memory folder tree for the demo folder browser. Maps an absolute +// dir → its immediate subdirs (name + whether it's a git repo + branch). +const FS_TREE = { + '/': [{ name: 'Users', git: false }], + '/Users': [{ name: 'moonshot', git: false }], + '/Users/moonshot': [ + { name: 'code', git: false }, + { name: 'Documents', git: false }, + { name: 'Downloads', git: false }, + ], + '/Users/moonshot/code': [ + { name: 'kimi-code-web', git: true, branch: 'main' }, + { name: 'kimi-cli', git: true, branch: 'dev' }, + { name: 'paseo', git: true, branch: 'main' }, + { name: 'scratch', git: false }, + ], + '/Users/moonshot/code/kimi-code-web': [ + { name: 'apps', git: false }, + { name: 'packages', git: false }, + { name: 'docs', git: false }, + ], + '/Users/moonshot/code/kimi-cli': [{ name: 'src', git: false }], + '/Users/moonshot/code/paseo': [{ name: 'src', git: false }], +}; + +function browseDir(dirPath) { + const kids = FS_TREE[dirPath] || []; + const parent = dirPath === '/' ? null : (dirPath.split('/').slice(0, -1).join('/') || '/'); + return { + path: dirPath, + parent, + entries: kids.map((k) => ({ + name: k.name, + path: dirPath === '/' ? `/${k.name}` : `${dirPath}/${k.name}`, + is_dir: true, + is_git_repo: !!k.git, + ...(k.branch ? { branch: k.branch } : {}), + })), + }; +} + +// ---- seed messages ---- + +const t = (text) => ({ type: 'text', text }); +const thinking = (text) => ({ + type: 'thinking', + thinking: text, + signature: 'sig_stub_' + Math.random().toString(36).slice(2, 8), +}); +const toolUse = (id, name, input) => ({ type: 'tool_use', tool_call_id: id, tool_name: name, input }); +const toolResult = (id, output, is_error = false) => ({ + type: 'tool_result', + tool_call_id: id, + output, + is_error, +}); + +function mkMsg(id, session_id, role, content, prompt_id) { + return { id, session_id, role, content, created_at: now(), prompt_id }; +} + +const messages = { + ses_1: [ + mkMsg('msg_s1_1', 'ses_1', 'user', [t('把 api client 的超时改成可配置,然后跑测试')], 'pr_s1_1'), + mkMsg('msg_s1_2', 'ses_1', 'assistant', [ + thinking( + '用户想让我修改 API client,把硬编码的超时时间改成可通过参数配置的方式。\n' + + '我需要先读取现有代码,了解超时目前是如何硬编码的,然后:\n' + + '1. 修改 createClient 函数签名,加入 timeoutMs 可选参数\n' + + '2. 更新相关测试\n让我先读一下文件。' + ), + t('好的,我先看一下 `api/client.ts` 的现有实现:'), + toolUse('tc_s1_a', 'read', { path: 'packages/api/src/client.ts' }), + ], 'pr_s1_1'), + mkMsg('msg_s1_3', 'ses_1', 'assistant', [ + toolResult('tc_s1_a', + 'export function createClient(baseUrl: string) {\n' + + ' const timeout = 30_000; // hardcoded\n' + + ' return { baseUrl, timeout };\n' + + '}\n', false), + ], 'pr_s1_1'), + mkMsg('msg_s1_4', 'ses_1', 'assistant', [ + t('找到了,超时是硬编码的 `30_000`。现在修改成可配置:'), + toolUse('tc_s1_b', 'edit', { + path: 'packages/api/src/client.ts', + old_string: 'export function createClient(baseUrl: string) {\n const timeout = 30_000; // hardcoded', + new_string: 'export function createClient(baseUrl: string, timeoutMs = 30_000) {\n const timeout = timeoutMs;', + }), + ], 'pr_s1_1'), + mkMsg('msg_s1_5', 'ses_1', 'assistant', [ + toolResult('tc_s1_b', 'File edited successfully.', false), + t('修改完成,现在跑测试确认没有回归:'), + toolUse('tc_s1_c', 'bash', { command: 'pnpm --filter @kimi-code/api test --run' }), + ], 'pr_s1_1'), + mkMsg('msg_s1_6', 'ses_1', 'assistant', [ + toolResult('tc_s1_c', + 'PASS packages/api/src/__tests__/client.test.ts\n' + + 'Test Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n', false), + ], 'pr_s1_1'), + mkMsg('msg_s1_7', 'ses_1', 'assistant', [ + t( + '## 完成\n\n' + + '**变更摘要**\n\n' + + '- 给 `createClient` 加了 `timeoutMs` 可选参数,默认保持 `30_000` 不变\n' + + '- 现有调用方无需修改(向后兼容)\n\n' + + '所有 3 个测试通过,没有回归。' + ), + ], 'pr_s1_1'), + ], + + ses_2: [ + mkMsg('msg_s2_1', 'ses_2', 'user', [t('TUI 偶尔抖动,看下渲染循环是否有问题')], 'pr_s2_1'), + mkMsg('msg_s2_2', 'ses_2', 'assistant', [ + thinking( + '用户报告 TUI 渲染抖动。可能原因:\n' + + '1. 渲染节流不够导致频繁重绘\n' + + '2. 状态更新与渲染循环冲突\n' + + '先读渲染主循环。' + ), + t('先看一下 TUI 渲染循环的实现:'), + toolUse('tc_s2_a', 'read', { path: 'apps/kimi-tui/src/renderer/loop.ts' }), + ], 'pr_s2_1'), + mkMsg('msg_s2_3', 'ses_2', 'assistant', [ + toolResult('tc_s2_a', + 'export class RenderLoop {\n' + + ' private dirty = false;\n' + + ' schedule() { this.dirty = true; this.flush(); }\n' + + ' flush() { if (!this.dirty) return; this.dirty = false; this.render(); }\n' + + '}\n', false), + ], 'pr_s2_1'), + mkMsg('msg_s2_4', 'ses_2', 'assistant', [ + t( + '问题找到了。`schedule()` 在标记 `dirty` 之后立即 `flush()`,' + + '所以同一帧内多次 `schedule()` 调用会触发多次 `render()`,造成抖动。\n\n' + + '修复方案:用 `setImmediate` 把 flush 推迟到当前 tick 末尾。' + ), + toolUse('tc_s2_b', 'edit', { + path: 'apps/kimi-tui/src/renderer/loop.ts', + old_string: ' schedule() { this.dirty = true; this.flush(); }', + new_string: ' schedule() { if (this.dirty) return; this.dirty = true; setImmediate(() => this.flush()); }', + }), + ], 'pr_s2_1'), + mkMsg('msg_s2_5', 'ses_2', 'assistant', [ + toolResult('tc_s2_b', 'File edited successfully.', false), + t('修复完成。现在 `schedule()` 在同一 tick 内只会排队一次 flush,消除了重复渲染。'), + ], 'pr_s2_1'), + ], + + ses_3: [ + mkMsg('msg_s3_1', 'ses_3', 'user', [t('登录态报错太乱了,帮我归一化一下错误处理')], 'pr_s3_1'), + mkMsg('msg_s3_2', 'ses_3', 'assistant', [ + t('好的,我先搜索一下目前登录相关的错误处理分散在哪些文件:'), + toolUse('tc_s3_a', 'bash', { + command: 'rg "AuthError|loginError|auth_error" --type ts -l apps/kimi-cli/src/', + }), + ], 'pr_s3_1'), + mkMsg('msg_s3_3', 'ses_3', 'assistant', [ + toolResult('tc_s3_a', + 'apps/kimi-cli/src/auth/login.ts\n' + + 'apps/kimi-cli/src/auth/refresh.ts\n' + + 'apps/kimi-cli/src/commands/auth.ts\n', false), + t( + '错误处理散落在 3 个文件里。建议统一到 `auth/errors.ts` 里定义一个 `AuthError` 类。\n\n' + + '你希望我直接动手实施这个方案,还是先看看具体代码再决定?' + ), + ], 'pr_s3_1'), + ], + + ses_4: [], +}; + +// Update message_count on sessions +for (const s of sessions) { + s.message_count = (messages[s.id] || []).length; +} + +// ---- seed tasks ---- + +const tasks = { + ses_1: [ + { + id: 'task_1', session_id: 'ses_1', kind: 'subagent', description: 'pnpm build --filter @kimi-code/api', + status: 'running', created_at: now(), started_at: now(), + output_preview: '$ pnpm build --filter @kimi-code/api\nvite v5.2.1 building for production...', + output_bytes: 128, + }, + { + id: 'task_2', session_id: 'ses_1', kind: 'bash', description: 'eslint packages/api/src', + status: 'running', created_at: now(), started_at: now(), + output_preview: 'Running ESLint on packages/api/src...', + output_bytes: 48, + }, + { + id: 'task_3', session_id: 'ses_1', kind: 'tool', description: 'Generate docs', + status: 'completed', created_at: now(), started_at: now(), completed_at: now(), + output_preview: 'Docs generated: docs/api/client.md\n0 errors, 0 warnings', + output_bytes: 512, + }, + ], + ses_2: [], + ses_3: [], + ses_4: [], +}; + +// ---- sequence counters ---- + +const seqBySession = { ses_1: 8, ses_2: 5, ses_3: 2, ses_4: 0 }; + +// ---- pending continuations keyed by session_id ---- +const pendingContinuation = {}; +const pendingApproval = {}; +const pendingQuestion = {}; + +// ---- WS broadcast ---- + +const sockets = new Set(); + +function broadcast(type, sessionId, payload) { + const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1); + const session = sessions.find((s) => s.id === sessionId); + if (session) session.last_seq = seq; + const frame = JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload }); + for (const ws of sockets) if (ws.readyState === 1) ws.send(frame); + return seq; +} + +// ---- raw mode flag ---- +// Set STUB_RAW_EVENTS=1 to emit raw agent-core events instead of projected event.* frames. +const RAW_EVENTS_MODE = process.env.STUB_RAW_EVENTS === '1'; + +// ---- scripted reply flows ---- + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function streamMarkdown(sessionId, msgId, contentIndex, text, chunkSize = 40) { + const chunks = []; + for (let i = 0; i < text.length; i += chunkSize) { + chunks.push(text.slice(i, i + chunkSize)); + } + for (const chunk of chunks) { + await delay(80 + Math.random() * 60); + broadcast('event.assistant.delta', sessionId, { + message_id: msgId, + content_index: contentIndex, + delta: { text: chunk }, + }); + } +} + +async function simulateToolUse(sessionId, parentMsgId, toolCallId, toolName, input, outputText) { + broadcast('event.assistant.tool_use_started', sessionId, { + message_id: parentMsgId, + tool_call_id: toolCallId, + tool_name: toolName, + content_index: 1, + }); + await delay(200); + + const inputStr = JSON.stringify(input); + const chunkSize = 20; + for (let i = 0; i < inputStr.length; i += chunkSize) { + await delay(40); + broadcast('event.assistant.tool_use_delta', sessionId, { + message_id: parentMsgId, + tool_call_id: toolCallId, + input_delta: inputStr.slice(i, i + chunkSize), + }); + } + + broadcast('event.assistant.tool_use_completed', sessionId, { + message_id: parentMsgId, + tool_call_id: toolCallId, + input, + }); + + await delay(100); + + broadcast('event.tool.started', sessionId, { + tool_call_id: toolCallId, + tool_name: toolName, + input, + parent_message_id: parentMsgId, + }); + + const lines = outputText.split('\n'); + for (const line of lines) { + await delay(50 + Math.random() * 80); + broadcast('event.tool.output', sessionId, { + tool_call_id: toolCallId, + chunk: line + '\n', + stream: 'stdout', + }); + } + + await delay(100); + broadcast('event.tool.completed', sessionId, { + tool_call_id: toolCallId, + output: outputText, + is_error: false, + duration_ms: 210 + Math.floor(Math.random() * 300), + }); +} + +async function simulateDefaultReply(sessionId, userText) { + const promptId = ulid('pr_'); + const session = sessions.find((s) => s.id === sessionId); + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'idle', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(80); + + const userMsgId = ulid('msg_'); + const userMsg = mkMsg(userMsgId, sessionId, 'user', [t(userText)], promptId); + (messages[sessionId] = messages[sessionId] || []).push(userMsg); + broadcast('event.message.created', sessionId, { message: userMsg }); + + await delay(150); + + const aMsgId = ulid('msg_'); + const aMsg = mkMsg(aMsgId, sessionId, 'assistant', [t('')], promptId); + (messages[sessionId]).push(aMsg); + broadcast('event.message.created', sessionId, { message: { ...aMsg, status: 'pending' } }); + + await delay(300); + + const mdText = + '## 分析结果\n\n' + + '我检查了相关代码,发现以下问题:\n\n' + + '- **超时配置** 目前硬编码在多处,应该统一到配置文件\n' + + '- **重试逻辑** 缺少指数退避(exponential backoff)\n\n' + + '我先读取 `src/api/client.ts` 确认当前实现:'; + + await streamMarkdown(sessionId, aMsgId, 0, mdText); + + const readCallId = ulid('tc_'); + await simulateToolUse( + sessionId, aMsgId, readCallId, 'read', + { path: 'src/api/client.ts' }, + 'import { fetch } from "node-fetch";\n\nconst TIMEOUT = 5000;\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n' + ); + + await delay(200); + + const preWriteText = '\n\n找到了,超时硬编码为 `5000`。现在我来更新这个文件:'; + await streamMarkdown(sessionId, aMsgId, 0, preWriteText); + + await delay(200); + + const writeCallId = ulid('tc_'); + const approvalId = ulid('apv_'); + + broadcast('event.assistant.tool_use_started', sessionId, { + message_id: aMsgId, + tool_call_id: writeCallId, + tool_name: 'edit', + content_index: 1, + }); + broadcast('event.assistant.tool_use_completed', sessionId, { + message_id: aMsgId, + tool_call_id: writeCallId, + input: { + path: 'src/api/client.ts', + old_string: 'const TIMEOUT = 5000;', + new_string: 'const TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);', + }, + }); + + await delay(150); + + broadcast('event.tool.started', sessionId, { + tool_call_id: writeCallId, + tool_name: 'edit', + input: { + path: 'src/api/client.ts', + old_string: 'const TIMEOUT = 5000;', + new_string: 'const TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);', + }, + parent_message_id: aMsgId, + }); + + await delay(100); + + broadcast('event.session.status_changed', sessionId, { + status: 'awaiting_approval', + previous_status: 'running', + current_prompt_id: promptId, + }); + if (session) session.status = 'awaiting_approval'; + + broadcast('event.approval.requested', sessionId, { + approval_id: approvalId, + session_id: sessionId, + tool_call_id: writeCallId, + tool_name: 'edit', + action: 'Edit file src/api/client.ts', + display: { + kind: 'diff', + path: 'src/api/client.ts', + old_text: + 'import { fetch } from "node-fetch";\n\nconst TIMEOUT = 5000;\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n', + new_text: + 'import { fetch } from "node-fetch";\n\nconst TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n', + summary: 'Replace hardcoded timeout with environment-variable-controlled value', + }, + expires_at: expires60(), + created_at: now(), + }); + + pendingApproval[sessionId] = approvalId; + + pendingContinuation[sessionId] = async () => { + delete pendingContinuation[sessionId]; + delete pendingApproval[sessionId]; + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'awaiting_approval', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(100); + + broadcast('event.tool.completed', sessionId, { + tool_call_id: writeCallId, + output: 'File edited successfully.', + is_error: false, + duration_ms: 34, + }); + + const toolResultMsgId = ulid('msg_'); + const trMsg = mkMsg(toolResultMsgId, sessionId, 'assistant', + [toolResult(writeCallId, 'File edited successfully.')], promptId); + messages[sessionId].push(trMsg); + broadcast('event.message.created', sessionId, { message: trMsg }); + + await delay(200); + + const conclusionText = + '\n\n文件已更新\n\n' + + '现在 `TIMEOUT` 会读取 `API_TIMEOUT_MS` 环境变量,不设置时回退到默认值 `5000`。\n\n' + + '需要我同时更新一下 `.env.example` 文件中的说明吗?'; + + await streamMarkdown(sessionId, aMsgId, 0, conclusionText); + + await delay(100); + + broadcast('event.assistant.completed', sessionId, { + message_id: aMsgId, + finish_reason: 'stop', + }); + + const finalContent = [t(mdText + preWriteText + conclusionText)]; + broadcast('event.message.updated', sessionId, { + message_id: aMsgId, + content: finalContent, + status: 'completed', + }); + const aEntry = messages[sessionId].find((m) => m.id === aMsgId); + if (aEntry) { aEntry.content = finalContent; aEntry.status = 'completed'; } + + await delay(100); + + const newCtx = (session?.usage?.context_tokens || 52000) + 8000; + const newUsage = mkUsage(newCtx, (session?.usage?.turn_count || 0) + 1); + if (session) session.usage = newUsage; + broadcast('event.session.usage_updated', sessionId, { + usage: newUsage, + delta: { + input_tokens: 2400, + output_tokens: 800, + cache_read_tokens: 400, + cache_creation_tokens: 0, + cost_usd: 0.0032, + }, + }); + + await delay(80); + + broadcast('event.session.status_changed', sessionId, { + status: 'idle', + previous_status: 'running', + }); + if (session) session.status = 'idle'; + + broadcastTaskProgress(sessionId); + }; +} + +async function simulateQuestionReply(sessionId, userText) { + const promptId = ulid('pr_'); + const session = sessions.find((s) => s.id === sessionId); + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'idle', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(80); + + const userMsgId = ulid('msg_'); + const userMsg = mkMsg(userMsgId, sessionId, 'user', [t(userText)], promptId); + (messages[sessionId] = messages[sessionId] || []).push(userMsg); + broadcast('event.message.created', sessionId, { message: userMsg }); + + await delay(200); + + const aMsgId = ulid('msg_'); + const aMsg = mkMsg(aMsgId, sessionId, 'assistant', [t('')], promptId); + messages[sessionId].push(aMsg); + broadcast('event.message.created', sessionId, { message: { ...aMsg, status: 'pending' } }); + + await delay(250); + await streamMarkdown(sessionId, aMsgId, 0, '在开始之前,我需要了解一下你的偏好:'); + + await delay(200); + + const questionId = ulid('qst_'); + + broadcast('event.session.status_changed', sessionId, { + status: 'awaiting_question', + previous_status: 'running', + current_prompt_id: promptId, + }); + if (session) session.status = 'awaiting_question'; + + broadcast('event.question.requested', sessionId, { + question_id: questionId, + session_id: sessionId, + questions: [ + { + id: 'q_1', + question: '你更倾向于哪种代码风格?', + header: '代码风格', + body: '影响注释、命名和函数长度等方面的偏好。', + options: [ + { id: 'opt_1a', label: '简洁优先', description: '短函数、少注释、精炼命名' }, + { id: 'opt_1b', label: '可读性优先', description: '长注释、描述性命名、函数分层' }, + { id: 'opt_1c', label: '性能优先', description: '尽量减少抽象和开销' }, + ], + multi_select: false, + allow_other: false, + }, + { + id: 'q_2', + question: '这次修改应该同时处理哪些子任务?', + header: '范围选择', + options: [ + { id: 'opt_2a', label: '更新单元测试' }, + { id: 'opt_2b', label: '更新文档注释' }, + { id: 'opt_2c', label: '更新 CHANGELOG' }, + { id: 'opt_2d', label: '更新 .env.example' }, + ], + multi_select: true, + allow_other: true, + other_label: '其他', + other_description: '如有特殊要求请填写', + }, + ], + expires_at: expires60(), + created_at: now(), + }); + + pendingQuestion[sessionId] = questionId; + + pendingContinuation[sessionId] = async () => { + delete pendingContinuation[sessionId]; + delete pendingQuestion[sessionId]; + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'awaiting_question', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(200); + + const replyText = '好的,已记录你的偏好,按照你的选择来实施修改。稍等……'; + await streamMarkdown(sessionId, aMsgId, 0, '\n\n' + replyText); + + await delay(100); + + broadcast('event.assistant.completed', sessionId, { message_id: aMsgId, finish_reason: 'stop' }); + broadcast('event.message.updated', sessionId, { + message_id: aMsgId, + content: [t(replyText)], + status: 'completed', + }); + + await delay(100); + + const newCtx = (session?.usage?.context_tokens || 20000) + 3000; + const newUsage = mkUsage(newCtx, (session?.usage?.turn_count || 0) + 1); + if (session) session.usage = newUsage; + broadcast('event.session.usage_updated', sessionId, { + usage: newUsage, + delta: { input_tokens: 800, output_tokens: 200, cache_read_tokens: 0, cache_creation_tokens: 0, cost_usd: 0.0008 }, + }); + + await delay(80); + + broadcast('event.session.status_changed', sessionId, { status: 'idle', previous_status: 'running' }); + if (session) session.status = 'idle'; + }; +} + +function broadcastTaskProgress(sessionId) { + const sessionTasks = (tasks[sessionId] || []).filter((t) => t.status === 'running'); + if (!sessionTasks.length) return; + + const intervals = []; + + for (const task of sessionTasks) { + const lines = [ + 'Building TypeScript sources...', + 'Resolving entry points...', + 'Bundling 42 modules...', + 'Emitting declaration files...', + 'Running post-build checks...', + 'Done.', + ]; + let lineIdx = 0; + + const iv = setInterval(() => { + if (lineIdx < lines.length) { + broadcast('event.task.progress', sessionId, { + task_id: task.id, + output_chunk: lines[lineIdx] + '\n', + stream: 'stdout', + }); + lineIdx++; + } else { + clearInterval(iv); + task.status = 'completed'; + task.completed_at = now(); + task.output_preview += '\nDone.'; + broadcast('event.task.completed', sessionId, { + task_id: task.id, + status: 'completed', + output_preview: task.output_preview, + output_bytes: (task.output_bytes || 0) + 64, + }); + } + }, 600); + + intervals.push(iv); + } + + return intervals; +} + +// ---- raw agent-core event simulation (STUB_RAW_EVENTS=1) ---- +// Emits the real daemon's raw event shapes instead of projected event.* frames. +// This lets us verify the client-side agentEventProjector end-to-end. + +function broadcastRaw(type, sessionId, payload) { + const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1); + const session = sessions.find((s) => s.id === sessionId); + if (session) session.last_seq = seq; + const frame = JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload: { type, ...payload } }); + for (const ws of sockets) if (ws.readyState === 1) ws.send(frame); + return seq; +} + +async function simulateRawReply(sessionId, userText) { + const session = sessions.find((s) => s.id === sessionId); + const promptId = ulid('pr_'); + const turnId = Math.floor(Math.random() * 100000); + + // Store user message + const userMsgId = ulid('msg_'); + const userMsg = mkMsg(userMsgId, sessionId, 'user', [{ type: 'text', text: userText }], promptId); + (messages[sessionId] = messages[sessionId] || []).push(userMsg); + + await delay(80); + + // turn.started + broadcastRaw('turn.started', sessionId, { + turnId, + origin: { kind: 'user' }, + agentId: 'main', + sessionId, + }); + + await delay(100); + + // turn.step.started + broadcastRaw('turn.step.started', sessionId, { + turnId, + step: 0, + stepId: ulid('step_'), + agentId: 'main', + sessionId, + }); + + await delay(120); + + // thinking.delta + const thinkingText = '分析用户输入:「' + userText + '」,准备回复。'; + for (let i = 0; i < thinkingText.length; i += 10) { + await delay(40); + broadcastRaw('thinking.delta', sessionId, { + turnId, + delta: thinkingText.slice(i, i + 10), + agentId: 'main', + sessionId, + }); + } + + await delay(100); + + // assistant.delta — stream a reply in chunks + const replyText = + '你好!我是 Kimi,你的 AI 助手。\n\n' + + '你发送了:**' + userText + '**\n\n' + + '我已经收到你的消息,正在处理中。'; + + const chunkSize = 8; + for (let i = 0; i < replyText.length; i += chunkSize) { + await delay(60 + Math.random() * 40); + broadcastRaw('assistant.delta', sessionId, { + turnId, + delta: replyText.slice(i, i + chunkSize), + agentId: 'main', + sessionId, + }); + } + + await delay(100); + + // turn.step.completed + broadcastRaw('turn.step.completed', sessionId, { + turnId, + step: 0, + stepId: ulid('step_'), + usage: { + inputOther: 1200, + output: 80, + inputCacheRead: 400, + inputCacheCreation: 0, + }, + finishReason: 'end_turn', + agentId: 'main', + sessionId, + }); + + await delay(80); + + // agent.status.updated + const newCtx = (session?.usage?.context_tokens || 8000) + 2000; + broadcastRaw('agent.status.updated', sessionId, { + model: session?.agent_config?.model || 'moonshot-v1-128k', + contextTokens: newCtx, + maxContextTokens: 131072, + contextUsage: newCtx / 131072, + planMode: false, + permission: 'auto', + usage: { + byModel: {}, + total: { inputOther: 1200, output: 80, inputCacheRead: 400, inputCacheCreation: 0 }, + currentTurn: { inputOther: 1200, output: 80, inputCacheRead: 400, inputCacheCreation: 0 }, + }, + agentId: 'main', + sessionId, + }); + if (session) session.usage.context_tokens = newCtx; + + await delay(80); + + // turn.ended + broadcastRaw('turn.ended', sessionId, { + turnId, + reason: 'completed', + agentId: 'main', + sessionId, + }); + + await delay(50); + + // prompt.completed + broadcastRaw('prompt.completed', sessionId, { + agentId: 'main', + sessionId, + promptId, + }); + + if (session) session.status = 'idle'; +} + +function simulateReply(sessionId, userText) { + if (RAW_EVENTS_MODE) { + simulateRawReply(sessionId, userText).catch(console.error); + return; + } + + const lower = userText.toLowerCase(); + const isQuestion = lower.includes('问') || lower.includes('ask') || lower.includes('?') || lower.includes('?'); + + if (isQuestion) { + simulateQuestionReply(sessionId, userText).catch(console.error); + } else { + simulateDefaultReply(sessionId, userText).catch(console.error); + } +} + +// ---- REST ---- + +const server = http.createServer((req, res) => { + const { url = '', method = 'GET' } = req; + const path = url.split('?')[0]; + + // Permissive CORS so a browser dev server on another port can read responses. + res.setHeader('access-control-allow-origin', req.headers.origin || '*'); + res.setHeader('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS'); + res.setHeader('access-control-allow-headers', 'content-type,x-request-id,authorization'); + res.setHeader('access-control-allow-credentials', 'true'); + res.setHeader('access-control-max-age', '86400'); + if (method === 'OPTIONS') { res.statusCode = 204; return res.end(); } + res.setHeader('content-type', 'application/json; charset=utf-8'); + + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const json = () => { try { return JSON.parse(body || '{}'); } catch { return {}; } }; + // Segments after stripping /api/v1 prefix + // path: /api/v1/sessions/ses_1/messages → stripped: /sessions/ses_1/messages + // We route on the stripped path for clarity. + const isApiV1 = path.startsWith('/api/v1'); + const stripped = isApiV1 ? path.slice('/api/v1'.length) : path; + const seg = stripped.split('/').filter(Boolean); + // seg[0]: resource group (sessions, providers, models, auth, …) + // seg[1]: first id + // seg[2]: sub-resource or action + const sid = seg[0] === 'sessions' ? seg[1] : undefined; + + // ---- healthz / meta ---- + if (stripped === '/healthz' || path === '/healthz') { + return res.end(ok({ ok: true })); + } + if (stripped === '/meta' || path === '/meta') { + return res.end(ok({ + server_version: '0.0.0-stub', + capabilities: { websocket: true, file_upload: false, fs_query: false, mcp: false, background_tasks: true }, + server_id: 'stub', + started_at: STARTED_AT, + })); + } + + // Require /api/v1 prefix for everything below + if (!isApiV1) { + return res.end(ok({})); + } + + // ---- sessions collection ---- + if (stripped === '/sessions' && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const pageSize = Math.min(Number(sp.get('page_size') || '20'), 100); + const status = sp.get('status'); + let items = [...sessions]; + if (status) items = items.filter((s) => s.status === status); + items = items.slice(0, pageSize); + return res.end(ok({ items, has_more: false })); + } + if (stripped === '/sessions' && method === 'POST') { + const b = json(); + const s = mkSession(ulid('ses_'), b.title || '新会话', 'idle', 8000, 0); + if (b.metadata) Object.assign(s.metadata, b.metadata); + if (b.agent_config) Object.assign(s.agent_config, b.agent_config); + sessions.unshift(s); + messages[s.id] = []; + tasks[s.id] = []; + seqBySession[s.id] = 0; + broadcast('event.session.created', s.id, { session: s }); + return res.end(ok(s)); + } + + // ---- sessions/{id}:compact ---- + // Mirrors the real daemon: 200 {} immediately, then raw agent-core + // compaction.* frames over WS. After ~1.2s the stored history is rewritten + // to [summary message with origin compaction_summary] + recent suffix, so + // a later snapshot reload shows the compacted view like the real server. + if (seg[0] === 'sessions' && seg.length === 2 && method === 'POST' && seg[1].endsWith(':compact')) { + const realSid = seg[1].slice(0, -':compact'.length); + const session = sessions.find((s) => s.id === realSid); + if (!session) return res.end(fail(40401, `session ${realSid} does not exist`)); + const msgs = messages[realSid] || []; + if (msgs.length < 2) return res.end(fail(40910, 'No prefix that can be compacted in current history.')); + const b = json(); + const instruction = typeof b.instruction === 'string' && b.instruction.trim() ? b.instruction.trim() : undefined; + + broadcast('compaction.started', realSid, { trigger: 'manual', instruction }); + setTimeout(() => { + const all = messages[realSid] || []; + const keep = all.slice(-2); + const compactedCount = all.length - keep.length; + const summaryText = + '(stub) Summary of the earlier conversation: the user asked to make the API ' + + 'client timeout configurable; the assistant edited createClient, ran the tests ' + + '(3 passed) and confirmed the change.' + + (instruction ? `\n\nInstruction honoured: ${instruction}` : ''); + const summaryMsg = mkMsg(ulid('msg_'), realSid, 'assistant', [t(summaryText)], undefined); + summaryMsg.metadata = { origin: { kind: 'compaction_summary' } }; + messages[realSid] = [summaryMsg, ...keep]; + broadcast('compaction.completed', realSid, { + result: { summary: summaryText, compactedCount, tokensBefore: 90000, tokensAfter: 12000 }, + }); + }, 1200); + return res.end(ok({})); + } + + // ---- sessions/{id} ---- + if (seg[0] === 'sessions' && sid && seg.length === 2) { + const session = sessions.find((s) => s.id === sid); + if (method === 'GET') { + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + return res.end(ok(session)); + } + if (method === 'PATCH') { + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const b = json(); + if (b.title !== undefined) session.title = b.title; + if (b.metadata !== undefined) Object.assign(session.metadata, b.metadata); + if (b.agent_config !== undefined) Object.assign(session.agent_config, b.agent_config); + if (b.permission_rules !== undefined) session.permission_rules = b.permission_rules; + session.updated_at = now(); + broadcast('event.session.updated', sid, { session, changed_fields: Object.keys(b) }); + return res.end(ok(session)); + } + if (method === 'DELETE') { + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const idx = sessions.findIndex((s) => s.id === sid); + if (idx >= 0) sessions.splice(idx, 1); + broadcast('event.session.deleted', sid, { session_id: sid }); + return res.end(ok({ deleted: true })); + } + } + + // ---- snapshot (v2 initial sync: GET /sessions/{id}/snapshot) ---- + if (seg[0] === 'sessions' && seg[2] === 'snapshot' && seg.length === 3 && method === 'GET') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + return res.end(ok({ + as_of_seq: seqBySession[sid] || 0, + epoch: 'ep_stub', + session, + messages: { items: messages[sid] || [], has_more: false }, + in_flight_turn: null, + pending_approvals: [], + pending_questions: [], + })); + } + + // ---- messages ---- + if (seg[0] === 'sessions' && seg[2] === 'messages' && seg.length === 3 && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const pageSize = Math.min(Number(sp.get('page_size') || '50'), 100); + const items = (messages[sid] || []).slice(-pageSize); + return res.end(ok({ items, has_more: false })); + } + if (seg[0] === 'sessions' && seg[2] === 'messages' && seg.length === 4 && method === 'GET') { + const msgId = seg[3]; + const msg = (messages[sid] || []).find((m) => m.id === msgId); + if (!msg) return res.end(fail(40403, `message ${msgId} does not exist`)); + return res.end(ok(msg)); + } + + // ---- tasks ---- + if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 3 && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const status = sp.get('status'); + let items = tasks[sid] || []; + if (status) items = items.filter((t) => t.status === status); + return res.end(ok({ items })); + } + if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 4 && method === 'GET') { + const taskId = seg[3]; + const task = (tasks[sid] || []).find((t) => t.id === taskId); + if (!task) return res.end(fail(40406, `task ${taskId} does not exist`)); + return res.end(ok(task)); + } + if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':cancel')) { + const taskId = seg[3].replace(':cancel', ''); + const task = (tasks[sid] || []).find((t) => t.id === taskId); + if (!task) return res.end(fail(40406, `task ${taskId} does not exist`)); + if (task.status !== 'running') return res.end(fail(40904, 'task already finished')); + task.status = 'cancelled'; + task.completed_at = now(); + return res.end(ok({ cancelled: true })); + } + + // ---- prompts ---- + if (seg[0] === 'sessions' && seg[2] === 'prompts' && seg.length === 3 && method === 'POST') { + const b = json(); + const content = b.content || []; + // Extract text from text-type parts only; tolerate image and other part types without crashing. + const userText = content.filter((c) => c.type === 'text').map((c) => c.text).filter(Boolean).join(''); + const imageCount = content.filter((c) => c.type === 'image').length; + const promptId = ulid('pr_'); + const userMsgId = ulid('msg_'); + const effectiveText = userText || (imageCount > 0 ? `[${imageCount} image(s) attached]` : '你好'); + setTimeout(() => simulateReply(sid, effectiveText), 100); + return res.end(ok({ prompt_id: promptId, user_message_id: userMsgId, status: 'running' })); + } + // POST /sessions/{sid}/prompts:steer — steer queued prompts into the + // active turn. The stub has no real queue: acknowledge so the web client's + // submit→steer two-step can be exercised end-to-end. + if (seg[0] === 'sessions' && seg[2] === 'prompts:steer' && seg.length === 3 && method === 'POST') { + const b = json(); + const ids = Array.isArray(b.prompt_ids) ? b.prompt_ids : []; + if (ids.length === 0) return res.end(fail(40001, 'prompt_ids required')); + return res.end(ok({ steered: true, prompt_ids: ids })); + } + if (seg[0] === 'sessions' && seg[2] === 'prompts' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':abort')) { + return res.end(fail(40903, 'prompt already completed', { aborted: false })); + } + + // ---- approvals ---- + if (seg[0] === 'sessions' && seg[2] === 'approvals' && seg.length === 4 && method === 'POST') { + const approvalId = seg[3]; + const b = json(); + const resolvedAt = now(); + + broadcast('event.approval.resolved', sid, { + approval_id: approvalId, + decision: b.decision || 'approved', + scope: b.scope, + feedback: b.feedback, + resolved_by: 'user', + resolved_at: resolvedAt, + }); + + if (pendingContinuation[sid]) { + const cont = pendingContinuation[sid]; + setTimeout(() => cont(), 200); + } + + return res.end(ok({ resolved: true, resolved_at: resolvedAt })); + } + + // ---- questions ---- + if (seg[0] === 'sessions' && seg[2] === 'questions' && seg.length === 4 && method === 'POST') { + const questionIdRaw = seg[3]; + + if (questionIdRaw.endsWith(':dismiss')) { + const questionId = questionIdRaw.replace(':dismiss', ''); + const dismissedAt = now(); + broadcast('event.question.dismissed', sid, { + question_id: questionId, + dismissed_by: 'user', + dismissed_at: dismissedAt, + }); + if (pendingContinuation[sid]) { + const cont = pendingContinuation[sid]; + setTimeout(() => cont(), 200); + } + return res.end(fail(40909, 'question.dismissed', { dismissed: true, dismissed_at: dismissedAt })); + } + + const questionId = questionIdRaw; + const b = json(); + const resolvedAt = now(); + + broadcast('event.question.answered', sid, { + question_id: questionId, + answers: b.answers || {}, + method: b.method, + note: b.note, + resolved_by: 'user', + resolved_at: resolvedAt, + }); + + if (pendingContinuation[sid]) { + const cont = pendingContinuation[sid]; + setTimeout(() => cont(), 200); + } + + return res.end(ok({ resolved: true, resolved_at: resolvedAt })); + } + + // ---- PRESUMED: models ---- + // PRESUMED — not in current daemon docs; swap when backend defines them. + if (stripped === '/models' && method === 'GET') { + return res.end(ok({ items: models })); + } + + // ---- PRESUMED: providers ---- + // PRESUMED — not in current daemon docs; swap when backend defines them. + if (stripped === '/providers' && method === 'GET') { + return res.end(ok({ items: providers })); + } + if (stripped === '/providers' && method === 'POST') { + const b = json(); + const newId = ulid('prov_'); + const newProv = { + id: newId, + type: b.type || 'custom', + base_url: b.base_url || undefined, + default_model: b.default_model || undefined, + has_api_key: !!b.api_key, + status: 'connected', + models: [], + }; + providers.push(newProv); + models.push( + { provider: newId, model: `${b.type || 'custom'}-default`, display_name: `${b.type || 'custom'} Default`, max_context_size: 128000, capabilities: [] }, + ); + newProv.models = [`${b.type || 'custom'}-default`]; + return res.end(ok(newProv)); + } + if (seg[0] === 'providers' && seg.length === 2 && method === 'DELETE') { + const provId = seg[1]; + const idx = providers.findIndex((p) => p.id === provId); + if (idx < 0) return res.end(fail(40401, `provider ${provId} not found`)); + providers.splice(idx, 1); + const toRemove = models.filter((m) => m.provider === provId); + for (const m of toRemove) { + const mi = models.indexOf(m); + if (mi >= 0) models.splice(mi, 1); + } + return res.end(ok({ deleted: true })); + } + if (seg[0] === 'providers' && seg.length === 2 && method === 'POST' && seg[1].endsWith(':refresh')) { + const provId = seg[1].replace(':refresh', ''); + const prov = providers.find((p) => p.id === provId); + if (!prov) return res.end(fail(40401, `provider ${provId} not found`)); + prov.status = 'connected'; + return res.end(ok(prov)); + } + + // ---- workspaces + daemon folder browser (demo) ---- + // GET /api/v1/workspaces — derived from seeded session cwds + a couple demo + // workspaces, each with a wd__ id (matches the real shape). + if (stripped === '/workspaces' && method === 'GET') { + return res.end(ok({ items: workspaces })); + } + // POST /api/v1/workspaces { root, name? } — echo a wd_ workspace (idempotent per root) + if (stripped === '/workspaces' && method === 'POST') { + const b = json(); + const root = String(b.root || '').replace(/\/+$/, '') || '/'; + const existing = workspaces.find((w) => w.root === root); + if (existing) return res.end(ok(existing)); + const ws = mkWorkspace(root, b.name); + workspaces.unshift(ws); + return res.end(ok(ws)); + } + // PATCH /api/v1/workspaces/{id} { name } + if (seg[0] === 'workspaces' && seg.length === 2 && method === 'PATCH') { + const ws = workspaces.find((w) => w.id === seg[1]); + if (!ws) return res.end(fail(40401, `workspace ${seg[1]} not found`)); + const b = json(); + if (b.name !== undefined) ws.name = b.name; + return res.end(ok(ws)); + } + // DELETE /api/v1/workspaces/{id} (registry only) + if (seg[0] === 'workspaces' && seg.length === 2 && method === 'DELETE') { + const idx = workspaces.findIndex((w) => w.id === seg[1]); + if (idx < 0) return res.end(fail(40401, `workspace ${seg[1]} not found`)); + workspaces.splice(idx, 1); + return res.end(ok({ deleted: true })); + } + + // GET /api/v1/fs:home — picker start dir + recent roots + if (stripped === '/fs:home' && method === 'GET') { + return res.end(ok({ home: FS_HOME, recent_roots: FS_RECENT })); + } + // GET /api/v1/fs:browse?path= — subdirs only + if (stripped === '/fs:browse' && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const reqPath = (sp.get('path') || FS_HOME).replace(/\/+$/, '') || '/'; + return res.end(ok(browseDir(reqPath))); + } + + // ---- REAL auth endpoints ---- + + // GET /api/v1/auth — readiness check + if (stripped === '/auth' && method === 'GET') { + return res.end(ok({ + ready: loggedIn, + providers_count: loggedIn ? 1 : 0, + default_model: loggedIn ? 'kimi-code/kimi-for-coding' : null, + managed_provider: loggedIn ? { status: 'authenticated' } : null, + })); + } + + // POST /api/v1/oauth/login — start singleton device flow + if (stripped === '/oauth/login' && method === 'POST') { + const flowId = ulid('flow_'); + const userCode = 'DEMO-1234'; + const expiresIn = 1800; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + currentFlow = { + flow_id: flowId, + provider: 'managed:kimi-code', + verification_uri: 'https://www.kimi.com/code/authorize_device', + verification_uri_complete: `https://www.kimi.com/code/authorize_device?user_code=${userCode}`, + user_code: userCode, + expires_in: expiresIn, + interval: 2, + status: 'pending', + expires_at: expiresAt, + }; + + // Auto-flip to 'authenticated' after 5 seconds + const capturedFlowId = flowId; + setTimeout(() => { + if (currentFlow && currentFlow.flow_id === capturedFlowId && currentFlow.status === 'pending') { + currentFlow.status = 'authenticated'; + currentFlow.resolved_at = now(); + loggedIn = true; + } + }, 5000); + + return res.end(ok({ ...currentFlow })); + } + + // GET /api/v1/oauth/login — poll current singleton flow + if (stripped === '/oauth/login' && method === 'GET') { + return res.end(ok(currentFlow)); + } + + // DELETE /api/v1/oauth/login — cancel current flow + if (stripped === '/oauth/login' && method === 'DELETE') { + if (currentFlow) { + currentFlow.status = 'cancelled'; + currentFlow.resolved_at = now(); + } + const wasCancelled = currentFlow !== null; + currentFlow = null; + return res.end(ok({ cancelled: wasCancelled, status: 'cancelled' })); + } + + // POST /api/v1/oauth/logout — logout + if (stripped === '/oauth/logout' && method === 'POST') { + loggedIn = false; + currentFlow = null; + return res.end(ok({ logged_out: true })); + } + + // ---- fs:git_status ---- + // POST /api/v1/sessions/{id}/fs:git_status + if (seg[0] === 'sessions' && seg[2] === 'fs:git_status' && seg.length === 3 && method === 'POST') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + // Return realistic git status keyed by session + const gitStatusBySid = { + ses_1: { + branch: 'feat/web', + ahead: 1, + behind: 0, + entries: { + 'apps/kimi-code/package.json': 'modified', + 'packages/daemon/src/middleware/schema.ts': 'added', + 'apps/kimi-web/src/composables/useKimiWebClient.ts': 'modified', + }, + }, + ses_2: { + branch: 'fix/tui-render', + ahead: 0, + behind: 2, + entries: { + 'apps/kimi-tui/src/renderer/loop.ts': 'modified', + }, + }, + ses_3: { + branch: 'refactor/auth-errors', + ahead: 3, + behind: 0, + entries: { + 'apps/kimi-cli/src/auth/errors.ts': 'added', + 'apps/kimi-cli/src/auth/login.ts': 'modified', + 'apps/kimi-cli/src/auth/refresh.ts': 'modified', + 'apps/kimi-cli/src/commands/auth.ts': 'modified', + }, + }, + ses_4: { + branch: 'feat/file-search', + ahead: 0, + behind: 0, + entries: {}, + }, + }; + const gs = gitStatusBySid[sid] ?? { + branch: 'main', + ahead: 0, + behind: 0, + entries: {}, + }; + return res.end(ok(gs)); + } + + // ---- fs:list ---- + // POST /api/v1/sessions/{id}/fs:list body: { path, depth?, include_git_status? } + if (seg[0] === 'sessions' && seg[2] === 'fs:list' && seg.length === 3 && method === 'POST') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const b = json(); + const reqPath = (b.path || '.').replace(/^\.\//, '').replace(/\/$/, '') || '.'; + + const modifiedNow = now(); + + // Nested stub filesystem tree + const fsTree = { + '.': [ + { path: 'src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_src', child_count: 5 }, + { path: 'docs', name: 'docs', kind: 'directory', modified_at: modifiedNow, etag: 'etag_docs', child_count: 2 }, + { path: 'packages', name: 'packages', kind: 'directory', modified_at: modifiedNow, etag: 'etag_pkg', child_count: 3 }, + { path: 'package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_pkgjson', size: 892, mime: 'application/json', language_id: 'json' }, + { path: 'README.md', name: 'README.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_readme', size: 1240, mime: 'text/markdown', language_id: 'markdown' }, + { path: 'tsconfig.json', name: 'tsconfig.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_tsconfig', size: 320, mime: 'application/json', language_id: 'json' }, + { path: 'logo.png', name: 'logo.png', kind: 'file', modified_at: modifiedNow, etag: 'etag_logo', size: 68, mime: 'image/png', is_binary: true }, + ], + 'src': [ + { path: 'src/components', name: 'components', kind: 'directory', modified_at: modifiedNow, etag: 'etag_comp', child_count: 4 }, + { path: 'src/api', name: 'api', kind: 'directory', modified_at: modifiedNow, etag: 'etag_api', child_count: 2 }, + { path: 'src/main.ts', name: 'main.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_main', size: 420, mime: 'text/typescript', language_id: 'typescript' }, + { path: 'src/App.vue', name: 'App.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_app', size: 3200, mime: 'text/x-vue', language_id: 'vue' }, + { path: 'src/style.css', name: 'style.css', kind: 'file', modified_at: modifiedNow, etag: 'etag_style', size: 680, mime: 'text/css', language_id: 'css', git_status: 'modified' }, + ], + 'src/components': [ + { path: 'src/components/TabBar.vue', name: 'TabBar.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_tabbar', size: 1800, mime: 'text/x-vue', language_id: 'vue' }, + { path: 'src/components/FileTree.vue', name: 'FileTree.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_filetree', size: 4200, mime: 'text/x-vue', language_id: 'vue', git_status: 'added' }, + { path: 'src/components/FilePreview.vue', name: 'FilePreview.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_filepreview', size: 3900, mime: 'text/x-vue', language_id: 'vue', git_status: 'added' }, + { path: 'src/components/DiffView.vue', name: 'DiffView.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_diffview', size: 2800, mime: 'text/x-vue', language_id: 'vue' }, + ], + 'src/api': [ + { path: 'src/api/types.ts', name: 'types.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_types', size: 5200, mime: 'text/typescript', language_id: 'typescript' }, + { path: 'src/api/client.ts', name: 'client.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_client', size: 1800, mime: 'text/typescript', language_id: 'typescript', git_status: 'modified' }, + ], + 'docs': [ + { path: 'docs/api.md', name: 'api.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_apidoc', size: 2400, mime: 'text/markdown', language_id: 'markdown', git_status: 'modified' }, + { path: 'docs/CHANGELOG.md', name: 'CHANGELOG.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_changelog', size: 5600, mime: 'text/markdown', language_id: 'markdown' }, + ], + 'packages': [ + { path: 'packages/daemon', name: 'daemon', kind: 'directory', modified_at: modifiedNow, etag: 'etag_daemon', child_count: 8 }, + { path: 'packages/api', name: 'api', kind: 'directory', modified_at: modifiedNow, etag: 'etag_api_pkg', child_count: 5 }, + { path: 'packages/types', name: 'types', kind: 'directory', modified_at: modifiedNow, etag: 'etag_types_pkg', child_count: 3 }, + ], + 'packages/daemon': [ + { path: 'packages/daemon/src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_dsrc', child_count: 6 }, + { path: 'packages/daemon/package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_dpkg', size: 640, mime: 'application/json', language_id: 'json' }, + ], + 'packages/api': [ + { path: 'packages/api/src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_asrc', child_count: 3 }, + { path: 'packages/api/package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_apkg', size: 420, mime: 'application/json', language_id: 'json' }, + ], + }; + + const items = fsTree[reqPath] || []; + return res.end(ok({ items, truncated: false })); + } + + // ---- fs:read ---- + // POST /api/v1/sessions/{id}/fs:read body: { path, offset?, length? } + if (seg[0] === 'sessions' && seg[2] === 'fs:read' && seg.length === 3 && method === 'POST') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const b = json(); + const reqPath = b.path || 'README.md'; + + // Tiny 1×1 transparent PNG (base64) + const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + const fileContents = { + 'README.md': { + content: + '# Kimi Code Web\n\n' + + 'A browser-based workspace client for the Kimi Code daemon.\n\n' + + '## Features\n\n' + + '- **~/chat** — Conversational AI interface with tool calls and approvals\n' + + '- **~/diff** — Real-time git status and changed-file tracking\n' + + '- **~/tasks** — Background task monitoring (subagents, bash, tools)\n' + + '- **~/files** — Workspace file browser with lazy tree and preview\n\n' + + '## Quick Start\n\n' + + '```bash\n' + + 'pnpm install\n' + + 'pnpm -C apps/kimi-web run dev:stub # start stub daemon\n' + + 'pnpm -C apps/kimi-web run dev # start Vite dev server\n' + + '```\n\n' + + '## Architecture\n\n' + + 'The web client is a Vue 3 + TypeScript SPA. All daemon calls go through\n' + + '`useKimiWebClient` composable, which owns the reactive state and exposes\n' + + 'typed action functions to components.\n\n' + + '> **Note:** The stub daemon (`dev/stub-daemon.mjs`) speaks the real wire\n' + + '> protocol closely enough for all UI features to be fully demoable.\n', + encoding: 'utf-8', + mime: 'text/markdown', + language_id: 'markdown', + size: 1240, + line_count: 35, + is_binary: false, + etag: 'etag_readme', + truncated: false, + }, + 'package.json': { + content: JSON.stringify({ + name: '@moonshot-ai/kimi-web', + version: '0.1.1', + private: true, + license: 'MIT', + type: 'module', + scripts: { + dev: 'vite', + 'dev:stub': 'node dev/stub-daemon.mjs', + build: 'vite build', + typecheck: 'vue-tsc --noEmit', + test: 'vitest run', + }, + dependencies: { marked: '^14.1.4', vue: '^3.5.35' }, + devDependencies: { + '@vitejs/plugin-vue': '^5.2.4', + '@vue/test-utils': '^2.4.6', + typescript: '6.0.2', + vite: '^6.3.3', + vitest: '^2.1.8', + }, + }, null, 2), + encoding: 'utf-8', + mime: 'application/json', + language_id: 'json', + size: 892, + line_count: 28, + is_binary: false, + etag: 'etag_pkgjson', + truncated: false, + }, + 'tsconfig.json': { + content: JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + jsx: 'preserve', + lib: ['ES2022', 'DOM'], + }, + include: ['src/**/*'], + exclude: ['node_modules'], + }, null, 2), + encoding: 'utf-8', + mime: 'application/json', + language_id: 'json', + size: 320, + line_count: 14, + is_binary: false, + etag: 'etag_tsconfig', + truncated: false, + }, + 'src/api/client.ts': { + content: + '// src/api/client.ts\n' + + '// Daemon HTTP + WS client — maps wire protocol to app types.\n\n' + + 'import type { KimiWebApi } from \'./types\';\n\n' + + 'const DEFAULT_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS ?? 30_000);\n\n' + + 'export function createApiClient(baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS): KimiWebApi {\n' + + ' // Implementation elided for brevity in the stub.\n' + + ' return {} as KimiWebApi;\n' + + '}\n', + encoding: 'utf-8', + mime: 'text/typescript', + language_id: 'typescript', + size: 1800, + line_count: 11, + is_binary: false, + etag: 'etag_client', + truncated: false, + }, + 'src/style.css': { + content: + '@import "tailwindcss";\n\n' + + ':root {\n' + + ' --ink: #14171c;\n' + + ' --text: #3f454d;\n' + + ' --dim: #697079;\n' + + ' --muted: #8b929b;\n' + + ' --line: #e7eaee;\n' + + ' --panel: #fafbfc;\n' + + ' --bg: #ffffff;\n' + + ' --blue: #1565c0;\n' + + ' --blue2: #0d4f9e;\n' + + ' --soft: #e9f0fa;\n' + + ' --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;\n' + + '}\n\n' + + 'body {\n' + + ' font-family: var(--mono);\n' + + ' color: var(--text);\n' + + ' background: var(--bg);\n' + + ' font-size: 12.5px;\n' + + ' line-height: 1.55;\n' + + '}\n', + encoding: 'utf-8', + mime: 'text/css', + language_id: 'css', + size: 680, + line_count: 24, + is_binary: false, + etag: 'etag_style', + truncated: false, + }, + 'docs/api.md': { + content: + '# API Reference\n\n' + + '## File System Endpoints\n\n' + + '### `POST /api/v1/sessions/{id}/fs:list`\n\n' + + 'List directory contents.\n\n' + + '**Request body:**\n' + + '```json\n' + + '{ "path": "src", "include_git_status": true }\n' + + '```\n\n' + + '**Response:**\n' + + '```json\n' + + '{ "items": [...], "truncated": false }\n' + + '```\n\n' + + '### `POST /api/v1/sessions/{id}/fs:read`\n\n' + + 'Read file content.\n\n' + + '**Request body:**\n' + + '```json\n' + + '{ "path": "README.md" }\n' + + '```\n\n' + + '**Response:**\n' + + '```json\n' + + '{ "content": "...", "encoding": "utf-8", "mime": "text/markdown", ... }\n' + + '```\n', + encoding: 'utf-8', + mime: 'text/markdown', + language_id: 'markdown', + size: 2400, + line_count: 42, + is_binary: false, + etag: 'etag_apidoc', + truncated: false, + }, + 'logo.png': { + content: PNG_1X1, + encoding: 'base64', + mime: 'image/png', + size: 68, + is_binary: true, + etag: 'etag_logo', + truncated: false, + }, + }; + + const fileData = fileContents[reqPath]; + if (fileData) { + return res.end(ok({ path: reqPath, ...fileData })); + } + + // Generic fallback for any unknown path + const ext = reqPath.split('.').pop() || ''; + const mimeMap = { + ts: 'text/typescript', vue: 'text/x-vue', js: 'text/javascript', + json: 'application/json', md: 'text/markdown', css: 'text/css', + html: 'text/html', sh: 'text/x-sh', txt: 'text/plain', + }; + const langMap = { + ts: 'typescript', vue: 'vue', js: 'javascript', json: 'json', + md: 'markdown', css: 'css', html: 'html', sh: 'shellscript', + }; + const mime = mimeMap[ext] || 'text/plain'; + const lang = langMap[ext] || ext; + const fallbackContent = `// ${reqPath}\n// (stub: content not seeded for this path)\n`; + return res.end(ok({ + path: reqPath, + content: fallbackContent, + encoding: 'utf-8', + mime, + language_id: lang || undefined, + size: fallbackContent.length, + line_count: 2, + is_binary: false, + etag: 'etag_fallback_' + reqPath.replace(/[^a-z0-9]/gi, '_'), + truncated: false, + })); + } + + // ---- file upload ---- + // POST /api/v1/files (multipart/form-data: file, name?, expires_in_sec?) + // The stub does not parse multipart fully — it just returns a synthesised FileMeta. + if (stripped === '/files' && method === 'POST') { + const fileId = ulid('file_'); + // Try to extract filename from Content-Disposition if possible; fall back to generic name. + const nameMatch = body.match(/filename="([^"]+)"/); + const fileName = nameMatch ? nameMatch[1] : 'upload.png'; + const fileMeta = { + id: fileId, + name: fileName, + media_type: 'image/png', + size: body.length, + created_at: now(), + }; + return res.end(ok(fileMeta)); + } + + // ---- tools / mcp ---- + if (stripped === '/tools' && method === 'GET') { + return res.end(ok({ + tools: [ + { name: 'read', description: 'Read a file from disk', input_schema: {}, source: 'builtin' }, + { name: 'bash', description: 'Run a shell command', input_schema: {}, source: 'builtin' }, + { name: 'edit', description: 'Edit a file with old/new string replacement', input_schema: {}, source: 'builtin' }, + { name: 'write', description: 'Write a new file', input_schema: {}, source: 'builtin' }, + { name: 'ls', description: 'List directory contents', input_schema: {}, source: 'builtin' }, + { name: 'grep', description: 'Search file contents with regex', input_schema: {}, source: 'builtin' }, + ], + })); + } + if (stripped === '/mcp/servers' && method === 'GET') { + return res.end(ok({ servers: [] })); + } + + // Fallback + return res.end(ok({})); + }); +}); + +// ---- WS ---- + +const wss = new WebSocketServer({ server, path: '/api/v1/ws' }); + +wss.on('connection', (ws) => { + sockets.add(ws); + + ws.send(JSON.stringify({ + type: 'server_hello', + timestamp: now(), + payload: { + server_id: 'stub', + heartbeat_ms: 30000, + max_event_buffer_size: 1000, + capabilities: { event_batching: false, compression: false }, + }, + })); + + const ping = setInterval(() => { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'ping', timestamp: now(), payload: { nonce: ulid('n_') } })); + } + }, 30000); + + ws.on('message', (raw) => { + let m; + try { m = JSON.parse(String(raw)); } catch { return; } + + if (m.type === 'client_hello') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { + accepted_subscriptions: m.payload?.subscriptions || [], + resync_required: [], + }, + })); + } + + if (m.type === 'subscribe') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { + accepted: m.payload?.session_ids || [], + not_found: [], + resync_required: [], + }, + })); + } + + if (m.type === 'unsubscribe') { + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: {} })); + } + + if (m.type === 'abort') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { aborted: false }, + })); + } + + if (m.type === 'pong') { + // heartbeat response — no-op + } + + if (m.type === 'watch_fs_add' || m.type === 'watch_fs_remove') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { watched_paths: m.payload?.paths || [] }, + })); + } + }); + + ws.on('close', () => { + clearInterval(ping); + sockets.delete(ws); + }); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[stub-daemon] REST+WS on http://127.0.0.1:${PORT} (Ctrl+C to stop)`); + console.log(`[stub-daemon] Routes: /api/v1/* (healthz, models, providers, auth/login, auth/logout, auth/status)`); + console.log(`[stub-daemon] WS path: /api/v1/ws`); + console.log(`[stub-daemon] Event mode: ${RAW_EVENTS_MODE ? 'RAW agent-core events (STUB_RAW_EVENTS=1)' : 'projected event.* protocol (default)'}`); + console.log(`[stub-daemon] Seeded ${sessions.length} sessions, ${Object.values(messages).flat().length} messages`); +}); diff --git a/apps/kimi-web/icon-preview.html b/apps/kimi-web/icon-preview.html new file mode 100644 index 000000000..cbe655b4d --- /dev/null +++ b/apps/kimi-web/icon-preview.html @@ -0,0 +1,155 @@ + + + + + +新建 Session 图标选择 + + + +

新建 Session 图标选择

+

点击卡片即可选中,告诉我编号即可

+ +
+ +
+ 当前按钮样式:背景透明,默认色 #bbb,hover #666,尺寸约 13×13px,display inline-flex 居中。
+ 如果以上都不满意,可以描述你想要的风格(更大/实心/某种形状等),我可以继续补充。 +
+ + + + diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html new file mode 100644 index 000000000..255b88737 --- /dev/null +++ b/apps/kimi-web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + Kimi Code Web + + +
+ + + diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json new file mode 100644 index 000000000..55e0b257e --- /dev/null +++ b/apps/kimi-web/package.json @@ -0,0 +1,34 @@ +{ + "name": "@moonshot-ai/kimi-web", + "version": "0.1.1", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "dev:stub": "node dev/stub-daemon.mjs", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "markstream-vue": "1.0.1-beta.5", + "shiki": "^4.2.0", + "stream-markdown": "^0.0.15", + "vue": "^3.5.35", + "vue-i18n": "^11.4.5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@vitejs/plugin-vue": "^5.2.4", + "@vue/test-utils": "^2.4.6", + "jsdom": "^25.0.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.2", + "vite": "^6.3.3", + "vitest": "4.1.4", + "vue-tsc": "~3.2.0", + "ws": "^8.18.0" + } +} diff --git a/apps/kimi-web/public/favicon.ico b/apps/kimi-web/public/favicon.ico new file mode 100644 index 000000000..9b4870b72 Binary files /dev/null and b/apps/kimi-web/public/favicon.ico differ diff --git a/apps/kimi-web/share-icons-preview.html b/apps/kimi-web/share-icons-preview.html new file mode 100644 index 000000000..e9b92192f --- /dev/null +++ b/apps/kimi-web/share-icons-preview.html @@ -0,0 +1,254 @@ + + + + + +Share Icon Options + + + +

选择分享图标

+

点击卡片选中,下方会实时预览在 TabBar 中的效果

+ +
+ +
+
+ + + + + + + +
+ A + iOS/Android Share +
+ + +
+
+ + + + + +
+ B + Arrow Up from Tray +
+ + +
+
+ + + + +
+ C + Arrow Out of Box +
+ + +
+
+ + + + +
+ D + Curved Forward +
+ + +
+
+ + + +
+ E + Paper Plane +
+ + +
+
+ + + + + +
+ F + Export Arrow +
+
+ +

下方是 TabBar 中的实际预览效果

+ +
+
+
chat
+
files
+
tasks 2
+
+
+ +
+
+ + + + diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue new file mode 100644 index 000000000..0e667f2cf --- /dev/null +++ b/apps/kimi-web/src/App.vue @@ -0,0 +1,986 @@ + + + + + + + + diff --git a/apps/kimi-web/src/api/config.ts b/apps/kimi-web/src/api/config.ts new file mode 100644 index 000000000..a7a936632 --- /dev/null +++ b/apps/kimi-web/src/api/config.ts @@ -0,0 +1,86 @@ +// apps/kimi-web/src/api/config.ts +// Reads Vite env, builds REST/WS URLs, manages stable clientId. + +const CLIENT_ID_KEY = 'kimi-web.client-id'; + +export interface KimiApiConfig { + serverHttpUrl: string; + clientId: string; +} + +export function readKimiApiConfig(): KimiApiConfig { + return { + serverHttpUrl: normalizeServerOrigin(import.meta.env.VITE_KIMI_SERVER_HTTP_URL), + clientId: getClientId(), + }; +} + +// Default to SAME-ORIGIN so we never depend on CORS: +// - dev: the SPA is served by Vite; the Vite dev proxy forwards /v1, /healthz +// and /v1/ws to the server (see vite.config.ts), so the browser only ever +// talks to its own origin. +// - prod: `kimi web` serves this built SPA from the server itself, so the +// server's origin already is the API origin. +// Set VITE_KIMI_SERVER_HTTP_URL to connect directly to an absolute server +// origin instead (that path does require the server to send CORS headers). +function defaultServerOrigin(): string { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + return 'http://127.0.0.1:7878'; +} + +export function normalizeServerOrigin(value: string | undefined): string { + const raw = value && value.trim() ? value : defaultServerOrigin(); + const url = new URL(raw); + url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** Strip the scheme for a compact display origin: `http://127.0.0.1:7878` → `127.0.0.1:7878`. */ +function shortOrigin(origin: string): string { + return origin.replace(/^https?:\/\//, '').replace(/\/$/, ''); +} + +/** + * Address of the REAL server the client is connected to, shown in the status bar. + * Always the actual server — never the dev-proxy URL — since that's the thing + * worth knowing at a glance. Cases: + * - VITE_KIMI_SERVER_HTTP_URL set → that absolute server origin (direct mode). + * - dev (same-origin proxy) → the proxy's upstream target (the real server). + * - prod (server serves the SPA) → the page origin (it IS the server). + */ +export function serverEndpointLabel(): string { + const direct = import.meta.env.VITE_KIMI_SERVER_HTTP_URL; + if (direct && direct.trim()) return shortOrigin(normalizeServerOrigin(direct)); + + const proxy = + typeof __KIMI_DEV_PROXY_TARGET__ !== 'undefined' ? __KIMI_DEV_PROXY_TARGET__ : ''; + if (import.meta.env.DEV && proxy) return shortOrigin(proxy); + + const origin = + typeof window !== 'undefined' && window.location?.origin ? window.location.origin : ''; + return shortOrigin(origin); +} + +// The real server serves everything (incl. healthz + ws) under the /api/v1 prefix. +export function buildRestUrl(origin: string, path: string): string { + return `${origin}/api/v1${path.startsWith('/') ? path : `/${path}`}`; +} + +export function buildWsUrl(origin: string, clientId: string): string { + const url = new URL(`${origin}/api/v1/ws`); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('client_id', clientId); + return url.toString(); +} + +function getClientId(): string { + const stored = globalThis.localStorage?.getItem(CLIENT_ID_KEY); + if (stored) return stored; + const generated = `web_${globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`; + globalThis.localStorage?.setItem(CLIENT_ID_KEY, generated); + return generated; +} diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts new file mode 100644 index 000000000..e2ff36d1b --- /dev/null +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -0,0 +1,1024 @@ +// apps/kimi-web/src/api/daemon/agentEventProjector.ts +// +// Client-side projector: raw agent-core WS events → AppEvent[] +// +// The real daemon pushes raw agent-core events (NOT the projected "event.*" +// protocol events). This projector translates them into the same AppEvent union +// that the existing reducer (eventReducer.ts) consumes. +// +// Ported from the daemon-side reference implementation: +// apps/kimi-daemon/src/session/event-projector.ts +// apps/kimi-daemon/src/session/message-log.ts +// apps/kimi-daemon/src/session/usage-tracker.ts +// +// Usage: +// const projector = createAgentProjector(); +// const appEvents = projector.project(rawType, payload, sessionId); +// // call reset() when re-subscribing / resyncing a session + +import type { AppEvent, AppInFlightTurn, AppMessage, AppMessageContent, AppSessionUsage } from '../types'; +import { i18n } from '../../i18n'; +import { toAppMessageContent } from './mappers'; +import type { WireMessageContent } from './wire'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function ulid(prefix = 'msg_'): string { + const t = Date.now().toString(36).padStart(10, '0'); + const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0'); + return `${prefix}${t}${r}`; +} + +/** Normalise the raw token usage shape emitted by agent-core. */ +function normalizeUsage(raw: unknown): { + input: number; + output: number; + cacheRead: number; + cacheCreate: number; +} { + if (!raw || typeof raw !== 'object') { + return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }; + } + const u = raw as Record; + return { + input: u['inputOther'] ?? u['input_tokens'] ?? 0, + output: u['output'] ?? u['output_tokens'] ?? 0, + cacheRead: u['inputCacheRead'] ?? u['cache_read_input_tokens'] ?? 0, + cacheCreate: u['inputCacheCreation'] ?? u['cache_creation_input_tokens'] ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Per-session projector state +// --------------------------------------------------------------------------- + +interface SessionState { + // Turn ID → promptId binding + turnPromptId: Map; + currentPromptId: string | undefined; + + // Assistant message tracking + currentAssistantMsgId: string | undefined; + + // Per-turn accumulated stream lengths — aligned against the wire `offset` + // on volatile delta frames (v2 sync protocol) to skip duplicates and + // detect gaps after a snapshot seed. + turnTextLen: number; + turnThinkLen: number; + + // Tool timing + toolStartTimes: Map; + + // Usage accumulator + totalInput: number; + totalOutput: number; + totalCacheRead: number; + totalCacheCreate: number; + contextTokens: number; + contextLimit: number; + turnCount: number; + model: string; + + // In-memory message log (mirrors daemon message-log.ts) + messages: AppMessage[]; +} + +function createSessionState(): SessionState { + return { + turnPromptId: new Map(), + currentPromptId: undefined, + currentAssistantMsgId: undefined, + turnTextLen: 0, + turnThinkLen: 0, + toolStartTimes: new Map(), + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + model: '', + messages: [], + }; +} + +// --------------------------------------------------------------------------- +// Message-log helpers (inlined; mirrors message-log.ts) +// --------------------------------------------------------------------------- + +/** + * Decouple an emitted message from the projector's internal log. The reducer + * stores emitted messages by reference; the projector keeps mutating its own + * copy in place (`slot.text += delta`), so sharing the content objects makes + * the reducer's delta-append run on already-appended text — the first streamed + * chunk of every text/thinking block rendered twice. + */ +function cloneMessage(msg: AppMessage): AppMessage { + return { ...msg, content: msg.content.map((c) => ({ ...c })) }; +} + +function startAssistantMessage(state: SessionState, sessionId: string, promptId: string): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'assistant', + content: [], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function startUserMessage( + state: SessionState, + sessionId: string, + promptId: string, + userMessageId: string, + content: AppMessageContent[], + createdAt: string, +): AppMessage { + const msg: AppMessage = { + id: userMessageId, + sessionId, + role: 'user', + content, + createdAt, + promptId, + }; + state.messages.push(msg); + return msg; +} + +function toAppPromptContent(raw: unknown): AppMessageContent[] { + if (!Array.isArray(raw)) return []; + return raw.map((part) => toAppMessageContent(part as WireMessageContent)); +} + +/** + * Append a streamed text/thinking delta in stream order: continue the LAST + * content part when it has the same type, otherwise open a NEW part at the + * end. Returns the content index written (-1 if the message is unknown) so + * the emitted assistantDelta targets the same slot in the reducer. + * + * No per-type fixed slots: a step that goes think → text → think again gets + * three parts in call order instead of all thinking collapsing into one slot. + */ +function appendAssistantDelta( + state: SessionState, + messageId: string, + kind: 'text' | 'thinking', + delta: string, +): number { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return -1; + const last = msg.content[msg.content.length - 1]; + if (last && last.type === kind) { + if (kind === 'text') (last as { type: 'text'; text: string }).text += delta; + else (last as { type: 'thinking'; thinking: string }).thinking += delta; + return msg.content.length - 1; + } + msg.content.push(kind === 'text' ? { type: 'text', text: delta } : { type: 'thinking', thinking: delta }); + return msg.content.length - 1; +} + +function appendToolUse( + state: SessionState, + messageId: string, + toolCallId: string, + toolName: string, + input: unknown, +): void { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return; + msg.content.push({ type: 'toolUse', toolCallId, toolName, input }); +} + +function finishAssistantMessage(state: SessionState, messageId: string): void { + const msg = state.messages.find((m) => m.id === messageId); + // We record nothing extra here — status is implicit in the downstream reducer + void msg; +} + +function appendToolResultMessage( + state: SessionState, + sessionId: string, + toolCallId: string, + output: unknown, + isError: boolean, + promptId: string, +): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'tool', + content: [{ type: 'toolResult', toolCallId, output, isError }], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function getMsgById(state: SessionState, messageId: string): AppMessage | undefined { + return state.messages.find((m) => m.id === messageId); +} + +// --------------------------------------------------------------------------- +// Usage snapshot builder +// --------------------------------------------------------------------------- + +function buildUsageSnapshot(state: SessionState): AppSessionUsage { + return { + inputTokens: state.totalInput, + outputTokens: state.totalOutput, + cacheReadTokens: state.totalCacheRead, + cacheCreationTokens: state.totalCacheCreate, + totalCostUsd: 0, + contextTokens: state.contextTokens, + contextLimit: state.contextLimit, + turnCount: state.turnCount, + }; +} + +// --------------------------------------------------------------------------- +// AgentProjector +// --------------------------------------------------------------------------- + +export interface ProjectMeta { + /** + * Wire-level pre-append stream offset on volatile text-delta frames (v2 + * sync protocol). Used to skip duplicate deltas and detect gaps after a + * snapshot seed. + */ + offset?: number; +} + +export interface AgentProjector { + /** Project a single raw agent-core event into zero or more AppEvents. Never throws. */ + project(rawType: string, payload: unknown, sessionId: string, meta?: ProjectMeta): AppEvent[]; + /** + * Bind an externally-known promptId to the next turn.startd for this session. + * Call this right after submitPrompt() returns, before the first turn.started arrives. + */ + bindNextPromptId(sessionId: string, promptId: string): void; + /** + * Seed mid-turn state from a session snapshot's `in_flight_turn` (v2 sync): + * resets per-session state, builds the partially-streamed assistant message + * (thinking + text + running tool_use parts), and returns the AppEvents + * (sessionStatusChanged + messageCreated) to apply to the reducer. Live + * deltas continue appending; their wire `offset` aligns against the seeded + * text so the overlap window around snapshot/subscribe is exact. + */ + seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[]; + /** Reset all per-session state (call on re-subscribe / resync). */ + reset(sessionId: string): void; +} + +export function createAgentProjector(): AgentProjector { + const sessions = new Map(); + + function getOrCreate(sessionId: string): SessionState { + let s = sessions.get(sessionId); + if (!s) { + s = createSessionState(); + sessions.set(sessionId, s); + } + return s; + } + + function reset(sessionId: string): void { + sessions.set(sessionId, createSessionState()); + } + + function bindNextPromptId(sessionId: string, promptId: string): void { + const s = getOrCreate(sessionId); + s.currentPromptId = promptId; + } + + function seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[] { + reset(sessionId); + const s = getOrCreate(sessionId); + + const promptId = ulid('pr_'); + s.currentPromptId = promptId; + s.turnPromptId.set(turn.turnId, promptId); + + const msg = startAssistantMessage(s, sessionId, promptId); + if (turn.thinkingText.length > 0) { + msg.content.push({ type: 'thinking', thinking: turn.thinkingText }); + } + if (turn.assistantText.length > 0) { + msg.content.push({ type: 'text', text: turn.assistantText }); + } + for (const tool of turn.runningTools) { + msg.content.push({ + type: 'toolUse', + toolCallId: tool.toolCallId, + toolName: tool.name, + input: tool.args ?? {}, + }); + s.toolStartTimes.set(tool.toolCallId, Date.now()); + } + s.currentAssistantMsgId = msg.id; + s.turnTextLen = turn.assistantText.length; + s.turnThinkLen = turn.thinkingText.length; + + return [ + { + type: 'sessionStatusChanged', + sessionId, + status: 'running', + previousStatus: 'idle', + currentPromptId: promptId, + }, + { type: 'messageCreated', message: cloneMessage(msg) }, + ]; + } + + function project( + rawType: string, + payload: unknown, + sessionId: string, + meta?: ProjectMeta, + ): AppEvent[] { + try { + return _project(rawType, payload, sessionId, meta); + } catch (err) { + // Defensive: log but never crash the caller + console.error('[agentProjector] Error projecting event:', rawType, err instanceof Error ? err.message : err); + return []; + } + } + + /** + * Align a live text-delta against the per-turn accumulated length using the + * wire `offset`. Returns 'skip' for duplicates (offset behind local state), + * 'gap' when deltas were missed (offset ahead — trigger a re-snapshot), and + * 'append' otherwise. + */ + function alignDelta(localLen: number, offset: number | undefined): 'append' | 'skip' | 'gap' { + if (offset === undefined) return 'append'; + if (offset < localLen) return 'skip'; + if (offset > localLen) return 'gap'; + return 'append'; + } + + function _project( + rawType: string, + payload: unknown, + sessionId: string, + meta?: ProjectMeta, + ): AppEvent[] { + const s = getOrCreate(sessionId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = payload as any; + const out: AppEvent[] = []; + + switch (rawType) { + // ----------------------------------------------------------------------- + case 'session.meta.updated': { + // The daemon auto-generates a title from the first prompt (and other + // clients can rename a session). It announces both via this event. We + // don't have the full AppSession here, so emit a lightweight + // sessionMetaUpdated that patches only the title field. + const title: string | undefined = p?.patch?.title ?? p?.title; + if (typeof title === 'string' && title.length > 0) { + out.push({ type: 'sessionMetaUpdated', sessionId, title }); + } + break; + } + + // ----------------------------------------------------------------------- + case 'prompt.submitted': { + const promptId: string | undefined = p?.promptId; + const userMessageId: string | undefined = p?.userMessageId; + if (!promptId || !userMessageId) break; + const content = toAppPromptContent(p?.content); + if (content.length === 0) break; + s.currentPromptId = promptId; + const msg = startUserMessage( + s, + sessionId, + promptId, + userMessageId, + content, + typeof p?.createdAt === 'string' ? p.createdAt : new Date().toISOString(), + ); + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.started': { + // Bind turnId → promptId. Generate a synthetic one if none was pre-bound. + const turnId: number = p?.turnId; + const existingPromptId = s.currentPromptId ?? ulid('pr_'); + s.currentPromptId = existingPromptId; + if (turnId !== undefined) { + s.turnPromptId.set(turnId, existingPromptId); + } + // Fresh turn → fresh per-turn stream offsets. + s.turnTextLen = 0; + s.turnThinkLen = 0; + + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: 'running', + previousStatus: 'idle', + currentPromptId: existingPromptId, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.started': { + const turnId: number = p?.turnId; + let promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) { + // Joined mid-turn (reconnect/resync wiped the binding): synthesize a + // promptId like turn.started does, so the REST of the turn still + // renders instead of every following event being dropped. + promptId = ulid('pr_'); + s.currentPromptId = promptId; + if (turnId !== undefined) s.turnPromptId.set(turnId, promptId); + } + + // Create a new pending assistant message + const msg = startAssistantMessage(s, sessionId, promptId); + s.currentAssistantMsgId = msg.id; + + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + break; + } + + // ----------------------------------------------------------------------- + case 'thinking.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + const align = alignDelta(s.turnThinkLen, meta?.offset); + if (align === 'skip') break; + if (align === 'gap') { + out.push({ type: 'historyCompacted', sessionId, beforeSeq: 0, reason: 'delta_gap' }); + break; + } + + const thinkIdx = appendAssistantDelta(s, msgId, 'thinking', delta); + if (thinkIdx < 0) break; + s.turnThinkLen += delta.length; + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: thinkIdx, + delta: { thinking: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'assistant.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + const align = alignDelta(s.turnTextLen, meta?.offset); + if (align === 'skip') break; + if (align === 'gap') { + // Deltas were missed in the snapshot↔subscribe window — the only + // exact recovery is a fresh snapshot. historyCompacted is routed to + // onResync by the client wrapper, which reloads via snapshot. + out.push({ type: 'historyCompacted', sessionId, beforeSeq: 0, reason: 'delta_gap' }); + break; + } + + const textIdx = appendAssistantDelta(s, msgId, 'text', delta); + if (textIdx < 0) break; + s.turnTextLen += delta.length; + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: textIdx, + delta: { text: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'tool.use': + case 'tool.call.started': { + const msgId = s.currentAssistantMsgId; + const turnId: number = p?.turnId; + const promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!msgId || !promptId) break; + + const toolCallId: string = p?.toolCallId; + // Real daemon field name is 'name' per event-projector.ts + const toolName: string = p?.name ?? p?.toolName ?? ''; + const args = p?.args ?? p?.input ?? {}; + + appendToolUse(s, msgId, toolCallId, toolName, args); + + const msg = getMsgById(s, msgId); + const contentIndex = msg ? msg.content.length - 1 : 0; + + // Record start time + s.toolStartTimes.set(toolCallId, Date.now()); + + // Emit messageUpdated so the reducer knows about the new tool-use slot + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: 'pending', + }); + } + void contentIndex; + break; + } + + // ----------------------------------------------------------------------- + case 'tool.call.delta': { + // Input streaming — no-op for the web client (content already in tool.call.started.args) + break; + } + + // ----------------------------------------------------------------------- + case 'tool.progress': { + // No-op — tool output streaming is not rendered at the AppEvent level + break; + } + + // ----------------------------------------------------------------------- + case 'tool.result': { + const turnId: number = p?.turnId; + let promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) { + // Same mid-turn-join fallback as turn.step.started. + promptId = ulid('pr_'); + s.currentPromptId = promptId; + if (turnId !== undefined) s.turnPromptId.set(turnId, promptId); + } + + const toolCallId: string = p?.toolCallId; + const output = p?.output; + const isError: boolean = p?.isError ?? false; + + const startTime = s.toolStartTimes.get(toolCallId) ?? Date.now(); + s.toolStartTimes.delete(toolCallId); + void (Date.now() - startTime); // duration — unused at client level + + const resultMsg = appendToolResultMessage(s, sessionId, toolCallId, output, isError, promptId); + out.push({ type: 'messageCreated', message: cloneMessage(resultMsg) }); + + // Reset assistant message tracking — next step.started will create a fresh one + s.currentAssistantMsgId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.completed': { + const msgId = s.currentAssistantMsgId; + + // Feed usage + const u = normalizeUsage(p?.usage); + s.totalInput += u.input; + s.totalOutput += u.output; + s.totalCacheRead += u.cacheRead; + s.totalCacheCreate += u.cacheCreate; + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: 'completed', + }); + } + } + break; + } + + // ----------------------------------------------------------------------- + case 'agent.status.updated': { + if (p?.model) s.model = p.model; + if (p?.contextTokens !== undefined) s.contextTokens = p.contextTokens; + if (p?.maxContextTokens !== undefined) s.contextLimit = p.maxContextTokens; + + out.push({ + type: 'sessionUsageUpdated', + sessionId, + usage: buildUsageSnapshot(s), + // Carry the live model so the status bar shows the real running model + // instead of falling back to the daemon's (empty) REST model. + model: s.model || undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.ended': { + const msgId = s.currentAssistantMsgId; + const reason: string = p?.reason ?? 'completed'; + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: reason === 'failed' ? 'error' : 'completed', + }); + } + } + + s.turnCount++; + const usageSnapshot = buildUsageSnapshot(s); + out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); + + const newStatus = reason === 'cancelled' ? 'aborted' : reason === 'failed' ? 'aborted' : 'idle'; + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: newStatus, + previousStatus: 'running', + }); + + // Clear per-turn state + s.currentAssistantMsgId = undefined; + s.currentPromptId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'prompt.completed': { + // No-op at AppEvent level — turn.ended already handles the transition to idle + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.retrying': + case 'turn.step.interrupted': { + // Discard current assistant message; next step.started will create a new one + s.currentAssistantMsgId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'subagent.spawned': { + out.push({ + type: 'taskCreated', + sessionId, + task: { + id: p?.subagentId ?? ulid('task_'), + sessionId, + kind: 'subagent', + description: p?.subagentName ?? 'subagent', + status: 'running', + createdAt: new Date().toISOString(), + }, + }); + break; + } + + case 'subagent.completed': { + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'completed', + outputPreview: typeof p?.resultSummary === 'string' ? p.resultSummary : undefined, + }); + break; + } + + case 'subagent.failed': { + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'failed', + outputPreview: typeof p?.error === 'string' ? p.error : undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'error': { + // Fold into an unknown event so the reducer pushes a warning string + out.push({ + type: 'unknown', + raw: { _agentError: true, code: p?.code, message: p?.message }, + }); + break; + } + + case 'warning': { + out.push({ + type: 'unknown', + raw: { _agentWarning: true, message: p?.message }, + }); + break; + } + + // ----------------------------------------------------------------------- + // Background tasks (e.g. a backgrounded Bash command). Real daemon shape: + // payload.info = { taskId, description, status, startedAt(ms), endedAt, + // kind:'process', command, pid, exitCode }. + case 'background.task.started': { + const info = (p?.info ?? {}) as Record; + const startedAt = + typeof info.startedAt === 'number' ? new Date(info.startedAt).toISOString() : undefined; + const taskId = + typeof info.taskId === 'string' + ? info.taskId + : typeof info.taskId === 'number' + ? String(info.taskId) + : ulid('task_'); + const description = + typeof info.description === 'string' + ? info.description + : typeof info.command === 'string' + ? info.command + : i18n.global.t('tasks.defaultDescription'); + out.push({ + type: 'taskCreated', + sessionId, + task: { + id: taskId, + sessionId, + kind: 'bash', + description, + status: 'running', + createdAt: startedAt ?? new Date().toISOString(), + startedAt, + outputPreview: typeof info.command === 'string' ? `$ ${info.command}` : undefined, + }, + }); + break; + } + case 'background.task.terminated': { + const info = (p?.info ?? {}) as Record; + const failed = + info.status === 'failed' || + (typeof info.exitCode === 'number' && info.exitCode !== 0); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: + typeof info.taskId === 'string' + ? info.taskId + : typeof info.taskId === 'number' + ? String(info.taskId) + : '', + status: failed ? 'failed' : 'completed', + outputPreview: typeof info.command === 'string' ? `$ ${info.command}` : undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'compaction.completed': { + // Compaction replaced a batch of old messages with a summary on the + // daemon side. The visible transcript is NOT reloaded (the client keeps + // the scrollback and the reducer appends a divider marker); the + // historyCompacted signal still fires so seq bookkeeping and any + // non-compaction consumers stay correct. + const result = (p?.result ?? {}) as Record; + out.push({ + type: 'compactionCompleted', + sessionId, + tokensBefore: typeof result.tokensBefore === 'number' ? result.tokensBefore : undefined, + tokensAfter: typeof result.tokensAfter === 'number' ? result.tokensAfter : undefined, + summary: typeof result.summary === 'string' ? result.summary : undefined, + }); + out.push({ + type: 'historyCompacted', + sessionId, + beforeSeq: 0, + reason: 'auto_compact', + }); + break; + } + + case 'compaction.started': { + out.push({ + type: 'compactionStarted', + sessionId, + trigger: p?.trigger === 'manual' ? 'manual' : 'auto', + instruction: typeof p?.instruction === 'string' ? p.instruction : undefined, + }); + break; + } + + case 'compaction.cancelled': { + out.push({ type: 'compactionCancelled', sessionId }); + break; + } + + // ----------------------------------------------------------------------- + // Explicitly known but not projected + case 'compaction.blocked': + case 'cron.fired': + case 'goal.updated': + case 'hook.result': + case 'mcp.server.status': + case 'skill.activated': + case 'tool.list.updated': + break; + + // ----------------------------------------------------------------------- + default: + // Unknown future events — safe no-op + break; + } + + return out; + } + + return { project, bindNextPromptId, seedInFlight, reset }; +} + +// --------------------------------------------------------------------------- +// Helpers for integration layer +// --------------------------------------------------------------------------- + +/** + * Detect whether an incoming WS frame type is a raw agent-core event + * (as opposed to a projected "event.*" protocol event or a control frame). + * + * Raw agent-core events do NOT start with "event." and are not control frames. + * Control frames: server_hello, ack, ping, resync_required, error. + */ +const CONTROL_FRAME_TYPES = new Set([ + 'server_hello', + 'ack', + 'ping', + 'resync_required', + 'error', + 'pong', +]); + +export function isRawAgentCoreEvent(frameType: string): boolean { + if (frameType.startsWith('event.')) return false; + if (CONTROL_FRAME_TYPES.has(frameType)) return false; + return true; +} + +/** + * Agent-core event names the projector knows how to project. These are the + * raw events the real daemon emits. The same names may arrive WITH an "event." + * prefix (newer daemon) or WITHOUT it (older daemon). + */ +const KNOWN_AGENT_CORE_TYPES = new Set([ + 'turn.started', + 'turn.step.started', + 'turn.step.completed', + 'turn.step.retrying', + 'turn.step.interrupted', + 'turn.ended', + 'thinking.delta', + 'assistant.delta', + 'tool.call.started', + 'tool.use', // alias the daemon may use for tool.call.started + 'tool.call.delta', + 'tool.progress', + 'tool.result', + 'agent.status.updated', + 'prompt.submitted', + 'prompt.completed', + 'session.meta.updated', + 'compaction.started', + 'compaction.completed', + 'compaction.cancelled', + 'error', + 'warning', + 'subagent.spawned', + 'subagent.completed', + 'subagent.failed', + 'background.task.started', + 'background.task.terminated', +]); + +/** + * "event."-prefixed names that are GENUINE protocol events (control/projected + * events produced server-side). The agent projector must NOT re-handle these — + * they go through the existing toAppEvent() path. This includes approval / + * question requests (which drive the approval/question UI) and the no-op-but- + * known streaming/tool protocol events. + */ +const PROTOCOL_EVENT_NAMES = new Set([ + // Session lifecycle (projected) + 'session.created', + 'session.updated', + 'session.deleted', + 'session.status_changed', + 'session.usage_updated', + 'session.history_compacted', + // Message lifecycle (projected) + 'message.created', + 'message.updated', + // Approval / Question — MUST stay on the protocol path to drive the UI + 'approval.requested', + 'approval.resolved', + 'approval.expired', + 'question.requested', + 'question.answered', + 'question.dismissed', + 'question.expired', + // Background tasks (projected) + 'task.created', + 'task.progress', + 'task.completed', + // No-op-but-known protocol streaming / tool events + 'assistant.tool_use_started', + 'assistant.tool_use_delta', + 'assistant.tool_use_completed', + 'assistant.completed', + 'tool.started', + 'tool.output', + 'tool.completed', +]); + +/** + * Names that are ambiguous between the raw agent-core form (payload.delta is a + * STRING) and the already-projected protocol form (payload.delta is an object + * { text? | thinking? }, or the payload carries message_id / content_index). + */ +const AMBIGUOUS_DELTA_NAMES = new Set(['assistant.delta', 'thinking.delta']); + +export type FrameRoute = + | { route: 'protocol' } + | { route: 'agent'; agentType: string } + | { route: 'ignore' }; + +/** + * Classify a (possibly "event."-prefixed) WS frame into the path it should take. + * + * - 'protocol' → hand the original frame to toAppEvent() (existing path). + * - 'agent' → hand `agentType` + payload to the agent projector. + * - 'ignore' → drop (no session context / unroutable). + * + * Robust to all three observed shapes: + * 1) raw agent-core (no prefix): turn.started, assistant.delta{delta:'…'} + * 2) "event."-prefixed agent-core: event.turn.started, event.assistant.delta{delta:'…'} + * 3) genuine protocol "event.*" events: event.message.created, event.session.*, … + */ +export function classifyFrame(rawType: string, payload: unknown): FrameRoute { + if (CONTROL_FRAME_TYPES.has(rawType)) return { route: 'ignore' }; + + const hasPrefix = rawType.startsWith('event.'); + const name = hasPrefix ? rawType.slice('event.'.length) : rawType; + + // Ambiguous delta events: disambiguate by payload shape regardless of prefix. + if (AMBIGUOUS_DELTA_NAMES.has(name)) { + if (deltaIsRawAgentCore(payload)) return { route: 'agent', agentType: name }; + // Object delta or protocol-shaped payload → projected protocol event. + return { route: 'protocol' }; + } + + // Unprefixed frames are raw agent-core (real daemon) when we know the name. + if (!hasPrefix) { + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown unprefixed name with no protocol meaning → still try the projector + // (it safely no-ops on unknown types and advances nothing). + return { route: 'agent', agentType: name }; + } + + // Prefixed frames: genuine protocol events take priority. + if (PROTOCOL_EVENT_NAMES.has(name)) return { route: 'protocol' }; + // Prefixed agent-core event (e.g. event.turn.started) → strip + project. + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown "event.*" → let toAppEvent() record it as an unknown protocol event. + return { route: 'protocol' }; +} + +/** + * True when an assistant.delta / thinking.delta payload is in the RAW agent-core + * form: payload.delta is a plain string, and there is no protocol-only field + * (message_id / content_index). The protocol form uses delta:{text|thinking}. + */ +function deltaIsRawAgentCore(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false; + const p = payload as Record; + if ('message_id' in p || 'content_index' in p) return false; + return typeof p['delta'] === 'string'; +} diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts new file mode 100644 index 000000000..b5a1e7a50 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -0,0 +1,1068 @@ +// apps/kimi-web/src/api/daemon/client.ts +// DaemonKimiWebApi — implements KimiWebApi using the daemon REST + WS APIs. + +import type { KimiApiConfig } from '../config'; +import { buildRestUrl, buildWsUrl } from '../config'; +import type { + AppMessage, + AppMessageRole, + AppModel, + AppProvider, + AppSession, + AppSessionCursor, + AppSessionRuntimeStatus, + AppSessionSnapshot, + AppSessionStatus, + AppTask, + AppTaskStatus, + AppWorkspace, + ApprovalResponse, + FsBrowseResult, + FsEntry, + KimiEventConnection, + KimiEventHandlers, + KimiWebApi, + Page, + PageRequest, + PromptSubmission, + PromptSubmitResult, + QuestionResponse, +} from '../types'; +import { createAgentProjector } from './agentEventProjector'; +import { DaemonHttpClient } from './http'; +import { + toAppApprovalRequest, + toAppEvent, + toAppFsEntry, + toAppMessage, + toAppModel, + toAppProvider, + toAppQuestionRequest, + toAppSession, + toAppTask, + toWireApprovalResponse, + toWirePromptSubmission, + toWireQuestionResponse, + toWireSessionStatus, + toAppWorkspace, + wireEventSeq, + wireEventSessionId, +} from './mappers'; +import type { + WireAuthResult, + WireBackgroundTask, + WireEvent, + WireFileMeta, + WireFsBrowseResult, + WireFsEntry, + WireFsHomeResult, + WireMessage, + WireModel, + WireOAuthCancelResult, + WireOAuthLoginPollResult, + WireOAuthLoginStartResult, + WirePage, + WirePromptSubmitResult, + WirePromptSteerResult, + WireProvider, + WireSession, + WireSessionRuntimeStatus, + WireSessionSnapshot, + WireWorkspace, + WireLogoutResult, +} from './wire'; +import { DaemonEventSocket } from './ws'; + +// --------------------------------------------------------------------------- +// Wire response shapes for endpoints not in shared wire.ts +// --------------------------------------------------------------------------- + +interface WireHealth { + status: 'ok'; + uptime_sec: number; +} + +interface WireMeta { + server_version: string; + server_id: string; + started_at: string; + capabilities: Record; +} + +interface WireAbortResult { + aborted: boolean; + at_seq?: number; +} + +interface WireDismissResult { + dismissed: boolean; + dismissed_at: string; +} + +interface WireApprovalResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireQuestionResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireCancelResult { + cancelled: true; +} + +interface WireDeleteResult { + deleted: true; +} + +interface WireListDirectoryResult { + items: WireFsEntry[]; + children_by_path?: Record; + truncated: boolean; +} + +interface WireReadFileResult { + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + language_id?: string; + line_count?: number; + is_binary: boolean; +} + +interface WireSearchFilesResult { + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + match_positions: number[]; + }>; + truncated: boolean; +} + +interface WireGrepFilesResult { + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + files_scanned: number; + truncated: boolean; + elapsed_ms: number; +} + +interface WireGitStatusResult { + branch: string; + ahead: number; + behind: number; + entries: Record; +} + +interface WireDiffResult { + path: string; + diff: string; +} + +/** + * historyCompacted reasons caused by compaction itself. These do NOT trigger a + * snapshot reload: the client keeps the visible scrollback and renders a + * divider marker instead. Every other reason (delta_gap, history_rewrite, …) + * still means "cached messages are stale" and goes through onResync. + */ +function isCompactionReason(reason: string): boolean { + return reason === 'auto_compact' || reason === 'manual_compact'; +} + +// --------------------------------------------------------------------------- +// DaemonKimiWebApi +// --------------------------------------------------------------------------- + +export class DaemonKimiWebApi implements KimiWebApi { + private readonly http: DaemonHttpClient; + private readonly config: KimiApiConfig; + + constructor(config: KimiApiConfig) { + this.config = config; + this.http = new DaemonHttpClient(config.serverHttpUrl); + } + + // ------------------------------------------------------------------------- + // Health / Meta + // ------------------------------------------------------------------------- + + async getHealth(): Promise<{ status: 'ok'; uptimeSec: number }> { + // Real daemon returns { ok: true }; the older shape was { status, uptime_sec }. + const data = await this.http.get>('/healthz'); + return { status: 'ok', uptimeSec: data.uptime_sec ?? 0 }; + } + + async getMeta(): Promise<{ + serverVersion: string; + serverId: string; + startedAt: string; + capabilities: Record; + }> { + const data = await this.http.get('/meta'); + return { + serverVersion: data.server_version, + serverId: data.server_id, + startedAt: data.started_at, + capabilities: data.capabilities, + }; + } + + // ------------------------------------------------------------------------- + // Sessions + // ------------------------------------------------------------------------- + + async listSessions( + input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + status: input?.status ? toWireSessionStatus(input.status) : undefined, + // PRESUMED — daemon supports ?workspace_id= once the registry ships; it + // ignores unknown query params until then, so this is safe to always send. + workspace_id: input?.workspaceId, + }; + const data = await this.http.get>('/sessions', query); + return { + items: data.items.map(toAppSession), + hasMore: data.has_more, + }; + } + + async createSession(input: { + title?: string; + cwd?: string; + model?: string; + workspaceId?: string; + }): Promise { + // The real daemon requires `metadata` to be an object (rejects a missing + // metadata with 40001), so always send it — with cwd when provided. + const body: Record = { + metadata: input.cwd !== undefined ? { cwd: input.cwd } : {}, + }; + // PRESUMED — daemon resolves cwd from workspace_id once the registry ships. + // We ALSO send metadata.cwd (above) as the fallback so today's daemon, which + // only understands cwd, still creates the session in the right folder. + if (input.workspaceId !== undefined) body['workspace_id'] = input.workspaceId; + if (input.title !== undefined) body['title'] = input.title; + if (input.model !== undefined) body['agent_config'] = { model: input.model }; + const data = await this.http.post('/sessions', body); + return toAppSession(data); + } + + // GET /sessions/{id} — fetch one session (deep links to sessions outside the + // first listSessions page). + async getSession(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}`, + ); + return toAppSession(data); + } + + // The daemon has no PATCH on sessions; mutating title/metadata/agent_config + // (model + runtime controls) goes through POST /sessions/{id}/profile with a + // SessionUpdate body { title?, metadata?, agent_config? }. Runtime controls in + // agent_config are dispatched to the matching core RPCs (setModel/setThinking/ + // setPermission/enterPlan|cancelPlan); the live values are read back from + // GET /sessions/{id}/status (the profile echo's agent_config can be stale/""). + async updateSession( + sessionId: string, + input: { + title?: string; + cwd?: string; + model?: string; + permissionMode?: string; + planMode?: boolean; + thinking?: string; + }, + ): Promise { + const body: Record = {}; + if (input.title !== undefined) body['title'] = input.title; + if (input.cwd !== undefined) body['metadata'] = { cwd: input.cwd }; + const agentConfig: Record = {}; + if (input.model !== undefined) agentConfig['model'] = input.model; + if (input.permissionMode !== undefined) agentConfig['permission_mode'] = input.permissionMode; + if (input.planMode !== undefined) agentConfig['plan_mode'] = input.planMode; + if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; + if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/profile`, + body, + ); + return toAppSession(data); + } + + /** + * GET /sessions/{id}/status — the session's live runtime state (current model, + * thinking level, permission mode, plan flag, and context-window usage). This + * is the source of truth for the status line; Session.agent_config.model can + * be "" on the read path. + */ + async getSessionStatus(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/status`, + ); + return { + model: data.model && data.model.length > 0 ? data.model : null, + thinkingLevel: data.thinking_level, + permission: data.permission, + planMode: data.plan_mode === true, + contextTokens: data.context_tokens ?? 0, + maxContextTokens: data.max_context_tokens ?? 0, + contextUsage: data.context_usage ?? 0, + }; + } + + async deleteSession(sessionId: string): Promise<{ deleted: true }> { + const data = await this.http.delete( + `/sessions/${encodeURIComponent(sessionId)}`, + ); + return data; + } + + // ------------------------------------------------------------------------- + // Messages + // ------------------------------------------------------------------------- + + async listMessages( + sessionId: string, + input?: PageRequest & { role?: AppMessageRole }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + role: input?.role, + }; + const data = await this.http.get>( + `/sessions/${encodeURIComponent(sessionId)}/messages`, + query, + ); + return { + items: data.items.map(toAppMessage), + hasMore: data.has_more, + }; + } + + /** + * v2 initial sync: atomic session state at an `as_of_seq` watermark. + * Rebuild flow: getSessionSnapshot() → seedSnapshot() → subscribe(cursor). + */ + async getSessionSnapshot(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/snapshot`, + ); + return { + asOfSeq: data.as_of_seq, + epoch: data.epoch, + session: toAppSession(data.session), + // Snapshot messages are already chronological ascending. + messages: data.messages.items.map(toAppMessage), + hasMoreMessages: data.messages.has_more, + inFlightTurn: + data.in_flight_turn === null + ? null + : { + turnId: data.in_flight_turn.turn_id, + assistantText: data.in_flight_turn.assistant_text, + thinkingText: data.in_flight_turn.thinking_text, + runningTools: data.in_flight_turn.running_tools.map((t) => ({ + toolCallId: t.tool_call_id, + name: t.name, + args: t.args, + description: t.description, + lastProgress: t.last_progress, + })), + }, + pendingApprovals: data.pending_approvals.map(toAppApprovalRequest), + pendingQuestions: data.pending_questions.map(toAppQuestionRequest), + }; + } + + // ------------------------------------------------------------------------- + // Prompt + // ------------------------------------------------------------------------- + + async submitPrompt( + sessionId: string, + input: PromptSubmission, + ): Promise { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts`, + toWirePromptSubmission(input), + ); + return { + promptId: data.prompt_id, + userMessageId: data.user_message_id, + status: data.status, + }; + } + + // POST /sessions/{id}/prompts:steer — steer daemon-queued prompts into the + // active turn (TUI ctrl+s). Throws PROMPT_NOT_FOUND when there is no active + // turn anymore (the queued prompt then starts its own turn — callers may + // treat that as success). + async steerPrompts( + sessionId: string, + promptIds: string[], + ): Promise<{ steered: boolean; promptIds: string[] }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts:steer`, + { prompt_ids: promptIds }, + ); + return { steered: data.steered, promptIds: data.prompt_ids }; + } + + async abortPrompt( + sessionId: string, + promptId: string, + ): Promise<{ aborted: boolean; atSeq?: number }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts/${encodeURIComponent(promptId)}:abort`, + undefined, + { allowCodes: [40903] }, + ); + // data.aborted is false when 40903 (prompt already completed) — that's correct + return { aborted: data.aborted, atSeq: data.at_seq }; + } + + // POST /sessions/{id}:compact — request history compaction. Returns {}; + // progress and completion arrive via the WS compaction.* events (the + // transcript itself is not reloaded — a divider marker is appended). + async compactSession(sessionId: string, instruction?: string): Promise { + await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:compact`, + instruction ? { instruction } : {}, + ); + } + + // POST /sessions/{id}:fork — fork the session into a new child session. + async forkSession(sessionId: string, input?: { title?: string }): Promise { + const body: Record = {}; + if (input?.title !== undefined) body['title'] = input.title; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:fork`, + body, + ); + return toAppSession(data); + } + + // ------------------------------------------------------------------------- + // Approval / Question + // ------------------------------------------------------------------------- + + async respondApproval( + sessionId: string, + approvalId: string, + response: ApprovalResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(approvalId)}`, + toWireApprovalResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async respondQuestion( + sessionId: string, + questionId: string, + response: QuestionResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`, + toWireQuestionResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async dismissQuestion( + sessionId: string, + questionId: string, + ): Promise<{ dismissed: true; dismissedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}:dismiss`, + undefined, + { allowCodes: [40909] }, + ); + // 40909 means question.dismissed — that's the success path per spec + return { dismissed: true, dismissedAt: data.dismissed_at }; + } + + // ------------------------------------------------------------------------- + // Tasks + // ------------------------------------------------------------------------- + + async listTasks(sessionId: string, status?: AppTaskStatus): Promise { + const query: Record = { + status: status, + }; + const data = await this.http.get<{ items: WireBackgroundTask[] }>( + `/sessions/${encodeURIComponent(sessionId)}/tasks`, + query, + ); + return data.items.map(toAppTask); + } + + async getTask( + sessionId: string, + taskId: string, + input?: { withOutput?: boolean; outputBytes?: number }, + ): Promise { + const query: Record = { + with_output: input?.withOutput, + output_bytes: input?.outputBytes, + }; + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}`, + query, + ); + return toAppTask(data); + } + + async cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}:cancel`, + ); + return data; + } + + // ------------------------------------------------------------------------- + // File System + // ------------------------------------------------------------------------- + + async listDirectory( + sessionId: string, + input: { path?: string; depth?: number; includeGitStatus?: boolean }, + ): Promise<{ + items: FsEntry[]; + childrenByPath?: Record; + truncated: boolean; + }> { + const body: Record = {}; + if (input.path !== undefined) body['path'] = input.path; + if (input.depth !== undefined) body['depth'] = input.depth; + if (input.includeGitStatus !== undefined) body['include_git_status'] = input.includeGitStatus; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:list`, + body, + ); + const childrenByPath = data.children_by_path + ? Object.fromEntries( + Object.entries(data.children_by_path).map(([k, v]) => [k, v.map(toAppFsEntry)]), + ) + : undefined; + return { + items: data.items.map(toAppFsEntry), + childrenByPath, + truncated: data.truncated, + }; + } + + async readFile( + sessionId: string, + input: { path: string; offset?: number; length?: number }, + ): Promise<{ + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + languageId?: string; + lineCount?: number; + isBinary: boolean; + }> { + const body: Record = { path: input.path }; + if (input.offset !== undefined) body['offset'] = input.offset; + if (input.length !== undefined) body['length'] = input.length; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:read`, + body, + ); + return { + path: data.path, + content: data.content, + encoding: data.encoding, + size: data.size, + truncated: data.truncated, + etag: data.etag, + mime: data.mime, + languageId: data.language_id, + lineCount: data.line_count, + isBinary: data.is_binary, + }; + } + + async searchFiles( + sessionId: string, + input: { query: string; limit?: number }, + ): Promise<{ + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + matchPositions: number[]; + }>; + truncated: boolean; + }> { + const body: Record = { query: input.query }; + if (input.limit !== undefined) body['limit'] = input.limit; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:search`, + body, + ); + return { + items: data.items.map((item) => ({ + path: item.path, + name: item.name, + kind: item.kind, + score: item.score, + matchPositions: item.match_positions, + })), + truncated: data.truncated, + }; + } + + async grepFiles( + sessionId: string, + input: { pattern: string; regex?: boolean; caseSensitive?: boolean }, + ): Promise<{ + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + filesScanned: number; + truncated: boolean; + elapsedMs: number; + }> { + const body: Record = { pattern: input.pattern }; + if (input.regex !== undefined) body['regex'] = input.regex; + if (input.caseSensitive !== undefined) body['case_sensitive'] = input.caseSensitive; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:grep`, + body, + ); + return { + files: data.files, + filesScanned: data.files_scanned, + truncated: data.truncated, + elapsedMs: data.elapsed_ms, + }; + } + + async getGitStatus( + sessionId: string, + paths?: string[], + ): Promise<{ branch: string; ahead: number; behind: number; entries: Record }> { + const body: Record = {}; + if (paths !== undefined) body['paths'] = paths; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:git_status`, + body, + ); + return { + branch: data.branch, + ahead: data.ahead, + behind: data.behind, + entries: data.entries, + }; + } + + async getFileDiff( + sessionId: string, + path?: string, + ): Promise<{ path: string; diff: string }> { + const body: Record = {}; + if (path !== undefined) body['path'] = path; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:diff`, + body, + ); + return { path: data.path, diff: data.diff }; + } + + getFileDownloadUrl(sessionId: string, path: string): string { + const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/'); + return buildRestUrl( + this.config.serverHttpUrl, + `/sessions/${encodeURIComponent(sessionId)}/fs/${encodedPath}:download`, + ); + } + + async openFile( + sessionId: string, + input: { path: string; line?: number }, + ): Promise<{ opened: true }> { + const body: Record = { path: input.path }; + if (input.line !== undefined) body['line'] = input.line; + return this.http.post<{ opened: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:open`, + body, + ); + } + + async revealFile( + sessionId: string, + input: { path: string }, + ): Promise<{ revealed: true }> { + return this.http.post<{ revealed: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:reveal`, + { path: input.path }, + ); + } + + // ------------------------------------------------------------------------- + // Workspaces + daemon folder browser + // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. + // ------------------------------------------------------------------------- + + /** + * List the registered workspaces. + * PRESUMED — GET /api/v1/workspaces. On 404/empty/error this returns [] and + * the composable DERIVES workspaces from the current sessions' cwds. So the + * switcher + grouping work immediately off existing sessions until the daemon + * ships the registry. + */ + async listWorkspaces(): Promise { + try { + const data = await this.http.get>('/workspaces'); + return (data.items ?? []).map(toAppWorkspace); + } catch { + return []; + } + } + + /** + * Register a workspace by folder path. + * PRESUMED — POST /api/v1/workspaces { root, name? }. On error this throws so + * the composable can fall back to a locally-derived workspace from the path. + */ + async addWorkspace(input: { root: string; name?: string }): Promise { + const body: Record = { root: input.root }; + if (input.name !== undefined) body['name'] = input.name; + const data = await this.http.post('/workspaces', body); + return toAppWorkspace(data); + } + + /** + * Remove a registered workspace. + * PRESUMED — DELETE /api/v1/workspaces/:id. On error this throws. + */ + async deleteWorkspace(id: string): Promise { + await this.http.delete(`/workspaces/${encodeURIComponent(id)}`); + } + + /** + * Browse directories under `path` (defaults to $HOME on the daemon). + * PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty result so + * the picker degrades to paste-path + recentRoots. + */ + async browseFs(path?: string): Promise { + try { + const data = await this.http.get('/fs:browse', { path }); + return { + path: data.path, + parent: data.parent, + entries: (data.entries ?? []).map((e) => ({ + name: e.name, + path: e.path, + isDir: e.is_dir, + isGitRepo: e.is_git_repo, + branch: e.branch, + })), + }; + } catch { + return { path: path ?? '', parent: null, entries: [] }; + } + } + + /** + * Get the picker start directory + recently-used roots. + * PRESUMED — GET /api/v1/fs:home. On error returns empty defaults. + */ + async getFsHome(): Promise<{ home: string; recentRoots: string[] }> { + try { + const data = await this.http.get('/fs:home'); + return { home: data.home, recentRoots: data.recent_roots ?? [] }; + } catch { + return { home: '', recentRoots: [] }; + } + } + + // ------------------------------------------------------------------------- + // Models + Providers + // PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. + // ------------------------------------------------------------------------- + + async listModels(): Promise { + // PRESUMED endpoint: GET /v1/models → { items: WireModel[] } + const data = await this.http.get<{ items: WireModel[] }>('/models'); + return data.items.map(toAppModel); + } + + async listProviders(): Promise { + // PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] } + const data = await this.http.get<{ items: WireProvider[] }>('/providers'); + return data.items.map(toAppProvider); + } + + async addProvider(input: { + type: string; + apiKey?: string; + baseUrl?: string; + defaultModel?: string; + }): Promise { + // PRESUMED endpoint: POST /v1/providers → WireProvider + const body: Record = { type: input.type }; + if (input.apiKey !== undefined) body['api_key'] = input.apiKey; + if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl; + if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel; + const data = await this.http.post('/providers', body); + return toAppProvider(data); + } + + async deleteProvider(id: string): Promise<{ deleted: true }> { + // PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true } + return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`); + } + + async refreshProvider(id: string): Promise { + // PRESUMED endpoint: POST /v1/providers/{id}:refresh → WireProvider + const data = await this.http.post( + `/providers/${encodeURIComponent(id)}:refresh`, + ); + return toAppProvider(data); + } + + // ------------------------------------------------------------------------- + // Auth — REAL endpoints + // ------------------------------------------------------------------------- + + async getAuth(): Promise<{ + ready: boolean; + providersCount: number; + defaultModel: string | null; + managedProvider: { status: string } | null; + }> { + const data = await this.http.get('/auth'); + return { + ready: data.ready, + providersCount: data.providers_count, + defaultModel: data.default_model, + managedProvider: data.managed_provider + ? { status: data.managed_provider.status } + : null, + }; + } + + async startOAuthLogin(): Promise<{ + flowId: string; + provider: string; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + status: 'pending'; + expiresAt: string; + }> { + const data = await this.http.post('/oauth/login', {}); + return { + flowId: data.flow_id, + provider: data.provider, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete, + userCode: data.user_code, + expiresIn: data.expires_in, + interval: data.interval, + status: data.status, + expiresAt: data.expires_at, + }; + } + + async pollOAuthLogin(): Promise<{ + flowId: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolvedAt?: string; + } | null> { + // data may be null if no flow is active + const data = await this.http.get('/oauth/login'); + if (!data) return null; + return { + flowId: data.flow_id, + status: data.status, + resolvedAt: data.resolved_at, + }; + } + + async cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }> { + const data = await this.http.delete('/oauth/login'); + return { cancelled: data.cancelled, status: data.status }; + } + + async logout(): Promise<{ loggedOut: boolean }> { + const data = await this.http.post('/oauth/logout', {}); + return { loggedOut: data.logged_out }; + } + + // ------------------------------------------------------------------------- + // File upload + // ------------------------------------------------------------------------- + + async uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }> { + const formData = new FormData(); + formData.append('file', input.file, input.name ?? (input.file instanceof File ? input.file.name : 'upload')); + if (input.name !== undefined) { + formData.append('name', input.name); + } + const data = await this.http.postForm('/files', formData); + return { + id: data.id, + name: data.name, + mediaType: data.media_type, + size: data.size, + }; + } + + getFileUrl(fileId: string): string { + return buildRestUrl(this.config.serverHttpUrl, `/files/${encodeURIComponent(fileId)}`); + } + + // ------------------------------------------------------------------------- + // WebSocket events + // ------------------------------------------------------------------------- + + connectEvents(handlers: KimiEventHandlers): KimiEventConnection { + const wsUrl = buildWsUrl(this.config.serverHttpUrl, this.config.clientId); + + // Per-session projector for raw agent-core events. + // Keyed by session_id; reset when a session is re-subscribed or resynced. + const projector = createAgentProjector(); + + const socket = new DaemonEventSocket(wsUrl, this.config.clientId, { + // ----------------------------------------------------------------------- + // Projected "event.*" frames — existing path (kept working for stub / spec) + // ----------------------------------------------------------------------- + onWireEvent: (wireEvent: WireEvent) => { + const sessionId = wireEventSessionId(wireEvent); + const seq = wireEventSeq(wireEvent); + const appEvent = toAppEvent(wireEvent); + + // Route history_compacted to onResync so the client reloads messages — + // EXCEPT for compaction itself: the transcript keeps the scrollback and + // the reducer appends a divider marker instead (reloading would replace + // the visible conversation with the compacted model context). + if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) { + handlers.onResync(appEvent.sessionId, appEvent.beforeSeq); + // Still dispatch the event to onEvent so the reducer can update lastSeqBySession + } + + // Deliver the AppEvent together with wire-level seq/session so the + // reducer can advance lastSeqBySession[sessionId] = seq. + handlers.onEvent(appEvent, { sessionId, seq }); + }, + + // ----------------------------------------------------------------------- + // Raw agent-core frames — client-side projection path (real daemon) + // ----------------------------------------------------------------------- + onRawAgentEvent: (frame) => { + const { type, seq, session_id: sessionId, payload, offset } = frame; + const appEvents = projector.project(type, payload, sessionId, { offset }); + for (const appEvent of appEvents) { + // historyCompacted from the projector is either a compaction signal + // (reason auto_compact — no reload, the divider marker handles it) or + // a delta-gap recovery (reason delta_gap — a real resync, routed to + // onResync with the real frame.seq, mirroring the protocol path). + if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) { + handlers.onResync(sessionId, seq); + } + handlers.onEvent(appEvent, { sessionId, seq }); + } + }, + + onResync: (sessionId: string, currentSeq: number, epoch?: string) => { + // Reset per-session projector state on resync + projector.reset(sessionId); + handlers.onResync(sessionId, currentSeq, epoch); + }, + + onConnectionState: (connected: boolean) => { + handlers.onConnectionChange(connected); + }, + + onError: (code: number, msg: string, fatal: boolean) => { + handlers.onError(code, msg, fatal); + }, + }); + + socket.connect(); + + return { + subscribe(sessionId: string, cursor?: AppSessionCursor): void { + // Do NOT reset projector state here: every sidebar click re-subscribes + // the (possibly running) session, and a reset wipes the turn/prompt + // bindings — the remainder of an in-flight turn would be dropped on + // the floor. The projector starts sessions fresh on first sight, and + // onResync (below) resets explicitly before messages are reloaded. + socket.subscribe(sessionId, cursor ?? { seq: 0 }); + }, + unsubscribe(sessionId: string): void { + socket.unsubscribe(sessionId); + }, + seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void { + // Rebuild the projector's mid-turn state from the snapshot. The + // resulting AppEvents (running status + partially-streamed assistant + // message) flow through the SAME onEvent path as live events, so the + // rendering layer needs no special handling. When there is no + // in-flight turn we only reset, so stale turn state can't leak into + // the freshly-loaded message list. + if (snapshot.inFlightTurn === null) { + projector.reset(sessionId); + return; + } + const appEvents = projector.seedInFlight(sessionId, snapshot.inFlightTurn); + for (const appEvent of appEvents) { + handlers.onEvent(appEvent, { sessionId, seq: snapshot.asOfSeq }); + } + }, + bindNextPromptId(sessionId: string, promptId: string): void { + // Wire the real daemon prompt_id into the projector so turn.started + // uses it instead of a synthetic ulid('pr_'). Without this, the + // synthetic id propagates to session.currentPromptId and the REST + // :abort endpoint never matches the daemon's real prompt_id. + projector.bindNextPromptId(sessionId, promptId); + }, + abort(sessionId: string, promptId: string): void { + socket.abort(sessionId, promptId); + }, + close(): void { + socket.close(); + }, + }; + } +} diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts new file mode 100644 index 000000000..d0911d358 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -0,0 +1,479 @@ +// apps/kimi-web/src/api/daemon/eventReducer.ts +// Pure TypeScript state reducer for KimiClient. +// Operates on plain TS state — no Vue reactivity here. +// The reducer consumes AppEvent (camelCase), produced by toAppEvent() in mappers.ts. +// +// No-op-but-known events (tool.*, assistant streaming, assistant.completed) +// are mapped to { type: 'unknown', raw: { _noop: true, ... } } by mappers.ts. +// The reducer detects `_noop: true` and silently advances lastSeqBySession +// without pushing a warning. + +import type { + AppApprovalRequest, + AppEvent, + AppMessage, + AppMessageContent, + AppWarning, + AppQuestionRequest, + AppSession, + AppTask, + CompactionMarkerMetadata, +} from '../types'; +import { COMPACTION_MARKER_METADATA_KEY } from '../types'; +import { i18n } from '../../i18n'; + +const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/** Live compaction progress for a session: present (status 'running') only + while the daemon is compacting. Completion is recorded as a persistent + divider marker message in the transcript, not as transient status. */ +export interface CompactionStatus { + status: 'running'; + trigger: 'manual' | 'auto'; +} + +export interface KimiClientState { + sessions: AppSession[]; + activeSessionId?: string; + messagesBySession: Record; + approvalsBySession: Record; + questionsBySession: Record; + tasksBySession: Record; + lastSeqBySession: Record; + compactionBySession: Record; + warnings: AppWarning[]; +} + +export function createInitialState(): KimiClientState { + return { + sessions: [], + activeSessionId: undefined, + messagesBySession: {}, + approvalsBySession: {}, + questionsBySession: {}, + tasksBySession: {}, + lastSeqBySession: {}, + compactionBySession: {}, + warnings: [], + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function cloneState(s: KimiClientState): KimiClientState { + return { + ...s, + sessions: [...s.sessions], + messagesBySession: { ...s.messagesBySession }, + approvalsBySession: { ...s.approvalsBySession }, + questionsBySession: { ...s.questionsBySession }, + tasksBySession: { ...s.tasksBySession }, + lastSeqBySession: { ...s.lastSeqBySession }, + compactionBySession: { ...s.compactionBySession }, + warnings: [...s.warnings], + }; +} + +function advanceSeq(state: KimiClientState, sessionId: string | undefined, seq: number | undefined): void { + if (sessionId !== undefined && seq !== undefined && seq > 0) { + const prev = state.lastSeqBySession[sessionId] ?? 0; + if (seq > prev) { + state.lastSeqBySession[sessionId] = seq; + } + } +} + +function isOptimisticUserMessage(message: AppMessage): boolean { + return ( + message.role === 'user' && + message.metadata?.[OPTIMISTIC_USER_MESSAGE_METADATA_KEY] === true + ); +} + +function sameMessageContent(a: AppMessage, b: AppMessage): boolean { + return JSON.stringify(a.content) === JSON.stringify(b.content); +} + +function findOptimisticUserEchoIndex(messages: AppMessage[], message: AppMessage): number { + for (let i = messages.length - 1; i >= 0; i--) { + const candidate = messages[i]!; + if (isOptimisticUserMessage(candidate) && sameMessageContent(candidate, message)) { + return i; + } + } + return -1; +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +/** + * Apply a single AppEvent to the state, returning a new state object. + * The event carries `_wireSeq` and `_wireSessionId` as hidden extras when + * produced by the client wrapper, but the reducer only depends on the + * AppEvent.type discriminant. + * + * Extra metadata attached by the caller: + * meta.sessionId — wire session_id for lastSeqBySession update + * meta.seq — wire seq for lastSeqBySession update + */ +export interface EventMeta { + sessionId: string; + seq: number; +} + +export function reduceAppEvent( + state: KimiClientState, + event: AppEvent, + meta: EventMeta, +): KimiClientState { + const next = cloneState(state); + + // Always advance lastSeqBySession for every event that carries seq info. + advanceSeq(next, meta.sessionId, meta.seq); + + switch (event.type) { + // ------------------------------------------------------------------------- + case 'sessionCreated': { + const exists = next.sessions.some((s) => s.id === event.session.id); + if (!exists) { + next.sessions = [event.session, ...next.sessions]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'sessionUpdated': { + next.sessions = next.sessions.map((s) => + s.id === event.session.id ? event.session : s, + ); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionDeleted': { + const id = event.sessionId; + next.sessions = next.sessions.filter((s) => s.id !== id); + delete next.messagesBySession[id]; + delete next.tasksBySession[id]; + delete next.approvalsBySession[id]; + delete next.questionsBySession[id]; + delete next.lastSeqBySession[id]; + if (next.activeSessionId === id) { + next.activeSessionId = undefined; + } + break; + } + + // ------------------------------------------------------------------------- + case 'sessionStatusChanged': { + next.sessions = next.sessions.map((s) => { + if (s.id !== event.sessionId) return s; + return { + ...s, + status: event.status, + currentPromptId: event.currentPromptId, + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionMetaUpdated': { + // Lightweight title patch — the daemon's auto-generated title (or a title + // changed by another client) arrives via session.meta.updated. We patch + // only the title field; the full session object stays as-is. + next.sessions = next.sessions.map((s) => + s.id === event.sessionId ? { ...s, title: event.title } : s, + ); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionUsageUpdated': { + next.sessions = next.sessions.map((s) => { + if (s.id !== event.sessionId) return s; + // The live model name (from agent.status.updated) rides along with usage. + // Only overwrite model when a non-empty one is supplied. + const model = event.model && event.model.length > 0 ? event.model : s.model; + return { ...s, usage: event.usage, model }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'historyCompacted': { + // Only advance lastSeqBySession; actual reload is triggered by client wrapper + // when it sees this event type (before_seq is in event.beforeSeq). + // The advanceSeq at top already handled seq update. + break; + } + + // ------------------------------------------------------------------------- + case 'compactionStarted': { + next.compactionBySession = { + ...next.compactionBySession, + [event.sessionId]: { status: 'running', trigger: event.trigger }, + }; + break; + } + + case 'compactionCompleted': { + const sid = event.sessionId; + const prev = next.compactionBySession[sid]; + const { [sid]: _doneEntry, ...rest } = next.compactionBySession; + next.compactionBySession = rest; + + // Append a persistent "context compacted" divider to the loaded + // transcript (TUI parity: the scrollback is kept untouched; only a + // one-line marker records that compaction happened). The marker id is + // derived from the wire seq so an event replay after reconnect can't + // duplicate it. + if (Object.prototype.hasOwnProperty.call(next.messagesBySession, sid)) { + const msgs = next.messagesBySession[sid] ?? []; + const markerId = `compaction_${sid}_${meta.seq}`; + if (!msgs.some((m) => m.id === markerId)) { + const marker: CompactionMarkerMetadata = { + trigger: prev?.trigger ?? 'auto', + tokensBefore: event.tokensBefore, + tokensAfter: event.tokensAfter, + }; + next.messagesBySession[sid] = [ + ...msgs, + { + id: markerId, + sessionId: sid, + role: 'assistant', + content: event.summary ? [{ type: 'text', text: event.summary }] : [], + createdAt: new Date().toISOString(), + metadata: { + origin: { kind: 'compaction_summary' }, + [COMPACTION_MARKER_METADATA_KEY]: marker, + }, + }, + ]; + } + } + break; + } + + case 'compactionCancelled': { + const { [event.sessionId]: _gone, ...rest } = next.compactionBySession; + next.compactionBySession = rest; + break; + } + + // ------------------------------------------------------------------------- + case 'messageCreated': { + const sid = event.message.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + const exists = msgs.some((m) => m.id === event.message.id); + if (!exists) { + if (event.message.role === 'user') { + const optimisticIndex = findOptimisticUserEchoIndex(msgs, event.message); + if (optimisticIndex !== -1) { + const updated = [...msgs]; + const optimistic = updated[optimisticIndex]!; + updated[optimisticIndex] = { + ...event.message, + id: optimistic.id, + promptId: event.message.promptId ?? optimistic.promptId, + metadata: { + ...event.message.metadata, + ...optimistic.metadata, + }, + }; + next.messagesBySession[sid] = updated; + break; + } + } + next.messagesBySession[sid] = [...msgs, event.message]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'messageUpdated': { + const sid = event.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + next.messagesBySession[sid] = msgs.map((m) => { + if (m.id !== event.messageId) return m; + return { ...m, content: event.content }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'assistantDelta': { + const sid = event.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + next.messagesBySession[sid] = msgs.map((m) => { + if (m.id !== event.messageId) return m; + const content = [...m.content]; + const idx = event.contentIndex; + // Ensure the slot exists + while (content.length <= idx) { + content.push({ type: 'text', text: '' }); + } + const existing = content[idx]!; + let patched: AppMessageContent; + if (event.delta.text !== undefined) { + if (existing.type === 'text') { + patched = { type: 'text', text: existing.text + event.delta.text }; + } else { + patched = { type: 'text', text: event.delta.text }; + } + } else if (event.delta.thinking !== undefined) { + if (existing.type === 'thinking') { + patched = { + type: 'thinking', + thinking: existing.thinking + event.delta.thinking, + signature: existing.signature, + }; + } else { + patched = { type: 'thinking', thinking: event.delta.thinking }; + } + } else { + patched = existing; + } + content[idx] = patched; + return { ...m, content }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'approvalRequested': { + const sid = event.sessionId; + const list = next.approvalsBySession[sid] ?? []; + const exists = list.some((a) => a.approvalId === event.approval.approvalId); + if (!exists) { + next.approvalsBySession[sid] = [...list, event.approval]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'approvalResolved': + case 'approvalExpired': { + const sid = event.sessionId; + const aid = event.approvalId; + const list = next.approvalsBySession[sid] ?? []; + next.approvalsBySession[sid] = list.filter((a) => a.approvalId !== aid); + break; + } + + // ------------------------------------------------------------------------- + case 'questionRequested': { + const sid = event.sessionId; + const list = next.questionsBySession[sid] ?? []; + const exists = list.some((q) => q.questionId === event.question.questionId); + if (!exists) { + next.questionsBySession[sid] = [...list, event.question]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'questionAnswered': + case 'questionDismissed': + case 'questionExpired': { + const sid = event.sessionId; + const qid = event.questionId; + const list = next.questionsBySession[sid] ?? []; + next.questionsBySession[sid] = list.filter((q) => q.questionId !== qid); + break; + } + + // ------------------------------------------------------------------------- + case 'taskCreated': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + const idx = list.findIndex((t) => t.id === event.task.id); + if (idx === -1) { + next.tasksBySession[sid] = [...list, event.task]; + } else { + const patched = [...list]; + patched[idx] = event.task; + next.tasksBySession[sid] = patched; + } + break; + } + + // ------------------------------------------------------------------------- + case 'taskProgress': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + next.tasksBySession[sid] = list.map((t) => { + if (t.id !== event.taskId) return t; + return { + ...t, + outputLines: [...(t.outputLines ?? []), event.outputChunk], + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'taskCompleted': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + next.tasksBySession[sid] = list.map((t) => { + if (t.id !== event.taskId) return t; + return { + ...t, + status: event.status, + outputPreview: event.outputPreview, + outputBytes: event.outputBytes, + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'unknown': { + // Distinguish no-op known events (sentinel _noop) from agent errors/warnings + // and truly unknown events. + const raw = event.raw as { + _noop?: boolean; + _agentError?: boolean; + _agentWarning?: boolean; + code?: string; + message?: string; + type?: string; + } | null; + if (raw && raw._noop === true) { + // No-op streaming/tool event — seq already advanced, nothing else to do + } else if (raw && (raw._agentError || raw._agentWarning)) { + // Surface the agent's real error/warning message (e.g. a 403 from the + // model provider) instead of a useless "Unhandled event". + const label = raw._agentError + ? i18n.global.t('warnings.errorLabel') + : i18n.global.t('warnings.noteLabel'); + const msg = raw.message ?? raw.code ?? 'agent error'; + next.warnings = [...next.warnings, `${label}: ${msg}`]; + } else { + // Truly unknown — push a warning + const wireType = raw?.type ?? '(unknown)'; + next.warnings = [...next.warnings, `Unhandled event: ${wireType}`]; + } + break; + } + + default: { + // TypeScript exhaustiveness guard — should not reach here + const _exhaustive: never = event; + void _exhaustive; + break; + } + } + + return next; +} diff --git a/apps/kimi-web/src/api/daemon/http.ts b/apps/kimi-web/src/api/daemon/http.ts new file mode 100644 index 000000000..6384b7fcf --- /dev/null +++ b/apps/kimi-web/src/api/daemon/http.ts @@ -0,0 +1,270 @@ +// apps/kimi-web/src/api/daemon/http.ts +// DaemonHttpClient — REST transport with envelope unwrap and allowCodes support. + +import { buildRestUrl } from '../config'; +import { DaemonApiError, DaemonNetworkError } from '../errors'; +import { traceRestFailure, traceRestRequest, traceRestResponse } from '../../debug/trace'; +import type { WireEnvelope } from './wire'; + +/** Per-request timeout. Without one, a hung connection (half-open TCP after a + network change, stuck daemon) leaves promises pending for minutes — and the + composer's in-flight flag with them. Generous enough for slow endpoints; + streaming runs over the WS, not these REST calls. */ +const REQUEST_TIMEOUT_MS = 30_000; +const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const BODY_PREVIEW_LIMIT = 500; + +/** AbortSignal.timeout with a fallback for older environments (jsdom). */ +function timeoutSignal(): AbortSignal | undefined { + try { + return AbortSignal.timeout(REQUEST_TIMEOUT_MS); + } catch { + return undefined; + } +} + +function encodeBase32(value: number, length: number): string { + let out = ''; + let next = value; + for (let i = 0; i < length; i++) { + out = ULID_ALPHABET[next % 32] + out; + next = Math.floor(next / 32); + } + return out; +} + +function randomBase32(length: number): string { + const bytes = new Uint8Array(length); + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + return Array.from(bytes, (byte) => ULID_ALPHABET[byte % 32]).join(''); +} + +function createRequestId(): string { + return `${encodeBase32(Date.now(), 10)}${randomBase32(16)}`; +} + +/** Trace-only FormData summary: field names + file name/size/type, never content. */ +function describeFormData(formData: FormData): unknown { + try { + const fields: Array> = []; + formData.forEach((value, field) => { + if (typeof value === 'string') { + fields.push({ field, value }); + } else { + fields.push({ field, file: value.name, size: value.size, type: value.type }); + } + }); + return { formData: fields }; + } catch { + return '[FormData]'; + } +} + +async function readResponsePreview(response: Response): Promise { + try { + const text = await response.text(); + if (!text) return undefined; + return text.length > BODY_PREVIEW_LIMIT ? `${text.slice(0, BODY_PREVIEW_LIMIT)}...` : text; + } catch { + return undefined; + } +} + +export class DaemonHttpClient { + constructor(private readonly origin: string) {} + + async get(path: string, query?: Record): Promise { + return this.request('GET', path, undefined, query); + } + + async post(path: string, body?: unknown, opts?: { allowCodes?: number[] }): Promise { + return this.request('POST', path, body, undefined, opts?.allowCodes); + } + + /** Send multipart/form-data (FormData). Does NOT set Content-Type — browser sets it with boundary. */ + async postForm(path: string, formData: FormData): Promise { + const url = buildRestUrl(this.origin, path); + const requestId = createRequestId(); + const headers: Record = { + 'X-Request-Id': requestId, + }; + const startedAt = Date.now(); + traceRestRequest({ method: 'POST', path, url, requestId, body: describeFormData(formData) }); + let response: Response; + try { + response = await fetch(url, { method: 'POST', headers, body: formData, signal: timeoutSignal() }); + } catch (err) { + traceRestFailure({ method: 'POST', path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err }); + throw new DaemonNetworkError({ + message: `Network error calling POST ${path}`, + cause: err, + method: 'POST', + path, + url, + requestId, + phase: 'fetch', + timeoutMs: REQUEST_TIMEOUT_MS, + }); + } + let envelope: WireEnvelope; + const responseForDiagnostics = response.clone(); + try { + envelope = (await response.json()) as WireEnvelope; + } catch (err) { + traceRestFailure({ method: 'POST', path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err }); + throw new DaemonNetworkError({ + message: `Failed to parse JSON response from POST ${path}`, + cause: err, + method: 'POST', + path, + url, + requestId, + phase: 'parse', + timeoutMs: REQUEST_TIMEOUT_MS, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type') ?? undefined, + bodyPreview: await readResponsePreview(responseForDiagnostics), + }); + } + traceRestResponse({ + method: 'POST', + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code: envelope.code, + msg: envelope.msg, + envelopeRequestId: envelope.request_id, + data: envelope.data, + }); + if (envelope.code !== 0) { + throw new DaemonApiError({ + code: envelope.code, + msg: envelope.msg, + requestId: envelope.request_id, + details: envelope.details, + }); + } + return envelope.data as T; + } + + async patch(path: string, body: unknown): Promise { + return this.request('PATCH', path, body); + } + + async delete(path: string): Promise { + return this.request('DELETE', path); + } + + private async request( + method: string, + path: string, + body?: unknown, + query?: Record, + allowCodes: number[] = [], + ): Promise { + // Build URL, appending query string (omit undefined values) + let url = buildRestUrl(this.origin, path); + if (query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + const qs = params.toString(); + if (qs) url = `${url}?${qs}`; + } + + // Build headers + const requestId = createRequestId(); + const headers: Record = { + 'X-Request-Id': requestId, + }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json; charset=utf-8'; + } + + const startedAt = Date.now(); + traceRestRequest({ method, path, url, requestId, body }); + + // Execute fetch + let response: Response; + try { + response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: timeoutSignal(), + }); + } catch (err) { + traceRestFailure({ method, path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err }); + throw new DaemonNetworkError({ + message: `Network error calling ${method} ${path}`, + cause: err, + method, + path, + url, + requestId, + phase: 'fetch', + timeoutMs: REQUEST_TIMEOUT_MS, + }); + } + + // Parse envelope + let envelope: WireEnvelope; + const responseForDiagnostics = response.clone(); + try { + envelope = (await response.json()) as WireEnvelope; + } catch (err) { + traceRestFailure({ method, path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err }); + throw new DaemonNetworkError({ + message: `Failed to parse JSON response from ${method} ${path}`, + cause: err, + method, + path, + url, + requestId, + phase: 'parse', + timeoutMs: REQUEST_TIMEOUT_MS, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type') ?? undefined, + bodyPreview: await readResponsePreview(responseForDiagnostics), + }); + } + + traceRestResponse({ + method, + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code: envelope.code, + msg: envelope.msg, + envelopeRequestId: envelope.request_id, + data: envelope.data, + }); + + // Unwrap: code 0 = success; allowed non-zero = return data; else throw + if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) { + throw new DaemonApiError({ + code: envelope.code, + msg: envelope.msg, + requestId: envelope.request_id, + details: envelope.details, + }); + } + + // For both code=0 and allowed non-zero codes, return the data field. + // Callers that pass allowCodes handle the null/non-null data themselves. + return envelope.data as T; + } +} diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts new file mode 100644 index 000000000..8b077193f --- /dev/null +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -0,0 +1,607 @@ +// apps/kimi-web/src/api/daemon/mappers.ts +// wire→app and app→wire mapper functions. +// All snake_case ↔ camelCase conversion happens ONLY here. + +import type { + AppApprovalRequest, + AppEvent, + AppModel, + AppProvider, + FsEntry, + AppMessage, + AppMessageContent, + AppMessageRole, + AppQuestionRequest, + AppSession, + AppSessionStatus, + AppSessionUsage, + AppTask, + AppTaskStatus, + AppWorkspace, + ApprovalResponse, + ImageSource, + PromptSubmission, + QuestionAnswer, + QuestionItem, + QuestionOption, + QuestionResponse, +} from '../types'; + +import type { + WireApprovalRequest, + WireApprovalResponse, + WireBackgroundTask, + WireFsEntry, + WireImageSource, + WireMessage, + WireMessageContent, + WireModel, + WirePromptSubmission, + WireProvider, + WireQuestionAnswer, + WireQuestionItem, + WireQuestionOption, + WireQuestionRequest, + WireQuestionResponse, + WireSession, + WireSessionStatus, + WireSessionUsage, + WireWorkspace, + WireEvent, +} from './wire'; + +// --------------------------------------------------------------------------- +// Session mappers +// --------------------------------------------------------------------------- + +export function toAppSessionUsage(wire: WireSessionUsage): AppSessionUsage { + return { + inputTokens: wire.input_tokens, + outputTokens: wire.output_tokens, + cacheReadTokens: wire.cache_read_tokens, + cacheCreationTokens: wire.cache_creation_tokens, + totalCostUsd: wire.total_cost_usd, + contextTokens: wire.context_tokens, + contextLimit: wire.context_limit, + turnCount: wire.turn_count, + }; +} + +export function toAppSessionStatus(wire: WireSessionStatus): AppSessionStatus { + switch (wire) { + case 'idle': return 'idle'; + case 'running': return 'running'; + case 'awaiting_approval': return 'awaitingApproval'; + case 'awaiting_question': return 'awaitingQuestion'; + case 'aborted': return 'aborted'; + } +} + +export function toWireSessionStatus(status: AppSessionStatus): WireSessionStatus { + switch (status) { + case 'idle': return 'idle'; + case 'running': return 'running'; + case 'awaitingApproval': return 'awaiting_approval'; + case 'awaitingQuestion': return 'awaiting_question'; + case 'aborted': return 'aborted'; + } +} + +export function toAppSession(wire: WireSession): AppSession { + return { + id: wire.id, + title: wire.title, + createdAt: wire.created_at, + updatedAt: wire.updated_at, + status: toAppSessionStatus(wire.status), + currentPromptId: wire.current_prompt_id, + cwd: wire.metadata.cwd, + model: wire.agent_config.model, + usage: toAppSessionUsage(wire.usage), + messageCount: wire.message_count, + lastSeq: wire.last_seq, + workspaceId: wire.workspace_id, + }; +} + +export function toAppWorkspace(wire: WireWorkspace): AppWorkspace { + return { + id: wire.id, + root: wire.root, + name: wire.name, + isGitRepo: wire.is_git_repo, + branch: wire.branch ?? undefined, + lastOpenedAt: wire.last_opened_at, + sessionCount: wire.session_count, + }; +} + +// --------------------------------------------------------------------------- +// Message mappers +// --------------------------------------------------------------------------- + +function toAppImageSource(src: WireImageSource): ImageSource { + if (src.kind === 'base64') { + return { kind: 'base64', mediaType: src.media_type, data: src.data }; + } + if (src.kind === 'file') { + return { kind: 'file', fileId: src.file_id }; + } + return { kind: 'url', url: src.url }; +} + +export function toAppMessageContent(wire: WireMessageContent): AppMessageContent { + switch (wire.type) { + case 'text': + return { type: 'text', text: wire.text }; + case 'tool_use': + return { + type: 'toolUse', + toolCallId: wire.tool_call_id, + toolName: wire.tool_name, + input: wire.input, + }; + case 'tool_result': + return { + type: 'toolResult', + toolCallId: wire.tool_call_id, + output: wire.output, + isError: wire.is_error, + }; + case 'image': + return { + type: 'image', + source: toAppImageSource(wire.source), + }; + case 'file': + return { + type: 'file', + fileId: wire.file_id, + name: wire.name, + mediaType: wire.media_type, + size: wire.size, + }; + case 'thinking': + return { + type: 'thinking', + thinking: wire.thinking, + signature: wire.signature, + }; + default: { + // Unknown content type — pass raw through + return { type: 'unknown', raw: wire }; + } + } +} + +export function toAppMessage(wire: WireMessage): AppMessage { + return { + id: wire.id, + sessionId: wire.session_id, + role: wire.role as AppMessageRole, + content: wire.content.map(toAppMessageContent), + createdAt: wire.created_at, + promptId: wire.prompt_id, + parentMessageId: wire.parent_message_id, + metadata: wire.metadata, + }; +} + +// --------------------------------------------------------------------------- +// Prompt mappers +// --------------------------------------------------------------------------- + +function toWireMessageContent(app: AppMessageContent): WireMessageContent { + switch (app.type) { + case 'text': + return { type: 'text', text: app.text }; + case 'toolUse': + return { + type: 'tool_use', + tool_call_id: app.toolCallId, + tool_name: app.toolName, + input: app.input, + }; + case 'toolResult': + return { + type: 'tool_result', + tool_call_id: app.toolCallId, + output: app.output, + is_error: app.isError, + }; + case 'image': { + const src = app.source; + let wireSrc: WireImageSource; + if (src.kind === 'base64') { + wireSrc = { kind: 'base64', media_type: src.mediaType, data: src.data }; + } else if (src.kind === 'file') { + wireSrc = { kind: 'file', file_id: src.fileId }; + } else { + wireSrc = { kind: 'url', url: src.url }; + } + return { type: 'image', source: wireSrc }; + } + case 'file': + return { + type: 'file', + file_id: app.fileId, + name: app.name, + media_type: app.mediaType, + size: app.size, + }; + case 'thinking': + return { type: 'thinking', thinking: app.thinking, signature: app.signature }; + case 'unknown': + // Best-effort: pass raw back. May not be a valid WireMessageContent. + return app.raw as WireMessageContent; + } +} + +export function toWirePromptSubmission(input: PromptSubmission): WirePromptSubmission { + return { + content: input.content.map(toWireMessageContent), + metadata: input.metadata, + model: input.model, + thinking: input.thinking, + permission_mode: input.permissionMode, + plan_mode: input.planMode, + }; +} + +// --------------------------------------------------------------------------- +// Approval mappers +// --------------------------------------------------------------------------- + +export function toWireApprovalResponse(input: ApprovalResponse): WireApprovalResponse { + return { + decision: input.decision, + scope: input.scope, + feedback: input.feedback, + selected_label: input.selectedLabel, + }; +} + +export function toAppApprovalRequest(wire: WireApprovalRequest): AppApprovalRequest { + return { + approvalId: wire.approval_id, + sessionId: wire.session_id, + turnId: wire.turn_id, + toolCallId: wire.tool_call_id, + toolName: wire.tool_name, + action: wire.action, + // The real daemon sends `tool_input_display`; the stub sends `display`. + display: wire.tool_input_display ?? wire.display, + expiresAt: wire.expires_at, + createdAt: wire.created_at, + }; +} + +// --------------------------------------------------------------------------- +// Question mappers +// --------------------------------------------------------------------------- + +function toAppQuestionOption(wire: WireQuestionOption): QuestionOption { + return { + id: wire.id, + label: wire.label, + description: wire.description, + }; +} + +function toAppQuestionItem(wire: WireQuestionItem): QuestionItem { + return { + id: wire.id, + question: wire.question, + header: wire.header, + body: wire.body, + options: wire.options.map(toAppQuestionOption), + multiSelect: wire.multi_select, + allowOther: wire.allow_other, + otherLabel: wire.other_label, + otherDescription: wire.other_description, + }; +} + +export function toAppQuestionRequest(wire: WireQuestionRequest): AppQuestionRequest { + return { + questionId: wire.question_id, + sessionId: wire.session_id, + turnId: wire.turn_id, + toolCallId: wire.tool_call_id, + questions: wire.questions.map(toAppQuestionItem), + expiresAt: wire.expires_at, + createdAt: wire.created_at, + }; +} + +function toWireQuestionAnswer(app: QuestionAnswer): WireQuestionAnswer { + switch (app.kind) { + case 'single': + return { kind: 'single', option_id: app.optionId }; + case 'multi': + return { kind: 'multi', option_ids: app.optionIds }; + case 'other': + return { kind: 'other', text: app.text }; + case 'multiWithOther': + return { kind: 'multi_with_other', option_ids: app.optionIds, other_text: app.otherText }; + case 'skipped': + return { kind: 'skipped' }; + } +} + +export function toWireQuestionResponse(input: QuestionResponse): WireQuestionResponse { + const wireAnswers: Record = {}; + for (const [questionId, answer] of Object.entries(input.answers)) { + wireAnswers[questionId] = toWireQuestionAnswer(answer); + } + return { + answers: wireAnswers, + method: input.method, + note: input.note, + }; +} + +// --------------------------------------------------------------------------- +// Task mapper +// --------------------------------------------------------------------------- + +export function toAppTask(wire: WireBackgroundTask): AppTask { + return { + id: wire.id, + sessionId: wire.session_id, + kind: wire.kind, + description: wire.description, + status: wire.status as AppTaskStatus, + createdAt: wire.created_at, + startedAt: wire.started_at, + completedAt: wire.completed_at, + outputPreview: wire.output_preview, + outputBytes: wire.output_bytes, + // outputLines starts undefined; populated by eventReducer via task.progress events + }; +} + +// --------------------------------------------------------------------------- +// FsEntry mapper +// --------------------------------------------------------------------------- + +export function toAppFsEntry(wire: WireFsEntry): FsEntry { + return { + path: wire.path, + name: wire.name, + kind: wire.kind, + size: wire.size, + modifiedAt: wire.modified_at, + etag: wire.etag, + mime: wire.mime, + languageId: wire.language_id, + isBinary: wire.is_binary, + isSymlinkTo: wire.is_symlink_to, + gitStatus: wire.git_status, + childCount: wire.child_count, + }; +} + +// --------------------------------------------------------------------------- +// WireEvent → AppEvent +// --------------------------------------------------------------------------- + +/** + * Map a WireEvent to an AppEvent. + * + * Decision: reducer consumes AppEvent. + * - Visible events are fully mapped to their camelCase AppEvent variant. + * - No-op-but-known streaming/tool events (tool.*, assistant.tool_use_*, + * assistant.completed) are folded to { type: 'unknown', raw } so the reducer + * can advance lastSeqBySession without emitting warnings. + * We use a dedicated sentinel raw: { _noop: true } so Task 7 reducer can + * distinguish real unknowns (push warning) from no-op knowns (silent advance). + * - Truly unknown events are also { type: 'unknown', raw } but raw._noop is absent. + */ +export function toAppEvent(wire: WireEvent): AppEvent { + // TypeScript cannot narrow the WireEvent union through specific `case` arms + // because the catch-all `WireEventUnknown` member has `type: string` (broad) + // and `payload: unknown`, which prevents discriminated-union narrowing. + // We cast to `any` once here; individual cases are still logically type-safe + // because the union member types document the actual payload shapes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = wire as any; + switch ((wire as { type: string }).type) { + // ----- Session lifecycle ----- + case 'event.session.created': + return { type: 'sessionCreated', session: toAppSession(w.payload.session) }; + + case 'event.session.updated': + return { + type: 'sessionUpdated', + session: toAppSession(w.payload.session), + changedFields: w.payload.changed_fields, + }; + + case 'event.session.deleted': + return { type: 'sessionDeleted', sessionId: w.session_id }; + + case 'event.session.status_changed': + return { + type: 'sessionStatusChanged', + sessionId: w.session_id, + status: toAppSessionStatus(w.payload.status), + previousStatus: toAppSessionStatus(w.payload.previous_status), + currentPromptId: w.payload.current_prompt_id, + }; + + case 'event.session.usage_updated': + return { + type: 'sessionUsageUpdated', + sessionId: w.session_id, + usage: toAppSessionUsage(w.payload.usage), + }; + + case 'event.session.history_compacted': + return { + type: 'historyCompacted', + sessionId: w.session_id, + beforeSeq: w.payload.before_seq, + reason: w.payload.reason, + summaryMessageId: w.payload.summary_message_id, + }; + + // ----- Message lifecycle ----- + case 'event.message.created': + return { type: 'messageCreated', message: toAppMessage(w.payload.message) }; + + case 'event.message.updated': + return { + type: 'messageUpdated', + sessionId: w.session_id, + messageId: w.payload.message_id, + content: w.payload.content.map(toAppMessageContent), + status: w.payload.status, + }; + + // ----- Assistant streaming ----- + case 'event.assistant.delta': + return { + type: 'assistantDelta', + sessionId: w.session_id, + messageId: w.payload.message_id, + contentIndex: w.payload.content_index, + delta: w.payload.delta, + }; + + // No-op streaming events — advance seq silently + case 'event.assistant.tool_use_started': + case 'event.assistant.tool_use_delta': + case 'event.assistant.tool_use_completed': + case 'event.assistant.completed': + case 'event.tool.started': + case 'event.tool.output': + case 'event.tool.progress': + case 'event.tool.completed': + return { type: 'unknown', raw: { _noop: true, _wireType: w.type } }; + + // ----- Approval ----- + case 'event.approval.requested': + return { + type: 'approvalRequested', + sessionId: w.session_id, + approval: toAppApprovalRequest(w.payload), + }; + + case 'event.approval.resolved': + return { + type: 'approvalResolved', + sessionId: w.session_id, + approvalId: w.payload.approval_id, + decision: w.payload.decision, + resolvedAt: w.payload.resolved_at, + }; + + case 'event.approval.expired': + return { + type: 'approvalExpired', + sessionId: w.session_id, + approvalId: w.payload.approval_id, + }; + + // ----- Question ----- + case 'event.question.requested': + return { + type: 'questionRequested', + sessionId: w.session_id, + question: toAppQuestionRequest(w.payload), + }; + + case 'event.question.answered': + return { + type: 'questionAnswered', + sessionId: w.session_id, + questionId: w.payload.question_id, + resolvedAt: w.payload.resolved_at, + }; + + case 'event.question.dismissed': + return { + type: 'questionDismissed', + sessionId: w.session_id, + questionId: w.payload.question_id, + dismissedAt: w.payload.dismissed_at, + }; + + case 'event.question.expired': + return { + type: 'questionExpired', + sessionId: w.session_id, + questionId: w.payload.question_id, + }; + + // ----- Background tasks ----- + case 'event.task.created': + return { + type: 'taskCreated', + sessionId: w.session_id, + task: toAppTask(w.payload.task), + }; + + case 'event.task.progress': + return { + type: 'taskProgress', + sessionId: w.session_id, + taskId: w.payload.task_id, + outputChunk: w.payload.output_chunk, + stream: w.payload.stream, + }; + + case 'event.task.completed': + return { + type: 'taskCompleted', + sessionId: w.session_id, + taskId: w.payload.task_id, + status: w.payload.status as AppTaskStatus, + outputPreview: w.payload.output_preview, + outputBytes: w.payload.output_bytes, + }; + + default: { + // Truly unknown event — record warning + return { type: 'unknown', raw: wire }; + } + } +} + +// --------------------------------------------------------------------------- +// Model + Provider mappers +// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. +// --------------------------------------------------------------------------- + +export function toAppModel(wire: WireModel): AppModel { + return { + id: wire.model, + provider: wire.provider, + model: wire.model, + displayName: wire.display_name, + maxContextSize: wire.max_context_size, + capabilities: wire.capabilities, + }; +} + +export function toAppProvider(wire: WireProvider): AppProvider { + return { + id: wire.id, + type: wire.type, + baseUrl: wire.base_url, + defaultModel: wire.default_model, + hasApiKey: wire.has_api_key, + status: wire.status, + models: wire.models, + }; +} + +// Helper to extract sessionId from a WireEvent (needed by reducer for lastSeq update) +export function wireEventSessionId(wire: WireEvent): string { + return wire.session_id; +} + +export function wireEventSeq(wire: WireEvent): number { + return wire.seq; +} diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts new file mode 100644 index 000000000..cd1897b6c --- /dev/null +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -0,0 +1,720 @@ +// apps/kimi-web/src/api/daemon/wire.ts +// Daemon wire DTOs — ALL fields stay snake_case as they appear on the wire. +// No camelCase conversions here; that is mappers.ts's job. + +// --------------------------------------------------------------------------- +// Envelope & Page +// --------------------------------------------------------------------------- + +export interface WireEnvelope { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} + +export interface WirePage { + items: T[]; + has_more: boolean; +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export type WireSessionStatus = + | 'idle' + | 'running' + | 'awaiting_approval' + | 'awaiting_question' + | 'aborted'; + +export interface WireSessionUsage { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + total_cost_usd: number; + context_tokens: number; + context_limit: number; + turn_count: number; +} + +export interface WireSessionUsageDelta { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + cost_usd: number; +} + +export interface WirePermissionRule { + id: string; + tool_name: string; + matcher?: { + kind: 'command_prefix' | 'path_glob' | 'exact_input' | 'always'; + value?: string; + }; + decision: 'approved'; + created_at: string; + created_by: 'user' | 'agent'; +} + +export interface WireSession { + id: string; + title: string; + created_at: string; + updated_at: string; + status: WireSessionStatus; + current_prompt_id?: string; + // PRESUMED — daemon adds this once it ships the workspace registry; until then + // it is absent and the client maps sessions by metadata.cwd === workspace.root. + workspace_id?: string; + metadata: { + cwd: string; + [key: string]: unknown; + }; + agent_config: { + model: string; + system_prompt?: string; + tools?: string[]; + mcp_servers?: string[]; + // Runtime controls — optional on read (the daemon may not backfill them; + // live values come from GET /sessions/{id}/status). + thinking?: string; + permission_mode?: string; + plan_mode?: boolean; + }; + usage: WireSessionUsage; + permission_rules: WirePermissionRule[]; + message_count: number; + last_seq: number; +} + +// GET /sessions/{id}/status — live runtime state, aligned with TUI /status. +export interface WireSessionRuntimeStatus { + model?: string; + thinking_level: string; + permission: string; + plan_mode: boolean; + context_tokens: number; + max_context_tokens: number; + context_usage: number; +} + +// --------------------------------------------------------------------------- +// Workspace + daemon folder browser wire DTOs +// PRESUMED — not in the live daemon yet; isolated here, swap when backend ships. +// --------------------------------------------------------------------------- + +export interface WireWorkspace { + id: string; + root: string; + name: string; + is_git_repo: boolean; + branch: string | null; + last_opened_at?: string; + session_count: number; +} + +export interface WireFsBrowseEntry { + name: string; + path: string; + is_dir: boolean; + is_git_repo: boolean; + branch?: string; +} + +export interface WireFsBrowseResult { + path: string; + parent: string | null; + entries: WireFsBrowseEntry[]; +} + +export interface WireFsHomeResult { + home: string; + recent_roots: string[]; +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +export type WireMessageContent = + | { type: 'text'; text: string } + | { type: 'tool_use'; tool_call_id: string; tool_name: string; input: unknown } + | { type: 'tool_result'; tool_call_id: string; output: unknown; is_error?: boolean } + | { type: 'image'; source: WireImageSource } + | { type: 'file'; file_id: string; name: string; media_type: string; size: number } + | { type: 'thinking'; thinking: string; signature?: string }; + +export type WireImageSource = + | { kind: 'url'; url: string } + | { kind: 'base64'; media_type: string; data: string } + | { kind: 'file'; file_id: string }; + +export interface WireMessage { + id: string; + session_id: string; + role: 'user' | 'assistant' | 'tool' | 'system'; + content: WireMessageContent[]; + created_at: string; + prompt_id?: string; + parent_message_id?: string; + metadata?: Record; +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +export interface WirePromptSubmission { + content: WireMessageContent[]; + metadata?: Record; + model?: string; + thinking?: string; + permission_mode?: string; + plan_mode?: boolean; +} + +export interface WirePromptSubmitResult { + prompt_id: string; + user_message_id: string; + /** 'running' = started immediately; 'queued' = parked behind the active prompt. */ + status?: 'running' | 'queued'; +} + +export interface WirePromptSteerResult { + steered: boolean; + prompt_ids: string[]; +} + +// --------------------------------------------------------------------------- +// Approval +// --------------------------------------------------------------------------- + +export interface WireApprovalRequest { + approval_id: string; + session_id: string; + turn_id?: number; + tool_call_id: string; + tool_name: string; + action: string; + /** ToolInputDisplay — 12 discriminated kinds; client falls back to generic. + The daemon protocol field is `tool_input_display` (protocol/approval.ts); + `display` is the stub daemon's older shape, kept for compatibility. */ + tool_input_display?: unknown; + display?: unknown; + expires_at: string; + created_at: string; +} + +export interface WireApprovalResponse { + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selected_label?: string; +} + +// --------------------------------------------------------------------------- +// Question +// --------------------------------------------------------------------------- + +export interface WireQuestionOption { + id: string; + label: string; + description?: string; +} + +export interface WireQuestionItem { + id: string; + question: string; + header?: string; + body?: string; + options: WireQuestionOption[]; + multi_select?: boolean; + allow_other?: boolean; + other_label?: string; + other_description?: string; +} + +export interface WireQuestionRequest { + question_id: string; + session_id: string; + turn_id?: number; + tool_call_id?: string; + questions: WireQuestionItem[]; + expires_at: string; + created_at: string; +} + +export type WireQuestionAnswer = + | { kind: 'single'; option_id: string } + | { kind: 'multi'; option_ids: string[] } + | { kind: 'other'; text: string } + | { kind: 'multi_with_other'; option_ids: string[]; other_text: string } + | { kind: 'skipped' }; + +export interface WireQuestionResponse { + answers: Record; + method?: 'enter' | 'space' | 'number_key' | 'click'; + note?: string; +} + +// --------------------------------------------------------------------------- +// Background Task +// --------------------------------------------------------------------------- + +export type WireTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface WireBackgroundTask { + id: string; + session_id: string; + kind: 'subagent' | 'bash' | 'tool'; + description: string; + status: WireTaskStatus; + created_at: string; + started_at?: string; + completed_at?: string; + output_preview?: string; + output_bytes?: number; +} + +// --------------------------------------------------------------------------- +// File System +// --------------------------------------------------------------------------- + +export type WireFsKind = 'file' | 'directory' | 'symlink'; + +export interface WireFsEntry { + path: string; + name: string; + kind: WireFsKind; + size?: number; + modified_at: string; + etag?: string; + mime?: string; + language_id?: string; + is_binary?: boolean; + is_symlink_to?: string; + git_status?: string; + child_count?: number; +} + +// --------------------------------------------------------------------------- +// Model + Provider wire DTOs +// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. +// --------------------------------------------------------------------------- + +export interface WireModel { + provider: string; + model: string; + display_name?: string; + max_context_size: number; + capabilities?: string[]; +} + +export interface WireProvider { + id: string; + type: string; + base_url?: string; + default_model?: string; + has_api_key: boolean; + status: 'connected' | 'error' | 'unconfigured'; + models?: string[]; +} + +// --------------------------------------------------------------------------- +// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout) +// --------------------------------------------------------------------------- + +export interface WireManagedProvider { + status: string; + [key: string]: unknown; +} + +export interface WireAuthResult { + ready: boolean; + providers_count: number; + default_model: string | null; + managed_provider: WireManagedProvider | null; +} + +export interface WireOAuthLoginStartResult { + flow_id: string; + provider: string; + verification_uri: string; + verification_uri_complete: string; + user_code: string; + expires_in: number; + interval: number; + status: 'pending'; + expires_at: string; +} + +export interface WireOAuthLoginPollResult { + flow_id: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolved_at?: string; +} + +export interface WireOAuthCancelResult { + cancelled: boolean; + status: string; +} + +export interface WireLogoutResult { + logged_out: boolean; +} + +// --------------------------------------------------------------------------- +// File upload wire DTOs +// --------------------------------------------------------------------------- + +export interface WireFileMeta { + id: string; + name: string; + media_type: string; + size: number; + created_at: string; + expires_at?: string; +} + +// --------------------------------------------------------------------------- +// WS Server frames (S→C) +// --------------------------------------------------------------------------- + +/** All typed server-to-client WS frames */ +export type WireServerFrame = + | WireServerHello + | WireAck + | WirePing + | WireResyncRequired + | WireErrorFrame + | WireEvent; + +export interface WireServerHello { + type: 'server_hello'; + timestamp: string; + payload: { + server_id: string; + heartbeat_ms: number; + max_event_buffer_size: number; + capabilities: { + event_batching: boolean; + compression: boolean; + }; + }; +} + +export interface WireAck { + type: 'ack'; + id: string; + code: number; + msg: string; + payload: unknown; +} + +export interface WirePing { + type: 'ping'; + timestamp: string; + payload: { nonce: string }; +} + +export interface WireResyncRequired { + type: 'resync_required'; + timestamp: string; + payload: { + session_id: string; + reason: 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; + current_seq: number; + /** Current journal epoch — adopt it after resyncing (v2 sync protocol). */ + epoch?: string; + }; +} + +// --------------------------------------------------------------------------- +// v2 sync protocol: cursors + session snapshot +// --------------------------------------------------------------------------- + +/** Per-session sync cursor: durable seq + journal epoch. */ +export interface WireSessionCursor { + seq: number; + epoch?: string; +} + +export interface WireInFlightToolCall { + tool_call_id: string; + name: string; + args?: unknown; + description?: string; + display?: unknown; + last_progress?: { + kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + text?: string; + percent?: number; + }; +} + +export interface WireInFlightTurn { + turn_id: number; + assistant_text: string; + thinking_text: string; + running_tools: WireInFlightToolCall[]; +} + +/** `GET /sessions/{sid}/snapshot` — atomic rebuild state at a watermark. */ +export interface WireSessionSnapshot { + as_of_seq: number; + epoch: string; + session: WireSession; + messages: { items: WireMessage[]; has_more: boolean }; + in_flight_turn: WireInFlightTurn | null; + pending_approvals: WireApprovalRequest[]; + pending_questions: WireQuestionRequest[]; +} + +export interface WireErrorFrame { + type: 'error'; + timestamp: string; + payload: { + code: number; + msg: string; + fatal: boolean; + request_id?: string; + details?: unknown; + }; +} + +// --------------------------------------------------------------------------- +// WS Client control messages (C→S) +// --------------------------------------------------------------------------- + +export type WireClientControl = + | WireClientHello + | WireSubscribe + | WireUnsubscribe + | WireAbort + | WirePong; + +export interface WireClientHello { + type: 'client_hello'; + id: string; + payload: { + client_id: string; + subscriptions: string[]; + cursors?: Record; + }; +} + +export interface WireSubscribe { + type: 'subscribe'; + id: string; + payload: { + session_ids: string[]; + cursors?: Record; + }; +} + +export interface WireUnsubscribe { + type: 'unsubscribe'; + id: string; + payload: { session_ids: string[] }; +} + +export interface WireAbort { + type: 'abort'; + id: string; + payload: { + session_id: string; + prompt_id: string; + }; +} + +export interface WirePong { + type: 'pong'; + payload: { nonce: string }; +} + +// --------------------------------------------------------------------------- +// WS Events (S→C) — all type: "event.*" +// --------------------------------------------------------------------------- + +/** Base shape for all WS event frames */ +interface WireEventBase { + type: T; + seq: number; + session_id: string; + timestamp: string; + payload: P; +} + +// Session lifecycle +type WireEventSessionCreated = WireEventBase<'event.session.created', { session: WireSession }>; +type WireEventSessionUpdated = WireEventBase<'event.session.updated', { session: WireSession; changed_fields: string[] }>; +type WireEventSessionDeleted = WireEventBase<'event.session.deleted', { session_id: string }>; +type WireEventSessionStatusChanged = WireEventBase<'event.session.status_changed', { + status: WireSessionStatus; + previous_status: WireSessionStatus; + current_prompt_id?: string; +}>; +type WireEventSessionUsageUpdated = WireEventBase<'event.session.usage_updated', { + usage: WireSessionUsage; + delta: WireSessionUsageDelta; +}>; +type WireEventSessionHistoryCompacted = WireEventBase<'event.session.history_compacted', { + before_seq: number; + reason: 'auto_compact' | 'manual_compact' | 'history_rewrite'; + summary_message_id?: string; +}>; + +// Message lifecycle +type WireEventMessageCreated = WireEventBase<'event.message.created', { message: WireMessage }>; +type WireEventMessageUpdated = WireEventBase<'event.message.updated', { + message_id: string; + content: WireMessageContent[]; + status: 'pending' | 'completed' | 'error'; +}>; + +// Assistant streaming +type WireEventAssistantDelta = WireEventBase<'event.assistant.delta', { + message_id: string; + content_index: number; + delta: { text?: string; thinking?: string }; +}>; +// No-op-but-known streaming events (advance lastSeq, no UI change) +type WireEventAssistantToolUseStarted = WireEventBase<'event.assistant.tool_use_started', { + message_id: string; + tool_call_id: string; + tool_name: string; + content_index: number; +}>; +type WireEventAssistantToolUseDelta = WireEventBase<'event.assistant.tool_use_delta', { + message_id: string; + tool_call_id: string; + input_delta: string; +}>; +type WireEventAssistantToolUseCompleted = WireEventBase<'event.assistant.tool_use_completed', { + message_id: string; + tool_call_id: string; + input: unknown; +}>; +type WireEventAssistantCompleted = WireEventBase<'event.assistant.completed', { + message_id: string; + finish_reason: 'stop' | 'tool_use' | 'length' | 'cancelled' | 'error'; +}>; + +// Tool execution (no-op-but-known) +type WireEventToolStarted = WireEventBase<'event.tool.started', { + tool_call_id: string; + tool_name: string; + input: unknown; + parent_message_id: string; +}>; +type WireEventToolOutput = WireEventBase<'event.tool.output', { + tool_call_id: string; + chunk: string; + stream: 'stdout' | 'stderr'; +}>; +type WireEventToolProgress = WireEventBase<'event.tool.progress', { + tool_call_id: string; + progress: number; + message?: string; +}>; +type WireEventToolCompleted = WireEventBase<'event.tool.completed', { + tool_call_id: string; + output: unknown; + is_error: boolean; + duration_ms: number; +}>; + +// Approval +type WireEventApprovalRequested = WireEventBase<'event.approval.requested', WireApprovalRequest>; +type WireEventApprovalResolved = WireEventBase<'event.approval.resolved', { + approval_id: string; + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selected_label?: string; + resolved_by: string; + resolved_at: string; +}>; +type WireEventApprovalExpired = WireEventBase<'event.approval.expired', { approval_id: string }>; + +// Question +type WireEventQuestionRequested = WireEventBase<'event.question.requested', WireQuestionRequest>; +type WireEventQuestionAnswered = WireEventBase<'event.question.answered', { + question_id: string; + answers: Record; + method?: string; + note?: string; + resolved_by: string; + resolved_at: string; +}>; +type WireEventQuestionDismissed = WireEventBase<'event.question.dismissed', { + question_id: string; + dismissed_by: string; + dismissed_at: string; +}>; +type WireEventQuestionExpired = WireEventBase<'event.question.expired', { question_id: string }>; + +// Background tasks +type WireEventTaskCreated = WireEventBase<'event.task.created', { task: WireBackgroundTask }>; +type WireEventTaskProgress = WireEventBase<'event.task.progress', { + task_id: string; + output_chunk: string; + stream: 'stdout' | 'stderr'; +}>; +type WireEventTaskCompleted = WireEventBase<'event.task.completed', { + task_id: string; + status: WireTaskStatus; + output_preview?: string; + output_bytes?: number; +}>; + +/** Catch-all for unrecognised event frames — keeps lastSeq advancing without warnings */ +type WireEventUnknown = { type: string; seq: number; session_id: string; timestamp: string; payload: unknown }; + +/** + * Union of all WS event frames the client will process. + * Visible events (UI updates) + no-op-but-known events (lastSeq only). + * The catch-all at the end handles future server events gracefully. + */ +export type WireEvent = + // Session lifecycle + | WireEventSessionCreated + | WireEventSessionUpdated + | WireEventSessionDeleted + | WireEventSessionStatusChanged + | WireEventSessionUsageUpdated + | WireEventSessionHistoryCompacted + // Message lifecycle + | WireEventMessageCreated + | WireEventMessageUpdated + // Assistant streaming + | WireEventAssistantDelta + | WireEventAssistantToolUseStarted + | WireEventAssistantToolUseDelta + | WireEventAssistantToolUseCompleted + | WireEventAssistantCompleted + // Tool execution + | WireEventToolStarted + | WireEventToolOutput + | WireEventToolProgress + | WireEventToolCompleted + // Approval + | WireEventApprovalRequested + | WireEventApprovalResolved + | WireEventApprovalExpired + // Question + | WireEventQuestionRequested + | WireEventQuestionAnswered + | WireEventQuestionDismissed + | WireEventQuestionExpired + // Background tasks + | WireEventTaskCreated + | WireEventTaskProgress + | WireEventTaskCompleted + // Unknown / future events + | WireEventUnknown; diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts new file mode 100644 index 000000000..f7fbae85e --- /dev/null +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -0,0 +1,367 @@ +// apps/kimi-web/src/api/daemon/ws.ts +// DaemonEventSocket — browser WebSocket client for the daemon WS protocol. +// Handles: server_hello / client_hello handshake, subscribe/unsubscribe, +// ping/pong heartbeat, resync_required, error frames, event.* dispatch. + +import { traceWsIn, traceWsLifecycle, traceWsOut } from '../../debug/trace'; +import { classifyFrame } from './agentEventProjector'; +import type { WireEvent, WireServerFrame } from './wire'; + +// --------------------------------------------------------------------------- +// Handler interface +// --------------------------------------------------------------------------- + +export interface DaemonEventSocketHandlers { + /** Called for every event.* frame received */ + onWireEvent(event: WireEvent): void; + /** + * Called for raw agent-core frames (type does NOT start with "event." and + * is not a control frame). The full parsed frame object is passed so the + * caller can extract type / seq / session_id / timestamp / payload, plus + * the v2 envelope extras (volatile / offset). + */ + onRawAgentEvent?(frame: { + type: string; + seq: number; + session_id: string; + timestamp: string; + payload: unknown; + volatile?: boolean; + offset?: number; + }): void; + /** Called when server says client is out of sync for a session */ + onResync(sessionId: string, currentSeq: number, epoch?: string): void; + /** Called when the WS connection opens or closes */ + onConnectionState(connected: boolean): void; + /** Called on error frames or JSON parse failures */ + onError(code: number, msg: string, fatal: boolean): void; +} + +// --------------------------------------------------------------------------- +// DaemonEventSocket +// --------------------------------------------------------------------------- + +/** v2 sync cursor: durable seq + journal epoch. */ +export interface SessionCursor { + seq: number; + epoch?: string; +} + +interface PendingSubscription { + sessionId: string; + cursor: SessionCursor; +} + +export class DaemonEventSocket { + private ws: WebSocket | null = null; + private connected = false; + private closed = false; + + /** subscriptions we manage: sessionId → last known cursor {seq, epoch} */ + private readonly subscriptions = new Map(); + + /** subscriptions queued while not yet connected */ + private readonly pendingSubscriptions: PendingSubscription[] = []; + + private msgSeq = 0; + + /** Automatic reconnect (exponential backoff, reset on a successful hello). */ + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + + constructor( + private readonly wsUrl: string, + private readonly clientId: string, + private readonly handlers: DaemonEventSocketHandlers, + ) {} + + /** Open the WebSocket connection. No-op while one is open or after close(). */ + connect(): void { + if (this.ws !== null || this.closed) return; + + traceWsLifecycle('connect', { url: this.wsUrl, attempt: this.reconnectAttempts }); + const ws = new WebSocket(this.wsUrl); + this.ws = ws; + + ws.onopen = () => { + // Don't mark as connected yet — wait for server_hello + traceWsLifecycle('open'); + }; + + ws.onmessage = (ev: MessageEvent) => { + try { + const frame = JSON.parse(String(ev.data)) as WireServerFrame; + traceWsIn(frame); + this.handleFrame(frame); + } catch (err) { + traceWsLifecycle('parse-error', { error: String(err) }); + this.handlers.onError(0, `Failed to parse WS frame: ${String(err)}`, false); + } + }; + + ws.onerror = () => { + // The error details are not exposed by the browser WS API; the close + // event with a reason code follows immediately. + traceWsLifecycle('error'); + this.handlers.onError(0, 'WebSocket error', false); + }; + + ws.onclose = (ev?: CloseEvent) => { + traceWsLifecycle('close', ev ? { code: ev.code, reason: ev.reason, wasClean: ev.wasClean } : undefined); + this.connected = false; + this.ws = null; + this.handlers.onConnectionState(false); + // Unexpected drop (daemon restart, sleep, network blip) → reconnect. + // onServerHello re-sends every kept subscription via client_hello, and + // the server answers a too-large seq gap with resync_required, so live + // updates resume without a page reload. + this.scheduleReconnect(); + }; + } + + private scheduleReconnect(): void { + if (this.closed || this.reconnectTimer !== null) return; + const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts); + const delay = base + Math.floor(Math.random() * 250); // jitter + this.reconnectAttempts += 1; + traceWsLifecycle('reconnect-scheduled', { delayMs: delay, attempt: this.reconnectAttempts }); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + /** + * Subscribe to events for a session at a `{seq, epoch}` cursor. + * If connected, sends immediately; otherwise queues until after server_hello. + */ + subscribe(sessionId: string, cursor: SessionCursor = { seq: 0 }): void { + this.subscriptions.set(sessionId, { ...cursor }); + + if (this.connected) { + this.sendSubscribe([sessionId], { [sessionId]: cursor }); + } else { + // Remove any earlier pending entry for this session, then enqueue + const idx = this.pendingSubscriptions.findIndex((p) => p.sessionId === sessionId); + if (idx !== -1) this.pendingSubscriptions.splice(idx, 1); + this.pendingSubscriptions.push({ sessionId, cursor: { ...cursor } }); + } + } + + /** Unsubscribe from a session's events. */ + unsubscribe(sessionId: string): void { + this.subscriptions.delete(sessionId); + if (this.connected && this.ws) { + this.send({ + type: 'unsubscribe', + id: this.nextId(), + payload: { session_ids: [sessionId] }, + }); + } + } + + /** + * Send a WS abort control message for a prompt. + * (The REST :abort endpoint is the primary path; this is the WS path per spec.) + */ + abort(sessionId: string, promptId: string): void { + if (!this.connected || !this.ws) return; + this.send({ + type: 'abort', + id: this.nextId(), + payload: { session_id: sessionId, prompt_id: promptId }, + }); + } + + /** Close the socket. Stops reconnect attempts. */ + close(): void { + this.closed = true; + this.connected = false; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(1000); + this.ws = null; + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private handleFrame(rawFrame: WireServerFrame): void { + // WireServerFrame union contains WireAck (payload: unknown) which prevents + // TypeScript from narrowing .payload in each case arm. Cast once here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const frame = rawFrame as any; + switch ((rawFrame as { type: string }).type) { + case 'server_hello': + this.onServerHello(); + break; + + case 'ping': + this.send({ type: 'pong', payload: { nonce: frame.payload.nonce } }); + break; + + case 'resync_required': { + const sid = frame.payload.session_id as string; + const epoch = frame.payload.epoch as string | undefined; + // Adopt the announced cursor so the next reconnect handshake doesn't + // re-trigger the same resync before the snapshot reload lands. + this.subscriptions.set(sid, { seq: frame.payload.current_seq, epoch }); + this.handlers.onResync(sid, frame.payload.current_seq, epoch); + break; + } + + case 'error': { + // A session-scoped error (has top-level session_id) is a real agent-core + // 'error' event — e.g. a 403 from the model provider — whose message + // must surface in the conversation. A connection-level control error + // (no session_id) goes to onError. + const sid = (frame as { session_id?: unknown }).session_id; + if (typeof sid === 'string' && this.handlers.onRawAgentEvent) { + this.handlers.onRawAgentEvent({ + type: 'error', + seq: frame.seq, + session_id: sid, + timestamp: frame.timestamp, + payload: frame.payload, + }); + } else { + this.handlers.onError(frame.payload.code, frame.payload.msg, frame.payload.fatal); + } + break; + } + + case 'ack': + // ack frames are fire-and-forget for now (no request tracking) + break; + + default: { + // Track the per-session cursor from durable event envelopes so the + // reconnect handshake resumes from the freshest watermark. Volatile + // frames carry the same watermark (never ahead), so skipping them is + // safe and avoids regressing the cursor. + this.trackCursor(frame as Record); + + // Classify the frame into protocol vs agent-core. Robust to all three + // shapes: raw agent-core, "event."-prefixed agent-core, and genuine + // projected "event.*" protocol events. See classifyFrame() for rules. + const type = (frame as { type: string }).type; + const decision = classifyFrame(type, (frame as { payload?: unknown }).payload); + + if (decision.route === 'protocol') { + // Genuine projected protocol event → existing toAppEvent() path. + this.handlers.onWireEvent(frame as unknown as WireEvent); + break; + } + + if (decision.route === 'agent') { + // Raw (or prefix-stripped) agent-core event → client-side projector. + // We pass the prefix-stripped agentType so the projector matches its + // raw case arms regardless of whether the wire frame carried "event.". + if ( + this.handlers.onRawAgentEvent && + typeof (frame as { session_id?: unknown }).session_id === 'string' + ) { + const f = frame as { + seq: number; + session_id: string; + timestamp: string; + payload: unknown; + }; + const extras = frame as { volatile?: boolean; offset?: number }; + this.handlers.onRawAgentEvent({ + type: decision.agentType, + seq: f.seq, + session_id: f.session_id, + timestamp: f.timestamp, + payload: f.payload, + ...(extras.volatile !== undefined ? { volatile: extras.volatile } : {}), + ...(extras.offset !== undefined ? { offset: extras.offset } : {}), + }); + } + break; + } + + // decision.route === 'ignore' (control-shaped or unroutable) → drop. + break; + } + } + } + + private onServerHello(): void { + this.connected = true; + this.reconnectAttempts = 0; + this.handlers.onConnectionState(true); + + // Build the initial subscription list from current subscriptions + pending + const allSessionIds = Array.from(this.subscriptions.keys()); + // Drain pending: merge into subscriptions map (pending overrides if seq differs) + for (const p of this.pendingSubscriptions) { + this.subscriptions.set(p.sessionId, p.cursor); + if (!allSessionIds.includes(p.sessionId)) allSessionIds.push(p.sessionId); + } + this.pendingSubscriptions.length = 0; + + // Build cursors from subscriptions + const cursors: Record = {}; + for (const [sid, cursor] of this.subscriptions.entries()) { + cursors[sid] = cursor; + } + + this.send({ + type: 'client_hello', + id: this.nextId(), + payload: { + client_id: this.clientId, + subscriptions: allSessionIds, + cursors, + }, + }); + } + + private sendSubscribe(sessionIds: string[], cursors: Record): void { + this.send({ + type: 'subscribe', + id: this.nextId(), + payload: { + session_ids: sessionIds, + cursors, + }, + }); + } + + /** + * Advance the tracked cursor from a durable event envelope (seq + epoch). + * Volatile frames are skipped (their seq is the same watermark, and a + * volatile frame can never carry a NEWER seq than the last durable one). + */ + private trackCursor(frame: Record): void { + if (frame['volatile'] === true) return; + const sid = frame['session_id']; + const seq = frame['seq']; + if (typeof sid !== 'string' || typeof seq !== 'number') return; + const existing = this.subscriptions.get(sid); + if (!existing) return; // not a session we manage + if (seq <= existing.seq && existing.epoch !== undefined) return; + const epoch = typeof frame['epoch'] === 'string' ? (frame['epoch'] as string) : existing.epoch; + this.subscriptions.set(sid, { seq: Math.max(seq, existing.seq), epoch }); + } + + private send(msg: unknown): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + try { + this.ws.send(JSON.stringify(msg)); + traceWsOut(msg); + } catch { + // Ignore send errors (socket closing races) + } + } + + private nextId(): string { + return `c_${++this.msgSeq}`; + } +} diff --git a/apps/kimi-web/src/api/errors.ts b/apps/kimi-web/src/api/errors.ts new file mode 100644 index 000000000..b4c2bce9a --- /dev/null +++ b/apps/kimi-web/src/api/errors.ts @@ -0,0 +1,80 @@ +// apps/kimi-web/src/api/errors.ts +// DaemonApiError, DaemonNetworkError, and type guard. + +export class DaemonApiError extends Error { + readonly code: number; + readonly requestId: string; + readonly details: unknown; + + constructor(input: { code: number; msg: string; requestId: string; details?: unknown }) { + super(input.msg); + this.name = 'DaemonApiError'; + this.code = input.code; + this.requestId = input.requestId; + this.details = input.details; + } +} + +export class DaemonNetworkError extends Error { + readonly cause: unknown; + readonly method: string; + readonly path: string; + readonly url: string; + readonly requestId: string; + readonly phase: 'fetch' | 'parse'; + readonly timeoutMs: number; + readonly status?: number; + readonly statusText?: string; + readonly contentType?: string; + readonly bodyPreview?: string; + + constructor(input: { + message: string; + cause: unknown; + method: string; + path: string; + url: string; + requestId: string; + phase: 'fetch' | 'parse'; + timeoutMs: number; + status?: number; + statusText?: string; + contentType?: string; + bodyPreview?: string; + }) { + super(input.message); + this.name = 'DaemonNetworkError'; + this.cause = input.cause; + this.method = input.method; + this.path = input.path; + this.url = input.url; + this.requestId = input.requestId; + this.phase = input.phase; + this.timeoutMs = input.timeoutMs; + this.status = input.status; + this.statusText = input.statusText; + this.contentType = input.contentType; + this.bodyPreview = input.bodyPreview; + } +} + +export function isDaemonApiError(error: unknown): error is DaemonApiError { + return ( + error instanceof DaemonApiError || + (typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'DaemonApiError' && + typeof (error as { code?: unknown }).code === 'number') + ); +} + +export function isDaemonNetworkError(error: unknown): error is DaemonNetworkError { + return ( + error instanceof DaemonNetworkError || + (typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'DaemonNetworkError' && + typeof (error as { method?: unknown }).method === 'string' && + typeof (error as { path?: unknown }).path === 'string') + ); +} diff --git a/apps/kimi-web/src/api/index.ts b/apps/kimi-web/src/api/index.ts new file mode 100644 index 000000000..3e2dffa2e --- /dev/null +++ b/apps/kimi-web/src/api/index.ts @@ -0,0 +1,13 @@ +// apps/kimi-web/src/api/index.ts +// Singleton factory for the KimiWebApi daemon client. + +import { readKimiApiConfig } from './config'; +import type { KimiWebApi } from './types'; +import { DaemonKimiWebApi } from './daemon/client'; + +let singleton: KimiWebApi | undefined; + +export function getKimiWebApi(): KimiWebApi { + singleton ??= new DaemonKimiWebApi(readKimiApiConfig()); + return singleton; +} diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts new file mode 100644 index 000000000..6edf53f34 --- /dev/null +++ b/apps/kimi-web/src/api/types.ts @@ -0,0 +1,536 @@ +// apps/kimi-web/src/api/types.ts +// App-facing camelCase model + KimiWebApi interface. +// No daemon wire details here — Vue components consume only these types. + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +export interface Page { + items: T[]; + hasMore: boolean; +} + +export interface PageRequest { + beforeId?: string; + afterId?: string; + pageSize?: number; +} + +// --------------------------------------------------------------------------- +// Notices +// --------------------------------------------------------------------------- + +export type AppNoticeSeverity = 'info' | 'warning' | 'error'; + +export interface AppNoticeDetail { + label: string; + value: string; +} + +export interface AppNotice { + severity: AppNoticeSeverity; + title: string; + message?: string; + details?: AppNoticeDetail[]; +} + +export type AppWarning = string | AppNotice; + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export type AppSessionStatus = + | 'idle' + | 'running' + | 'awaitingApproval' + | 'awaitingQuestion' + | 'aborted'; + +export interface AppSessionUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalCostUsd: number; + contextTokens: number; + contextLimit: number; + turnCount: number; +} + +export interface AppSession { + id: string; + title: string; + createdAt: string; + updatedAt: string; + status: AppSessionStatus; + currentPromptId?: string; + cwd: string; + model: string; + usage: AppSessionUsage; + messageCount: number; + lastSeq: number; + /** + * The workspace this session belongs to. Present once the daemon ships the + * workspace registry (returns `workspace_id` on Session). Until then it is + * undefined and the composable maps sessions to workspaces by cwd === root. + */ + workspaceId?: string; +} + +/** + * Live runtime state from GET /sessions/{id}/status — the source of truth for + * the current model + context usage (Session.agent_config.model can be ""). + */ +export interface AppSessionRuntimeStatus { + /** Current model alias, or null if the daemon couldn't resolve it. */ + model: string | null; + thinkingLevel: string; + permission: string; + planMode: boolean; + contextTokens: number; + maxContextTokens: number; + contextUsage: number; +} + +// --------------------------------------------------------------------------- +// Workspace — a real folder the client organizes sessions by. +// 1 Workspace : N Sessions. A session inherits the workspace's root as its cwd. +// --------------------------------------------------------------------------- + +export interface AppWorkspace { + /** Stable id. In fallback mode (derived from session cwds) this IS the root. */ + id: string; + /** Absolute path to the project root. */ + root: string; + /** Display name — defaults to basename(root), may be renamed on the daemon. */ + name: string; + /** Whether root is inside a git repository. */ + isGitRepo: boolean; + /** Current branch, when known. */ + branch?: string; + /** ISO timestamp of when this workspace was last opened. */ + lastOpenedAt?: string; + /** Number of sessions belonging to this workspace. */ + sessionCount: number; +} + +/** One directory entry from the daemon folder browser (fs:browse). */ +export interface FsBrowseEntry { + name: string; + path: string; + isDir: boolean; + isGitRepo: boolean; + branch?: string; +} + +export interface FsBrowseResult { + path: string; + parent: string | null; + entries: FsBrowseEntry[]; +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +export type AppMessageRole = 'user' | 'assistant' | 'tool' | 'system'; + +export type AppMessageContent = + | { type: 'text'; text: string } + | { type: 'toolUse'; toolCallId: string; toolName: string; input: unknown } + | { type: 'toolResult'; toolCallId: string; output: unknown; isError?: boolean } + | { type: 'image'; source: ImageSource } + | { type: 'file'; fileId: string; name: string; mediaType: string; size: number } + | { type: 'thinking'; thinking: string; signature?: string } + | { type: 'unknown'; raw: unknown }; + +export type ImageSource = + | { kind: 'url'; url: string } + | { kind: 'base64'; mediaType: string; data: string } + | { kind: 'file'; fileId: string }; + +export interface AppMessage { + id: string; + sessionId: string; + role: AppMessageRole; + content: AppMessageContent[]; + createdAt: string; + promptId?: string; + parentMessageId?: string; + metadata?: Record; +} + +/** + * Metadata key of the client-side compaction marker message appended on + * compactionCompleted. The transcript keeps all prior messages (TUI parity); + * this marker renders as a "context compacted" divider. Snapshot-loaded + * summary messages (origin kind 'compaction_summary') render the same way + * but carry no token stats. + */ +export const COMPACTION_MARKER_METADATA_KEY = 'kimiWeb.compaction'; + +export interface CompactionMarkerMetadata { + trigger: 'manual' | 'auto'; + tokensBefore?: number; + tokensAfter?: number; +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + +export interface PromptSubmission { + content: AppMessageContent[]; + metadata?: Record; + /** The daemon requires these on every prompt (per-prompt, not session-level). */ + model?: string; + thinking?: ThinkingLevel; + permissionMode?: 'manual' | 'auto' | 'yolo'; + planMode?: boolean; +} + +export interface PromptSubmitResult { + promptId: string; + userMessageId: string; + /** 'running' when the prompt started a turn immediately; 'queued' when + another prompt is active and the daemon parked it (steerable). */ + status?: 'running' | 'queued'; +} + +// --------------------------------------------------------------------------- +// Approval +// --------------------------------------------------------------------------- + +export type ApprovalDecision = 'approved' | 'rejected' | 'cancelled'; + +export interface ApprovalResponse { + decision: ApprovalDecision; + scope?: 'session'; + feedback?: string; + selectedLabel?: string; +} + +export interface AppApprovalRequest { + approvalId: string; + sessionId: string; + turnId?: number; + toolCallId: string; + toolName: string; + action: string; + display: unknown; // ToolInputDisplay — Web renders what it knows, falls back to generic + expiresAt: string; + createdAt: string; +} + +// --------------------------------------------------------------------------- +// Question +// --------------------------------------------------------------------------- + +export interface QuestionOption { + id: string; + label: string; + description?: string; +} + +export interface QuestionItem { + id: string; + question: string; + header?: string; + body?: string; + options: QuestionOption[]; + multiSelect?: boolean; + allowOther?: boolean; + otherLabel?: string; + otherDescription?: string; +} + +export interface AppQuestionRequest { + questionId: string; + sessionId: string; + turnId?: number; + toolCallId?: string; + questions: QuestionItem[]; + expiresAt: string; + createdAt: string; +} + +export type QuestionAnswer = + | { kind: 'single'; optionId: string } + | { kind: 'multi'; optionIds: string[] } + | { kind: 'other'; text: string } + | { kind: 'multiWithOther'; optionIds: string[]; otherText: string } + | { kind: 'skipped' }; + +export interface QuestionResponse { + answers: Record; + method?: 'enter' | 'space' | 'number_key' | 'click'; + note?: string; +} + +// --------------------------------------------------------------------------- +// Background Task +// --------------------------------------------------------------------------- + +export type AppTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface AppTask { + id: string; + sessionId: string; + kind: 'subagent' | 'bash' | 'tool'; + description: string; + status: AppTaskStatus; + createdAt: string; + startedAt?: string; + completedAt?: string; + outputPreview?: string; + outputBytes?: number; + outputLines?: string[]; // accumulated by eventReducer from task.progress chunks +} + +// --------------------------------------------------------------------------- +// File System +// --------------------------------------------------------------------------- + +export type FsKind = 'file' | 'directory' | 'symlink'; + +export interface FsEntry { + path: string; + name: string; + kind: FsKind; + size?: number; + modifiedAt: string; + etag?: string; + mime?: string; + languageId?: string; + isBinary?: boolean; + isSymlinkTo?: string; + gitStatus?: string; + childCount?: number; +} + +// --------------------------------------------------------------------------- +// Events (app-facing, camelCase) +// --------------------------------------------------------------------------- + +export type AppEvent = + | { type: 'sessionCreated'; session: AppSession } + | { type: 'sessionUpdated'; session: AppSession; changedFields: string[] } + | { type: 'sessionDeleted'; sessionId: string } + | { type: 'sessionStatusChanged'; sessionId: string; status: AppSessionStatus; previousStatus: AppSessionStatus; currentPromptId?: string } + | { type: 'sessionMetaUpdated'; sessionId: string; title: string } + | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string } + | { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string } + | { type: 'compactionStarted'; sessionId: string; trigger: 'manual' | 'auto'; instruction?: string } + | { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number; summary?: string } + | { type: 'compactionCancelled'; sessionId: string } + | { type: 'messageCreated'; message: AppMessage } + | { type: 'messageUpdated'; sessionId: string; messageId: string; content: AppMessageContent[]; status: 'pending' | 'completed' | 'error' } + | { type: 'assistantDelta'; sessionId: string; messageId: string; contentIndex: number; delta: { text?: string; thinking?: string } } + | { type: 'approvalRequested'; sessionId: string; approval: AppApprovalRequest } + | { type: 'approvalResolved'; sessionId: string; approvalId: string; decision: ApprovalDecision; resolvedAt: string } + | { type: 'approvalExpired'; sessionId: string; approvalId: string } + | { type: 'questionRequested'; sessionId: string; question: AppQuestionRequest } + | { type: 'questionAnswered'; sessionId: string; questionId: string; resolvedAt: string } + | { type: 'questionDismissed'; sessionId: string; questionId: string; dismissedAt: string } + | { type: 'questionExpired'; sessionId: string; questionId: string } + | { type: 'taskCreated'; sessionId: string; task: AppTask } + | { type: 'taskProgress'; sessionId: string; taskId: string; outputChunk: string; stream: 'stdout' | 'stderr' } + | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } + | { type: 'unknown'; raw: unknown }; + +// --------------------------------------------------------------------------- +// WebSocket connection helpers +// --------------------------------------------------------------------------- + +/** Per-session sync cursor (v2): durable seq + journal epoch. */ +export interface AppSessionCursor { + seq: number; + epoch?: string; +} + +/** In-flight (mid-turn) state recovered from the session snapshot. */ +export interface AppInFlightToolCall { + toolCallId: string; + name: string; + args?: unknown; + description?: string; + lastProgress?: { kind: string; text?: string; percent?: number }; +} + +export interface AppInFlightTurn { + turnId: number; + assistantText: string; + thinkingText: string; + runningTools: AppInFlightToolCall[]; +} + +/** + * IM-style initial sync result: everything needed to rebuild a session's UI + * state, consistent at `asOfSeq`. The standard flow is + * `getSessionSnapshot()` → `subscribe(sessionId, {seq: asOfSeq, epoch})`. + */ +export interface AppSessionSnapshot { + asOfSeq: number; + epoch: string; + session: AppSession; + /** Most recent messages, chronological ascending. */ + messages: AppMessage[]; + hasMoreMessages: boolean; + inFlightTurn: AppInFlightTurn | null; + pendingApprovals: AppApprovalRequest[]; + pendingQuestions: AppQuestionRequest[]; +} + +export interface KimiEventHandlers { + onEvent(event: AppEvent, meta: { sessionId: string; seq: number }): void; + onResync(sessionId: string, currentSeq: number, epoch?: string): void; + onError(code: number, msg: string, fatal: boolean): void; + onConnectionChange(connected: boolean): void; +} + +export interface KimiEventConnection { + subscribe(sessionId: string, cursor?: AppSessionCursor): void; + unsubscribe(sessionId: string): void; + /** + * Bind the real daemon prompt_id to the next turn for a session, so the + * client-side projector stops synthesizing a random promptId on turn.started. + * Call right after submitPrompt() returns. + */ + bindNextPromptId(sessionId: string, promptId: string): void; + /** + * Seed the client-side projector with a snapshot's in-flight turn so a + * reconnecting client renders mid-turn state immediately; emits the + * corresponding AppEvents through `onEvent`. Resets per-session projector + * state first — call BEFORE subscribe(), with the snapshot's cursor. + */ + seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void; + abort(sessionId: string, promptId: string): void; + close(): void; +} + +// --------------------------------------------------------------------------- +// Model + Provider (app-facing, camelCase) +// PRESUMED — not in current daemon docs; isolated in adapter, swap when backend defines them. +// --------------------------------------------------------------------------- + +export interface AppModel { + /** Unique identifier for this model (the string passed to PATCH session agent_config.model) */ + id: string; + /** Provider id this model belongs to */ + provider: string; + /** Raw model name (e.g. "moonshot-v1-128k") */ + model: string; + /** Optional human-readable display name */ + displayName?: string; + /** Maximum context size in tokens */ + maxContextSize: number; + /** Optional capability tags (e.g. ["vision", "thinking"]) */ + capabilities?: string[]; +} + +export interface AppProvider { + /** Provider id */ + id: string; + /** Provider type (e.g. "moonshot", "anthropic", "openai", "custom") */ + type: string; + /** Optional custom base URL */ + baseUrl?: string; + /** Optional default model alias */ + defaultModel?: string; + /** Whether an API key is stored for this provider */ + hasApiKey: boolean; + /** Provider connectivity status */ + status: 'connected' | 'error' | 'unconfigured'; + /** Model ids available from this provider */ + models?: string[]; +} + +// --------------------------------------------------------------------------- +// KimiWebApi — the app-facing interface +// --------------------------------------------------------------------------- + +export interface KimiWebApi { + getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>; + getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record }>; + listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string }): Promise>; + createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; + /** Fetch one session by id (deep links beyond the first listSessions page). */ + getSession(sessionId: string): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; thinking?: string }): Promise; + getSessionStatus(sessionId: string): Promise; + deleteSession(sessionId: string): Promise<{ deleted: true }>; + listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise>; + /** v2 initial sync: atomic session state + `asOfSeq` watermark + epoch. */ + getSessionSnapshot(sessionId: string): Promise; + submitPrompt(sessionId: string, input: PromptSubmission): Promise; + /** Steer daemon-queued prompts into the active turn (TUI ctrl+s). */ + steerPrompts(sessionId: string, promptIds: string[]): Promise<{ steered: boolean; promptIds: string[] }>; + abortPrompt(sessionId: string, promptId: string): Promise<{ aborted: boolean; atSeq?: number }>; + compactSession(sessionId: string, instruction?: string): Promise; + forkSession(sessionId: string, input?: { title?: string }): Promise; + respondApproval(sessionId: string, approvalId: string, response: ApprovalResponse): Promise<{ resolved: true; resolvedAt: string }>; + respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>; + dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>; + listTasks(sessionId: string, status?: AppTaskStatus): Promise; + getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise; + cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }>; + listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; + readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; + searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; + grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>; + getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record }>; + getFileDiff(sessionId: string, path?: string): Promise<{ path: string; diff: string }>; + getFileDownloadUrl(sessionId: string, path: string): string; + openFile(sessionId: string, input: { path: string; line?: number }): Promise<{ opened: true }>; + revealFile(sessionId: string, input: { path: string }): Promise<{ revealed: true }>; + connectEvents(handlers: KimiEventHandlers): KimiEventConnection; + + // Workspaces + daemon folder browser + // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. + listWorkspaces(): Promise; + addWorkspace(input: { root: string; name?: string }): Promise; + deleteWorkspace(id: string): Promise; + browseFs(path?: string): Promise; + getFsHome(): Promise<{ home: string; recentRoots: string[] }>; + + // PRESUMED — not in current daemon docs; isolated in adapter, swap when backend defines them. + listModels(): Promise; + listProviders(): Promise; + addProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise; + deleteProvider(id: string): Promise<{ deleted: true }>; + refreshProvider(id: string): Promise; + + // File upload / download + uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }>; + getFileUrl(fileId: string): string; + + // Auth — REAL endpoints + getAuth(): Promise<{ + ready: boolean; + providersCount: number; + defaultModel: string | null; + managedProvider: { status: string } | null; + }>; + startOAuthLogin(): Promise<{ + flowId: string; + provider: string; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + status: 'pending'; + expiresAt: string; + }>; + pollOAuthLogin(): Promise<{ + flowId: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolvedAt?: string; + } | null>; + cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>; + logout(): Promise<{ loggedOut: boolean }>; +} diff --git a/apps/kimi-web/src/components/ActivityNotice.vue b/apps/kimi-web/src/components/ActivityNotice.vue new file mode 100644 index 000000000..5595a51ab --- /dev/null +++ b/apps/kimi-web/src/components/ActivityNotice.vue @@ -0,0 +1,66 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/AddWorkspaceDialog.vue b/apps/kimi-web/src/components/AddWorkspaceDialog.vue new file mode 100644 index 000000000..b02bdf1b7 --- /dev/null +++ b/apps/kimi-web/src/components/AddWorkspaceDialog.vue @@ -0,0 +1,439 @@ + + + + + + + + + + + diff --git a/apps/kimi-web/src/components/ApprovalCard.vue b/apps/kimi-web/src/components/ApprovalCard.vue new file mode 100644 index 000000000..05259b217 --- /dev/null +++ b/apps/kimi-web/src/components/ApprovalCard.vue @@ -0,0 +1,417 @@ + + + +