diff --git a/.changeset/web-usage-limits-panel.md b/.changeset/web-usage-limits-panel.md new file mode 100644 index 0000000000..fb9400dcf6 --- /dev/null +++ b/.changeset/web-usage-limits-panel.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Add a usage limits panel showing context size, managed weekly and rolling rate limits, and booster balance. Click the context ring in the composer toolbar to open it. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..e85b286986 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -7,10 +7,12 @@ import { traceKeyEvent } from '../../debug/trace'; import type { AppConfig, AppGoal, + AppManagedUsage, AppMessage, AppMessageRole, AppModel, AppProvider, + AppUsageRow, ProviderRefreshResult, AppSession, AppSkill, @@ -35,7 +37,8 @@ import type { QuestionResponse, } from '../types'; import { createAgentProjector } from './agentEventProjector'; -import { DaemonHttpClient } from './http'; +import { DaemonHttpClient, SERVER_AUTH_UNAUTHORIZED_CODE } from './http'; +import { DaemonApiError } from '../errors'; import { toAppApprovalRequest, toAppConfig, @@ -83,6 +86,8 @@ import type { WireSessionSnapshot, WireWorkspace, WireLogoutResult, + WireManagedUsage, + WireUsageRow, } from './wire'; import { DaemonEventSocket } from './ws'; @@ -1359,6 +1364,58 @@ export class DaemonKimiWebApi implements KimiWebApi { return { loggedOut: data.logged_out }; } + async getManagedUsage(): Promise { + let data: WireManagedUsage; + try { + data = await this.http.get('/oauth/usage'); + } catch (error) { + // Degraded results keep the panel renderable: 404 = old backend without + // the route; server-auth 401 = re-login required; anything else is a + // plain fetch failure. Server-auth 401 also trips the global auth gate + // via the http layer, so the panel copy is mostly a fallback there. + if (error instanceof DaemonApiError) { + if (error.code === 404) { + return { kind: 'error', code: 'route_unavailable', message: error.message }; + } + if (error.code === SERVER_AUTH_UNAUTHORIZED_CODE || error.code === 401) { + return { kind: 'error', code: 'unauthenticated', message: error.message }; + } + } + return { + kind: 'error', + code: 'unavailable', + message: error instanceof Error ? error.message : String(error), + }; + } + if (data.kind === 'error') { + return { kind: 'error', code: data.code, message: data.message }; + } + const row = (r: WireUsageRow): AppUsageRow => ({ + label: r.label, + used: r.used, + limit: r.limit, + resetHint: r.reset_hint, + resetAt: r.reset_at, + windowSeconds: r.window_seconds, + }); + return { + kind: 'ok', + summary: data.summary === null ? null : row(data.summary), + limits: data.limits.map(row), + extraUsage: + data.extra_usage === null + ? null + : { + balanceCents: data.extra_usage.balance_cents, + totalCents: data.extra_usage.total_cents, + monthlyChargeLimitEnabled: data.extra_usage.monthly_charge_limit_enabled, + monthlyChargeLimitCents: data.extra_usage.monthly_charge_limit_cents, + monthlyUsedCents: data.extra_usage.monthly_used_cents, + currency: data.extra_usage.currency, + }, + }; + } + // ------------------------------------------------------------------------- // File upload // ------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/daemon/http.ts b/apps/kimi-web/src/api/daemon/http.ts index 140ca1deb3..76e18599b1 100644 --- a/apps/kimi-web/src/api/daemon/http.ts +++ b/apps/kimi-web/src/api/daemon/http.ts @@ -518,8 +518,10 @@ export class DaemonHttpClient { // 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, + // Non-envelope error bodies (e.g. the router's plain 404 JSON) carry no + // `code`; fall back to the HTTP status so callers can branch on it. + code: envelope.code ?? response.status, + msg: envelope.msg ?? response.statusText, requestId: envelope.request_id, details: envelope.details, timestamp: Date.now(), diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 900d98bcf0..f45b00067a 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -423,7 +423,7 @@ export interface WireConfig { } // --------------------------------------------------------------------------- -// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout) +// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout, GET /api/v1/oauth/usage) // --------------------------------------------------------------------------- export interface WireManagedProvider { @@ -482,6 +482,35 @@ export interface WireLogoutResult { logged_out: boolean; } +// GET /oauth/usage — managed-platform quotas (weekly summary, rolling 5h +// limits, booster wallet). Discriminated by `kind`. +export interface WireUsageRow { + label: string; + used: number; + limit: number; + reset_hint?: string; + reset_at?: string; + window_seconds?: number; +} + +export interface WireBoosterWallet { + balance_cents: number; + total_cents: number; + monthly_charge_limit_enabled: boolean; + monthly_charge_limit_cents: number; + monthly_used_cents: number; + currency: string; +} + +export type WireManagedUsage = + | { + kind: 'ok'; + summary: WireUsageRow | null; + limits: WireUsageRow[]; + extra_usage: WireBoosterWallet | null; + } + | { kind: 'error'; code: 'unauthenticated' | 'unavailable'; message: string }; + // --------------------------------------------------------------------------- // File upload wire DTOs // --------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 3f82db6a4b..42c3f1d9b4 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -801,8 +801,48 @@ export interface KimiWebApi { } | null>; cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>; logout(): Promise<{ loggedOut: boolean }>; + /** Managed-account usage quotas (5h / weekly / booster wallet) — GET /oauth/usage. */ + getManagedUsage(): Promise; } +/** Managed-platform quota row (weekly summary, rolling 5h limit, …). */ +export interface AppUsageRow { + label: string; + used: number; + limit: number; + resetHint?: string; + /** Raw ISO reset timestamp — preferred over `resetHint` for localized display. */ + resetAt?: string; + /** Rolling window length in seconds — preferred over `label` for localized display. */ + windowSeconds?: number; +} + +/** Booster wallet — pay-as-you-go balance beyond the subscription quota. */ +export interface AppBoosterWallet { + balanceCents: number; + totalCents: number; + monthlyChargeLimitEnabled: boolean; + monthlyChargeLimitCents: number; + monthlyUsedCents: number; + currency: string; +} + +/** Stable failure reason for a usage fetch — the UI binds localized copy to + * the code instead of rendering the raw upstream `message`. + * `unauthenticated` / `unavailable` come from the server; `route_unavailable` + * is synthesized by the client when the backend predates the route (404). */ +export type AppManagedUsageErrorCode = 'unauthenticated' | 'unavailable' | 'route_unavailable'; + +/** Result of `getManagedUsage()`, mirroring the wire discriminated union. */ +export type AppManagedUsage = + | { + kind: 'ok'; + summary: AppUsageRow | null; + limits: AppUsageRow[]; + extraUsage: AppBoosterWallet | null; + } + | { kind: 'error'; code: AppManagedUsageErrorCode; message: string }; + /** Result of `startOAuthLogin()`, mirroring the wire discriminated union. */ export type OAuthLoginStartResult = | { diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index fc21e665be..64885adcad 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -6,7 +6,6 @@ import { useI18n } from 'vue-i18n'; import SlashMenu from './SlashMenu.vue'; import MentionMenu from './MentionMenu.vue'; import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands'; -import { formatTokens } from '../../lib/formatTokens'; import type { FileItem } from './MentionMenu.vue'; import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types'; import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types'; @@ -29,9 +28,9 @@ import Spinner from '../ui/Spinner.vue'; import Button from '../ui/Button.vue'; import IconButton from '../ui/IconButton.vue'; import Icon from '../ui/Icon.vue'; -import ContextRing from '../ui/ContextRing.vue'; import Tooltip from '../ui/Tooltip.vue'; import AttachmentChip from './AttachmentChip.vue'; +import ContextUsagePanel from './ContextUsagePanel.vue'; // --------------------------------------------------------------------------- // Props & emits @@ -560,12 +559,14 @@ const hasUpload = computed(() => !!props.uploadImage); const dropdownOpen = ref(false); const permDropdownOpen = ref(false); const toolbarRef = ref(null); +const usagePanelRef = ref<{ close: () => void } | null>(null); function toggleDropdown(): void { dropdownOpen.value = !dropdownOpen.value; if (dropdownOpen.value) { permDropdownOpen.value = false; closeModes(); + usagePanelRef.value?.close(); document.addEventListener('click', onDocClick, true); } else { document.removeEventListener('click', onDocClick, true); @@ -584,6 +585,7 @@ function togglePermDropdown(): void { if (permDropdownOpen.value) { dropdownOpen.value = false; closeModes(); + usagePanelRef.value?.close(); document.addEventListener('click', onDocClick, true); } else { document.removeEventListener('click', onDocClick, true); @@ -618,14 +620,15 @@ const pct = computed(() => { return Math.min(100, Math.max(0, Math.ceil(((props.status?.ctxUsed ?? 0) / max) * 100))); }); -const ctxTooltip = computed(() => { - const used = formatTokens(props.status?.ctxUsed ?? 0); - const max = formatTokens(props.status?.ctxMax ?? 0); - return t('status.ctxTooltip', { used, max, pct: pct.value }); -}); - const showCompact = computed(() => pct.value >= 80); +// Keep the toolbar menus mutually exclusive when the usage panel opens. +function onUsagePanelOpen(): void { + closeDropdown(); + closePermDropdown(); + closeModes(); +} + // Thinking toggle // Identity is the model id — display/model names can collide across providers. const currentModel = computed(() => @@ -703,6 +706,7 @@ function toggleModes(): void { // Keep the toolbar menus mutually exclusive so they never overlap. closeDropdown(); closePermDropdown(); + usagePanelRef.value?.close(); const r = modesRef.value?.getBoundingClientRect(); if (r) { modesMenuStyle.value = { @@ -1086,21 +1090,15 @@ function selectModel(modelId: string): void { - - - - - - + + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Tooltip.vue b/apps/kimi-web/src/components/ui/Tooltip.vue index a120cbff3d..54457d828d 100644 --- a/apps/kimi-web/src/components/ui/Tooltip.vue +++ b/apps/kimi-web/src/components/ui/Tooltip.vue @@ -9,7 +9,7 @@ line; long text wraps within `maxWidth` and is clamped to `maxLines` lines with an ellipsis so the bubble never grows too tall. -->