kimi-code/apps/kimi-web
qer 9e1248416f
fix(web): remember the thinking level per model (#1838)
* fix(web): remember the thinking level per model

Persist kimi-web.thinking as a JSON map of model id to level instead of a
single global value, and resolve the active level against the model's
catalog (stored pick when still declared, else the model default) at
loadModels, setModel, and on active-model changes via a watcher.

Fixes the empty, unresponsive thinking picker shown for a model that does
not declare a previously stored level (e.g. a max-only model with a stale
global 'low').

* fix(web): resolve a submitted prompt's thinking from its own model

submitPromptInternal and the steer path read the single active-session
rawState.thinking, so a queue drain for a background session submitted the
level of whichever session the user had switched to since enqueueing —
the same cross-model leak on the submit path. Thinking now joins model and
the per-session modes in being resolved from the prompt's own session
model (its stored pick when declared, else the catalog default), falling
back to the active value only when the model has left the catalog.

* fix(web): keep model switches from persisting derived thinking defaults

setModel routed the resolved level through applyThinkingLevel, which
writes per-model storage unconditionally — a switch to a model with no
saved pick stored the catalog default as if it were an explicit choice,
pinning the user to it across later default changes, and the rollback
path did the same write for a switch that never happened. Model switches
now update the in-memory level only; storage writes stay with
setThinking, the explicit picker path.

* fix(web): resolve thinking per target session on the BTW and skill paths

sendSideChatPromptOn combined the captured parent's model with the
active-session level, so a session switch during the startBtw await sent
the BTW first turn at the wrong model's effort — resolve it from the
parent's own model, falling back to the active value off-catalog, same
as the other submit paths.

activateSkill carries no thinking either, so the daemon ran skills at
the session profile effort, which can predate the per-model restore the
picker now shows. Persist the resolved level to the session profile
first, mirroring the new-session skill path; that path itself now
resolves against the new session's model instead of the raw active
value.

* fix(web): keep per-model thinking picks in memory as the runtime truth

The resolver re-read localStorage on every submission, letting storage —
not the displayed state — decide what the daemon receives: with storage
unavailable (policy/quota) an explicit pick reached the UI while every
submit path fell back to the catalog default, and a pick made in another
tab silently changed what this tab submits mid-session.

Per-model picks now live in an in-memory map hydrated from localStorage
at startup; explicit picks update it first and persist best-effort
(read-modify-write merge, so concurrent tabs' entries still survive).
localStorage is only hydration plus persistence — another tab's pick can
no longer alter this tab's runtime level.

* fix(web): carry the legacy global thinking pick forward as a fallback

Pre-map installs stored a single global level as a raw string; the map
parser dropped it, silently resetting the user's explicit preference to
the catalog default on upgrade. The legacy value is now carried as a
fallback for models without their own entry — validated against each
model's catalog at resolution, so effort models keep the user's pick
while a max-only model still falls through to its default and can never
be trapped by it.

* fix(web): keep the legacy thinking fallback across the first map rewrite

The first explicit pick after an upgrade rewrote the raw legacy value
into a map containing only that one model, so the next reload saw a
nonempty map and dropped the legacy fallback for every other model.
The migrated value now lives inside the map under a '*' key that no
real model id can collide with: per-model entries override it, and
rewrites persist it alongside them instead of deleting it.

* fix(web): persist only the changed thinking pick on write

Overlaying the whole in-memory map on write could revert a newer pick
made in another tab for a model this tab still held a stale copy of.
Write the changed entry alone (delta-style, like saveUnread), carrying
only the migrated legacy '*' fallback along so it survives the first
rewrite into map format.

* fix(web): abort skill activation when the thinking profile persist fails

persistSessionProfile surfaces failures itself and resolves, so awaiting
it never blocked a following activation: a failed /profile write still
launched the skill at the session's stale effort. It now resolves a
success flag; both activation paths (existing session and new-session
draft) gate on it and skip activating when the persist fails, without
reporting a second, synthetic error.

* refactor(web): persist the new-session skill profile's thinking once

startSessionAndActivateSkill persisted the resolved thinking and then
activateSkill persisted it again unconditionally — a redundant profile
update and status refresh whose transient failure would false-veto an
activation whose prerequisite profile was already applied. Thinking is
now written by activateSkill alone (the single, gated writer); the
draft patch carries only model, plan/swarm and permission.

* fix(web): throw an Error instance for the profile-persist sentinel

oxlint --type-aware (only-throw-error) rejects throwing a Symbol; the
identity-based sentinel works the same as a shared Error instance.

* fix(web): resolve an empty session model through the default before skills

session.model can be '' transiently (daemon profile echo), so
activateSkill fell back to the raw active-view level; in the new-session
flow a concurrent switch could persist another model's effort onto the
target session. Normalize '' through the configured default_model first,
same as the prompt/BTW/steer paths.
2026-07-17 19:56:26 +08:00
..
public feat(web): allow attaching any file type and fix CSP on non-loopback binds (#1731) 2026-07-15 21:54:48 +08:00
scripts refactor(web): migrate icons to unplugin-icons (#1467) 2026-07-07 16:44:56 +08:00
src fix(web): remember the thinking level per model (#1838) 2026-07-17 19:56:26 +08:00
test fix(web): remember the thinking level per model (#1838) 2026-07-17 19:56:26 +08:00
AGENTS.md fix(web): submit thinking level verbatim and drop the hardcoded default (#1673) 2026-07-14 15:04:28 +08:00
CHANGELOG.md ci: release packages (#1061) 2026-06-26 02:29:53 +08:00
index.html feat(web): allow attaching any file type and fix CSP on non-loopback binds (#1731) 2026-07-15 21:54:48 +08:00
package.json fix(web): submit thinking level verbatim and drop the hardcoded default (#1673) 2026-07-14 15:04:28 +08:00
README.md fix(web): submit thinking level verbatim and drop the hardcoded default (#1673) 2026-07-14 15:04:28 +08:00
tsconfig.json refactor(web): migrate icons to unplugin-icons (#1467) 2026-07-07 16:44:56 +08:00
vite.config.ts feat(server): default to kap-server and remove the v1 server package (#1617) 2026-07-13 21:43:45 +08:00

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

# Against a REAL server (the server must be running and reachable)
WEB_PORT=5197 KIMI_SERVER_URL=http://192.168.97.91:58627 pnpm -C apps/kimi-web run dev
#   …or from the repo root:  pnpm dev:web   (uses the defaults below)

# checks
pnpm -C apps/kimi-web run typecheck     # vue-tsc --noEmit
pnpm -C apps/kimi-web run test          # vitest (pure logic only)
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:58627 where /api/v1 (and /api/v1/ws) is forwarded

Behind a corporate HTTP proxy, also set NO_PROXY=<server-host> (for example, NO_PROXY=127.0.0.1,localhost) 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 AppEvents.
  • i18n (src/i18n/): vue-i18n, en/zh, per-namespace flat camelCase keys. Detect order: localStorage('kimi-locale')navigator.languageen.

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_<slug>_<hash> 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.

Release & deployment

Kimi Web is not published as a standalone package. It ships as the built-in web UI of the kimi CLI (apps/kimi-code).

Current release flow

  1. Developpnpm dev:web (or pnpm -C apps/kimi-web run dev).
  2. Buildpnpm -C apps/kimi-web run build produces apps/kimi-web/dist.
  3. Bundle into CLIpnpm -C apps/kimi-code run build runs scripts/copy-web-assets.mjs, which copies apps/kimi-web/dist into apps/kimi-code/dist-web.
  4. Publish — the root .github/workflows/release.yml publishes @moonshot-ai/kimi-code to npm; dist-web is listed in the package files array, so the built web assets travel with the CLI package.
  5. Servekimi server run / kimi web serves dist-web from the installed package.

The web UI does not display its own package version or build commit. It is bundled into the CLI package and follows the published @moonshot-ai/kimi-code release.

Suggested improvements

  • Keep the current coupling for now. Because Kimi Code is primarily a local CLI/server product, bundling the web UI into the CLI package keeps installs self-contained and avoids cross-origin/CORS complexity.
  • Add an independent web-deploy workflow only when needed. If a public standalone web deployment is required later, create .github/workflows/web-deploy.yml that builds apps/kimi-web and uploads dist/ to the chosen static host (S3/CloudFront, Cloudflare Pages, Vercel, etc.). Until then, do not maintain a separate deploy target.
  • Keep versioning owned by the CLI release. apps/kimi-web/package.json remains internal workspace metadata; do not surface it as a separate user version unless the web app becomes an independently published product.
  • Ensure the web build is exercised in CI. The root build script already builds every workspace, so pnpm run build in CI covers apps/kimi-web. Keep it that way; do not bypass the web build in release pipelines.