From d6a8977d55884f5205e7b991965c90a7abb63a37 Mon Sep 17 00:00:00 2001 From: ackness Date: Fri, 17 Jul 2026 11:03:10 +0800 Subject: [PATCH 01/10] feat(context): derive compaction and prompt budgets from model capability, add cost estimation - Compactor context window now resolves live from the narrative slot's model capability (llm.toml hot-reload aware); COVEL_COMPACTOR_CONTEXT_WINDOW becomes an optional explicit override (fallback 32768). - New CJK-aware estimateTokens replaces the chars/4 heuristic that undercounted Chinese text ~3x. - Activate the applyBudget hard prune as last line of defense: estimator + getter-based contextBudget injected into turn-executor deps. - Debug cost panel: per-model USD estimation via model-db pricing; model recovered by pairing llm.responded with the preceding llm.calling. - ModelCapabilityInfo gains the flat per-M-token price fields actually returned by /api/model-db/lookup. Riders on shared files: memorySystem module-setter typing cleanup (actions.ts), withSessionLock ghost-comment reword (env.d.ts), dead searchModelDb removal (llm.ts), orphaned lorebook i18n keys removal (locales), doc-sync lines in CLAUDE.md / env-registry.md. --- CLAUDE.md | 8 +- apps/server/src/app.ts | 20 ++ apps/server/src/env.d.ts | 13 +- apps/server/src/routes/api/actions.ts | 77 +++++--- apps/server/src/routes/api/bootstrap.ts | 25 ++- .../src/routes/api/bootstrap/compactor.ts | 85 ++++++-- apps/server/src/routes/api/plugin-rpc.ts | 5 + .../bootstrap/turn-context-budget.test.ts | 49 +++++ apps/web/src/i18n/locales/en-US.json | 17 +- apps/web/src/i18n/locales/zh-CN.json | 17 +- apps/web/src/routes/debug/-cost-panel.tsx | 183 ++++++++++++++++-- .../debug/__tests__/-cost-panel.test.ts | 88 ++++++++- apps/web/src/services/api/llm.ts | 30 +-- docs/guide/env-registry.md | 21 +- packages/context/src/budget.ts | 22 +++ packages/context/src/index.ts | 2 +- packages/context/tests/budget.test.ts | 23 ++- packages/shared/src/env/groups/feature.ts | 5 +- packages/shared/src/env/registry-readers.ts | 15 +- packages/shared/tests/env-registry.test.ts | 4 +- 20 files changed, 582 insertions(+), 127 deletions(-) create mode 100644 apps/server/tests/bootstrap/turn-context-budget.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4c0dc04e..d0b6965b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,7 @@ All panels/blocks render through [json-render](https://github.com/vercel-labs/js - Configured via `llm.toml` `[covel.]` sections. If missing, single `story` → DeepSeek fallback boots the app. - **Tag-aware fallback**: an unconfigured slot falls back to the first slot with the same tag (`text`/`image`/`embedding`/`speech`/`transcription`). Cross-tag fallback is forbidden (an image request never silently routes to text). - Supports OpenAI, Anthropic, DeepSeek, Qwen (Aliyun DashScope). -- **Model capabilities** (multimodal, features, token limits, pricing) auto-detected via: frontend localStorage override → `llm.toml` manual → `known-models.ts` (~60 common) → LiteLLM DB (2597 models, `pnpm --filter @covel/ai-provider update-model-db`) → protocol defaults. Directional modality: `input: InputModality[]` = accepts, `output: OutputModality[]` = produces. +- **Model capabilities** (multimodal, features, token limits, pricing) auto-detected via: frontend localStorage override → `llm.toml` manual → `known-models.ts` (~60 common) → LiteLLM DB (2967 models, `pnpm --filter @covel/ai-provider update-model-db`) → protocol defaults. Directional modality: `input: InputModality[]` = accepts, `output: OutputModality[]` = produces. - **Media generation (image / TTS / STT)**: `ctx.images.generate()` and `ctx.speech.generate()`/`.transcribe()` (function-runtime plugins, preferred) route through pluggable per-modality wire registries — builtin `openai-images` (default) + `dashscope-wan` for image, `openai-speech` / `openai-transcription` for speech — selectable per-slot via `llm.toml` `providerRequestMetadata.imageWire|speechWire|transcriptionWire`. Both `generate` paths dedupe on promptHash and persist to MediaStore. Plugins register vendor wires via the PLUGIN.md `wires` frontmatter field (ids namespaced `/`, trust-gated loading in `bootstrap/plugin-wires.ts`); bundled code may call `register{Image,Speech,Transcription}Wire()` directly. See [docs/reference/slots.md](./docs/reference/slots.md), [docs/reference/media-store.md](./docs/reference/media-store.md) and [docs/guide/plugin-authoring-advanced.md](./docs/guide/plugin-authoring-advanced.md#6-函数-runtime手动触发与后台执行). ## Critical Conventions (Read These) @@ -288,7 +288,7 @@ Each SQL backend keeps a thin public factory plus focused method modules: - **vitest** is the single runner (`vitest run` for CI, `vitest` for watch). No Node `node:test`. - **Contract tests** (`store-contract.ts` + `contract/suites/`): every `DataStore` backend must pass the shared suite. Required for any new backend. -- **Plugin tests**: use `@covel/plugin-test-utils` — `MockLLM`, `createTestHarness`, `makeTurnInput`, `makeTriggerContext`, `makeRuntimeResult`. +- **Plugin tests**: use `@covel/plugin-test-utils` — `MockLLM`, `makeManualFunctionContext`, `expectAssetGenerated`, `makeTurnInput`, `makeTriggerContext`, `makeRuntimeResult`. - **IDB tests**: `fake-indexeddb` polyfill. **PG tests**: real local DB (`pnpm db:up`). - **E2E harness**: `scripts/e2e-plugin-verify.ts` is the API-driven, plugin-level, 7-phase harness (artefacts under `debugs/e2e-logs/`) — see [docs/guide/e2e-plugin-verify.md](./docs/guide/e2e-plugin-verify.md). - Coverage via `@vitest/coverage-v8` on all packages (`--coverage` flag). @@ -323,8 +323,8 @@ Trace chain: `traceId → runId → branchId → turnId → runtimeId → plugin - **Runtime trace** (DB `trace_events`): structured turn hierarchy — LLM delta messages, tool calls, proposals, hooks, provider binding, context fragments. Delta recording avoids duplicating prompt history. - **Infrastructure log** (console, `[component]`-prefixed): startup, plugin loading, DB, SSE connections. -- **Consumption**: `/api/traces/*`, `TraceExporter` interface (e.g. Langfuse), JSON export for players. -- **Frontend `/debug`**: Session Timeline · Runtime Inspector · Prompt Viewer (full reconstruction + diff) · Data Explorer · Cost (token-usage aggregation from `llm.responded` / `gateway.responded` trace usage). +- **Consumption**: `/api/traces/*` endpoints, JSON export for players. +- **Frontend `/debug`**: Session Timeline · Runtime Inspector · Prompt Viewer (full reconstruction + diff) · Data Explorer · Cost (token-usage aggregation from `llm.responded` / `gateway.responded` trace usage, with per-model USD estimation via model-db pricing — model recovered by pairing each `llm.responded` with the preceding `llm.calling`). ## Deployment Tiers diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 44ea1730..f039fadd 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -239,6 +239,25 @@ const pluginUtils = { }; const preferredMemorySlot = resolvePreferredMemorySlot(ai.slotRegistry); +// Live budget view of the main narrative slot ("default" when configured, +// else the isDefault/first preset). Resolved per call — never cached — so +// llm.toml hot-reloads propagate to compaction thresholds and prompt budget. +// ponytail: ignores per-session runtime_model_overrides and X-Slot-Config +// overlays; wire the session's actual narrative slot if that ever matters. +const resolveNarrativeBudget = () => { + const presetId = ai.slotRegistry.resolveSlot("default"); + const capability = ai.presetRegistry.resolvePreset(presetId)?.capability; + if (!capability) return undefined; + return { + ...(capability.contextWindow !== undefined + ? { contextWindow: capability.contextWindow } + : {}), + ...(capability.maxOutputTokens !== undefined + ? { maxOutputTokens: capability.maxOutputTokens } + : {}), + }; +}; + // ── Bootstrap API ─────────────────────────────────────────────── // Bundled plugins ship inside the repo / packaged app. The desktop shell // can additionally mount a user plugins directory via COVEL_USER_PLUGINS_DIR @@ -293,6 +312,7 @@ const api = await bootstrapApi({ preferredMemorySlot, perRequestMiddleware: [perRequestLlm], sessionLock, + resolveNarrativeBudget, }); const seededWorldIds = new Set(); diff --git a/apps/server/src/env.d.ts b/apps/server/src/env.d.ts index a3db970a..0c65a0b2 100644 --- a/apps/server/src/env.d.ts +++ b/apps/server/src/env.d.ts @@ -17,7 +17,7 @@ import type { } from "@covel/runtime"; import type { RpcApprovalGate } from "@covel/approval"; import type { RuntimeManifest } from "@covel/shared"; -import type { CompactorRunner } from "@covel/context"; +import type { BudgetOptions, CompactorRunner } from "@covel/context"; import type { SessionLock } from "./lib/session-lock.js"; import type { EventDirectory } from "./routes/api/bootstrap/event-directory.js"; @@ -78,6 +78,12 @@ declare module "hono" { getConfigFn: GetConfigFn; resolveModel: ResolveModelFn; compactorRunner: CompactorRunner; + /** + * Prompt-assembly hard-prune budget (`applyBudget`), derived from the + * narrative slot's model capability. Optional so hand-built test DI + * need not wire it — turn routes spread it in conditionally. + */ + turnContextBudget?: Omit; rpcExecutor: RpcExecutor; rpcRegistry: PluginRpcRegistry; rpcApprovalGate: RpcApprovalGate; @@ -88,8 +94,9 @@ declare module "hono" { * - everything else → `createInProcessSessionLock()` — `Map`-based * chain, correct for single-process deployments. * - * Route handlers MUST use this instead of the legacy `withSessionLock` - * import so PG deployments automatically get cross-pod safety. + * Route handlers MUST use this instead of a hand-rolled lock; the + * historical `withSessionLock` import was replaced by this DI-injected + * `sessionLock` so PG deployments automatically get cross-pod safety. */ sessionLock: SessionLock; /** diff --git a/apps/server/src/routes/api/actions.ts b/apps/server/src/routes/api/actions.ts index 1ed299ed..c6fe6b3b 100644 --- a/apps/server/src/routes/api/actions.ts +++ b/apps/server/src/routes/api/actions.ts @@ -19,6 +19,7 @@ import { } from "@covel/runtime"; import type { CovelEventType, RuntimeManifest } from "@covel/shared"; import { FORWARDED_EVENT_TYPES } from "@covel/shared"; +import { estimateTokens } from "@covel/context"; import type { CompactorRunner } from "@covel/context"; import { errorBody } from "../../api-error.js"; import { rateLimiter } from "../../middleware/rate-limit.js"; @@ -39,6 +40,39 @@ import { checkSessionOwner } from "./session/session-guard.js"; // SSE uses ProtocolEventType names directly — no legacy mapping. // Frontend handleSseEvent handles these standard types. +/** + * Memory-system facade injected by bootstrap via `setMemorySystem()`. Kept + * out of `Env["Variables"]` because it never flows through Hono's + * `c.set`/`c.get` — see the module-level reference below. + */ +interface MemorySystemFacade { + readonly manager: { + loadBlocks( + sid: string, + ): Promise< + readonly { label: string; content: string; updatedAt: string }[] + >; + initializeDefaults(sid: string): Promise; + }; + readonly updater: { + updateAfterTurn(p: { + sessionId: string; + narrativeText: string; + toolCallSummaries?: readonly string[]; + currentBlocks: readonly { + label: string; + content: string; + updatedAt: string; + }[]; + locale?: string; + }): Promise<{ + updated: boolean; + blocksChanged: readonly string[]; + error?: string; + }>; + }; +} + type Env = { Variables: { store: DataStore; @@ -61,33 +95,6 @@ type Env = { compactorRunner: CompactorRunner; mediaStore?: MediaStore; hookPipeline?: HookPipeline; - memorySystem?: { - readonly manager: { - loadBlocks( - sid: string, - ): Promise< - readonly { label: string; content: string; updatedAt: string }[] - >; - initializeDefaults(sid: string): Promise; - }; - readonly updater: { - updateAfterTurn(p: { - sessionId: string; - narrativeText: string; - toolCallSummaries?: readonly string[]; - currentBlocks: readonly { - label: string; - content: string; - updatedAt: string; - }[]; - locale?: string; - }): Promise<{ - updated: boolean; - blocksChanged: readonly string[]; - error?: string; - }>; - }; - }; ensureEmbeddingLock?: (sessionId: string) => Promise; }; }; @@ -97,8 +104,8 @@ export const actionRoutes = new Hono(); // Module-level memory system reference, set by bootstrap via setMemorySystem(). // Using a module variable instead of Hono context because Hono's typed // c.set/c.get doesn't support optional cross-module types cleanly. -let _memorySystem: Env["Variables"]["memorySystem"] | undefined; -export function setMemorySystem(ms: Env["Variables"]["memorySystem"]) { +let _memorySystem: MemorySystemFacade | undefined; +export function setMemorySystem(ms: MemorySystemFacade | undefined) { _memorySystem = ms; } @@ -124,6 +131,7 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { const resolveModel = c.get("resolveModel"); const eventBus = c.get("eventBus"); const compactorRunner = c.get("compactorRunner"); + const turnContextBudget = c.get("turnContextBudget"); const sessionLock = c.get("sessionLock"); const mediaStore = c.get("mediaStore"); const eventDirectory = c.get("eventDirectory"); @@ -506,6 +514,14 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { }); }, compactor: compactorRunner, + // Prompt-assembly hard prune — last line of defense when + // compaction is skipped/vetoed/insufficient for the model window. + ...(turnContextBudget + ? { + estimator: estimateTokens, + contextBudget: turnContextBudget, + } + : {}), memorySystem: _memorySystem, // Let the turn executor construct a unified SessionContextSnapshot. capabilityPluginIds, @@ -614,6 +630,9 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { toolExecutor, resolveModel, compactor: compactorRunner, + ...(turnContextBudget + ? { estimator: estimateTokens, contextBudget: turnContextBudget } + : {}), capabilityPluginIds, ...(eventDirectory ? { eventDirectory } : {}), }, diff --git a/apps/server/src/routes/api/bootstrap.ts b/apps/server/src/routes/api/bootstrap.ts index bfa7d5c2..2b51029a 100644 --- a/apps/server/src/routes/api/bootstrap.ts +++ b/apps/server/src/routes/api/bootstrap.ts @@ -66,7 +66,11 @@ import { runtimeOutputRoutes } from "./runtime-outputs.js"; import { pluginRpcRoutes } from "./plugin-rpc.js"; import { approvalRoutes, sessionApprovalRoutes } from "./approvals.js"; export { wrapStoreWithPluginDataEvents } from "./bootstrap/plugin-data-store-events.js"; -import { createBootstrapCompactorRunner } from "./bootstrap/compactor.js"; +import { + createBootstrapCompactorRunner, + createTurnContextBudget, + type ResolveNarrativeBudgetFn, +} from "./bootstrap/compactor.js"; import { discoverAndRegisterPlugins } from "./bootstrap/plugin-discovery.js"; import { createBootstrapHookPipeline } from "./bootstrap/plugin-hooks.js"; import { setupPluginTools } from "./bootstrap/tools.js"; @@ -175,6 +179,13 @@ export interface ApiBootstrapConfig { readonly mediaStore?: MediaStore; readonly mediaBackend?: MediaStoreBackend; readonly vectorBackend?: VectorBackend; + /** + * Live view of the main narrative slot's model budget (contextWindow / + * maxOutputTokens), built by the composition root against the AI + * registries. Drives the compaction threshold and the prompt-assembly + * hard prune. Absent (tests, minimal harnesses) → fixed fallback window. + */ + readonly resolveNarrativeBudget?: ResolveNarrativeBudgetFn; } export interface ApiBootstrapResult { @@ -493,12 +504,21 @@ export async function bootstrapApi( }; const runtimeEnv = readRuntimeEnv(); + const budgetSource = { + ...(runtimeEnv.compactorContextWindow !== undefined + ? { contextWindowOverride: runtimeEnv.compactorContextWindow } + : {}), + ...(config.resolveNarrativeBudget + ? { resolveNarrativeBudget: config.resolveNarrativeBudget } + : {}), + }; const compactorRunner = createBootstrapCompactorRunner({ manifestCache, store, llmAdapter: config.llmAdapter, - contextWindow: runtimeEnv.compactorContextWindow, + ...budgetSource, }); + const turnContextBudget = createTurnContextBudget(budgetSource); // 8. Create memory system (Letta-style three-tier memory) const bootstrapMemory = createBootstrapMemorySystem({ @@ -545,6 +565,7 @@ export async function bootstrapApi( c.set("getConfigFn", getConfigFn); c.set("resolveModel", resolveModel); c.set("compactorRunner", compactorRunner); + c.set("turnContextBudget", turnContextBudget); c.set("hookPipeline", hookPipeline); c.set("eventDirectory", eventDirectory); // memorySystem injected via module-level setter, not Hono context diff --git a/apps/server/src/routes/api/bootstrap/compactor.ts b/apps/server/src/routes/api/bootstrap/compactor.ts index 5419eb70..ad6c077f 100644 --- a/apps/server/src/routes/api/bootstrap/compactor.ts +++ b/apps/server/src/routes/api/bootstrap/compactor.ts @@ -1,25 +1,61 @@ import type { LLMAdapter } from "@covel/runtime"; import type { DataStore } from "@covel/store"; import { + estimateTokens, maybeCompact, + type BudgetOptions, type CompactorLLMAdapter, type CompactorRunner, } from "@covel/context"; import type { ParsedPluginMd } from "@covel/plugin-loader"; -export interface CreateBootstrapCompactorRunnerParams { +/** + * Last-resort context window when neither an explicit env override nor a + * model-capability lookup yields a value (e.g. an unknown model with no + * llm.toml capability block). + */ +const FALLBACK_CONTEXT_WINDOW = 32768; + +/** Matches applyBudget's own default; explicit here because getters can't omit. */ +const DEFAULT_RESERVED_FOR_RESPONSE = 4000; + +/** + * Live view of the main narrative slot's model budget. Implemented in the + * composition root (app.ts) against the AI registries so llm.toml hot-reloads + * are observed without a server restart — resolve on every call, never cache. + */ +export type ResolveNarrativeBudgetFn = () => + | { + readonly contextWindow?: number; + readonly maxOutputTokens?: number; + } + | undefined; + +interface BudgetSourceParams { + /** Explicit COVEL_COMPACTOR_CONTEXT_WINDOW override — wins over capability. */ + readonly contextWindowOverride?: number; + readonly resolveNarrativeBudget?: ResolveNarrativeBudgetFn; +} + +/** Resolution order: env override > slot model capability > fallback. */ +function resolveContextWindow(params: BudgetSourceParams): number { + return ( + params.contextWindowOverride ?? + params.resolveNarrativeBudget?.()?.contextWindow ?? + FALLBACK_CONTEXT_WINDOW + ); +} + +export interface CreateBootstrapCompactorRunnerParams extends BudgetSourceParams { readonly manifestCache: ReadonlyMap; readonly store: DataStore; readonly llmAdapter: LLMAdapter; - readonly contextWindow: number; } -export function createBootstrapCompactorRunner({ - manifestCache, - store, - llmAdapter, - contextWindow, -}: CreateBootstrapCompactorRunnerParams): CompactorRunner { +export function createBootstrapCompactorRunner( + params: CreateBootstrapCompactorRunnerParams, +): CompactorRunner { + const { manifestCache, store, llmAdapter } = params; const allSummaryFocus = new Set(); for (const [, manifests] of manifestCache) { for (const parsed of manifests) { @@ -29,15 +65,14 @@ export function createBootstrapCompactorRunner({ } } const focusSections: readonly string[] = [...allSummaryFocus]; - const simpleEstimator = (text: string): number => Math.ceil(text.length / 4); const fastSlotLlm: CompactorLLMAdapter = { - async complete(params) { + async complete(input) { const response = await llmAdapter.generate({ model: "fast", messages: [ - { role: "system", content: params.systemPrompt }, - ...params.messages.map((m) => ({ + { role: "system", content: input.systemPrompt }, + ...input.messages.map((m) => ({ role: m.role as "user", content: m.content, })), @@ -61,9 +96,10 @@ export function createBootstrapCompactorRunner({ messages, { store, - estimator: simpleEstimator, + estimator: estimateTokens, fastSlotLlm, - contextWindow, + // Resolved per run so llm.toml hot-reloads take effect immediately. + contextWindow: resolveContextWindow(params), }, { focusSections, @@ -74,3 +110,24 @@ export function createBootstrapCompactorRunner({ }, }; } + +/** + * Budget config for the prompt-assembly hard prune (`applyBudget`) — the last + * line of defense when compaction is skipped, vetoed, or insufficient. + * Getter-based so each turn observes the current llm.toml capability. + */ +export function createTurnContextBudget( + params: BudgetSourceParams, +): Omit { + return { + get maxInputTokens(): number { + return resolveContextWindow(params); + }, + get reservedForResponse(): number { + return ( + params.resolveNarrativeBudget?.()?.maxOutputTokens ?? + DEFAULT_RESERVED_FOR_RESPONSE + ); + }, + }; +} diff --git a/apps/server/src/routes/api/plugin-rpc.ts b/apps/server/src/routes/api/plugin-rpc.ts index 0323b601..411d76de 100644 --- a/apps/server/src/routes/api/plugin-rpc.ts +++ b/apps/server/src/routes/api/plugin-rpc.ts @@ -34,6 +34,7 @@ */ import { Hono } from "hono"; +import { estimateTokens } from "@covel/context"; import { COMMUNITY_SERVER_CODE_ACTION } from "@covel/approval"; import { createRpcHandlerStoreView } from "@covel/runtime"; import { RpcDispatchError, RpcValidationError } from "@covel/runtime"; @@ -116,6 +117,7 @@ pluginRpcRoutes.post("/:id/plugin-rpc", rateLimiter({ max: 30 }), async (c) => { const resolveModel = c.get("resolveModel"); const eventBus = c.get("eventBus"); const compactorRunner = c.get("compactorRunner"); + const turnContextBudget = c.get("turnContextBudget"); const hookPipeline = c.get("hookPipeline"); const sessionLock = c.get("sessionLock"); const mediaStore = c.get("mediaStore"); @@ -260,6 +262,9 @@ pluginRpcRoutes.post("/:id/plugin-rpc", rateLimiter({ max: 30 }), async (c) => { // then can't tell the runtime ever finished. Wire it through so // manual-trigger turns produce the same trace surface as auto turns. compactor: compactorRunner, + ...(turnContextBudget + ? { estimator: estimateTokens, contextBudget: turnContextBudget } + : {}), capabilityPluginIds, ...(hookPipeline ? { hookPipeline } : {}), ...(eventDirectory ? { eventDirectory } : {}), diff --git a/apps/server/tests/bootstrap/turn-context-budget.test.ts b/apps/server/tests/bootstrap/turn-context-budget.test.ts new file mode 100644 index 00000000..99349e59 --- /dev/null +++ b/apps/server/tests/bootstrap/turn-context-budget.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { createTurnContextBudget } from "../../src/routes/api/bootstrap/compactor.js"; + +describe("createTurnContextBudget", () => { + it("prefers the explicit env override over capability", () => { + const budget = createTurnContextBudget({ + contextWindowOverride: 8000, + resolveNarrativeBudget: () => ({ + contextWindow: 128_000, + maxOutputTokens: 8192, + }), + }); + + expect(budget.maxInputTokens).toBe(8000); + // reservedForResponse still comes from capability — the override only + // pins the window. + expect(budget.reservedForResponse).toBe(8192); + }); + + it("derives window and reserve from the narrative slot capability", () => { + const budget = createTurnContextBudget({ + resolveNarrativeBudget: () => ({ + contextWindow: 200_000, + maxOutputTokens: 16_000, + }), + }); + + expect(budget.maxInputTokens).toBe(200_000); + expect(budget.reservedForResponse).toBe(16_000); + }); + + it("falls back to 32768 / 4000 when no source is available", () => { + const budget = createTurnContextBudget({}); + + expect(budget.maxInputTokens).toBe(32_768); + expect(budget.reservedForResponse).toBe(4000); + }); + + it("re-resolves capability on every access (llm.toml hot-reload)", () => { + let window = 64_000; + const budget = createTurnContextBudget({ + resolveNarrativeBudget: () => ({ contextWindow: window }), + }); + + expect(budget.maxInputTokens).toBe(64_000); + window = 1_000_000; + expect(budget.maxInputTokens).toBe(1_000_000); + }); +}); diff --git a/apps/web/src/i18n/locales/en-US.json b/apps/web/src/i18n/locales/en-US.json index a8735ad9..40b54e8a 100644 --- a/apps/web/src/i18n/locales/en-US.json +++ b/apps/web/src/i18n/locales/en-US.json @@ -906,17 +906,6 @@ "enemy": "Enemy" } }, - "lorebook": { - "title": "Lorebook", - "refresh": "Refresh", - "deleteConfirm": "Delete entry \"{{id}}\"? This cannot be undone.", - "noEntries": "No session lorebook entries yet", - "enable": "Enable", - "disable": "Disable", - "delete": "Delete", - "expand": "Expand", - "collapse": "Collapse" - }, "graph": { "empty": "Graph is currently empty — waiting for NPCs to appear.", "clearSelection": "Clear selection", @@ -1038,7 +1027,11 @@ "calls": "LLM calls", "byRuntime": "By runtime", "byTurn": "By turn", - "note": "Token sums from llm.responded / gateway.responded. USD cost is a pending follow-up (model id not yet in the trace payload).", + "byModel": "By model", + "estCost": "Est. cost (USD)", + "unknownModel": "(unknown model)", + "noPrice": "no price", + "note": "Token sums from llm.responded / gateway.responded. USD cost estimated from model-db prices; usage without a model id or price is excluded (cost shown as a lower bound).", "noData": "No token usage recorded for this session.", "windowTitle": "Loaded window", "partialNote": "Only the most recent window is loaded — totals below are partial.", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 3d29d7f2..1fedfec0 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -906,17 +906,6 @@ "enemy": "敌人" } }, - "lorebook": { - "title": "知识库", - "refresh": "刷新", - "deleteConfirm": "删除词条 \"{{id}}\"?此操作无法撤销。", - "noEntries": "尚无 session lorebook 词条", - "enable": "启用", - "disable": "禁用", - "delete": "删除", - "expand": "展开", - "collapse": "收起" - }, "graph": { "empty": "关系图当前为空——等待 NPC 出现。", "clearSelection": "清除选中", @@ -1038,7 +1027,11 @@ "calls": "LLM 调用", "byRuntime": "按 runtime", "byTurn": "按回合", - "note": "token 求和来自 llm.responded / gateway.responded。美元成本为待办 follow-up(model id 尚未进入 trace 负载)。", + "byModel": "按模型", + "estCost": "估算成本 (USD)", + "unknownModel": "(未知模型)", + "noPrice": "无价格", + "note": "token 求和来自 llm.responded / gateway.responded。美元成本按 model-db 价格估算;无 model id 或无价格的用量不计入(此时显示为下限值)。", "noData": "本会话暂无 token 用量记录。", "windowTitle": "已加载窗口", "partialNote": "仅加载最近一段窗口,下方合计为部分值。", diff --git a/apps/web/src/routes/debug/-cost-panel.tsx b/apps/web/src/routes/debug/-cost-panel.tsx index 5b56e3b6..5ede3c5a 100644 --- a/apps/web/src/routes/debug/-cost-panel.tsx +++ b/apps/web/src/routes/debug/-cost-panel.tsx @@ -1,20 +1,29 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { lookupModelCapability } from "@/services/api.js"; import type * as api from "@/services/api.js"; import type { VisibleTurn } from "./-debug-page-model.js"; /** - * Token usage panel — aggregates `usage` from the persisted `llm.responded` / - * `gateway.responded` trace events (per runtime, per turn, session total). + * Token usage + estimated cost panel — aggregates `usage` from the persisted + * `llm.responded` / `gateway.responded` trace events (per runtime, per turn, + * per model, session total). * * Aggregates generically by event type + runtimeId, never hardcoding a plugin - * ID. USD cost conversion is intentionally NOT done here: `llm.responded` - * payloads do not yet carry the model id, so `usage × pricing` would be - * incomplete — that is a separate follow-up (thread model into the trace - * payload, then sum via the server-side `resolveCapability` for a single - * pricing source of truth). + * ID. Model attribution: `llm.responded` payloads carry no model id, so each + * usage event is paired with the most recent `llm.calling` for the same + * runtimeId within the turn (events are stored in emission order, and + * calling/responded always pair up per call). `gateway.responded` carries a + * slot (presetId), not a model — its usage stays unattributed and is excluded + * from pricing, making the USD figure a lower bound when present. + * + * Pricing comes from `/api/model-db/lookup` (LiteLLM-derived per-M-token + * prices) via the existing `lookupModelCapability` service. */ +/** Sentinel for usage that cannot be attributed to a concrete model id. */ +export const UNKNOWN_MODEL = "(unknown)"; + interface Usage { readonly inputTokens: number; readonly outputTokens: number; @@ -50,16 +59,25 @@ interface TurnAgg { outputTokens: number; } +interface ModelAgg { + readonly model: string; + calls: number; + inputTokens: number; + outputTokens: number; +} + interface CostModel { readonly totalCalls: number; readonly totalInput: number; readonly totalOutput: number; readonly byRuntime: readonly RuntimeAgg[]; readonly byTurn: readonly TurnAgg[]; + readonly byModel: readonly ModelAgg[]; } export function aggregate(turns: readonly VisibleTurn[]): CostModel { const runtimeMap = new Map(); + const modelMap = new Map(); const byTurn: TurnAgg[] = []; let totalCalls = 0; let totalInput = 0; @@ -69,12 +87,32 @@ export function aggregate(turns: readonly VisibleTurn[]): CostModel { let turnCalls = 0; let turnInput = 0; let turnOutput = 0; + // Sequential pairing: remember the model announced by the latest + // `llm.calling` per runtime so the next `llm.responded` inherits it. + const lastModelByRuntime = new Map(); for (const event of turn.events) { + const payload = event.payload as Record; + if (event.type === "llm.calling") { + const rid = payload.runtimeId as string | undefined; + const mdl = payload.model as string | undefined; + if (rid && mdl) lastModelByRuntime.set(rid, mdl); + continue; + } const usage = readUsage(event); if (!usage) continue; - const payload = event.payload as Record; const runtimeId = (payload.runtimeId as string) || "(unknown)"; const pluginId = (payload.pluginId as string) || ""; + const model = + event.type === "llm.responded" + ? (lastModelByRuntime.get(runtimeId) ?? UNKNOWN_MODEL) + : UNKNOWN_MODEL; + const prevModel = modelMap.get(model); + modelMap.set(model, { + model, + calls: (prevModel?.calls ?? 0) + 1, + inputTokens: (prevModel?.inputTokens ?? 0) + usage.inputTokens, + outputTokens: (prevModel?.outputTokens ?? 0) + usage.outputTokens, + }); // Immutable accumulate: replace the map entry with a fresh object // rather than mutating the stored one in place. const prev = runtimeMap.get(runtimeId); @@ -106,7 +144,71 @@ export function aggregate(turns: readonly VisibleTurn[]): CostModel { const byRuntime = [...runtimeMap.values()].sort( (a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), ); - return { totalCalls, totalInput, totalOutput, byRuntime, byTurn }; + const byModel = [...modelMap.values()].sort( + (a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ); + return { totalCalls, totalInput, totalOutput, byRuntime, byTurn, byModel }; +} + +// ── Pricing ────────────────────────────────────────────────────── + +interface ModelPrice { + readonly inputPerMToken?: number; + readonly outputPerMToken?: number; +} + +/** Module-level lookup cache — model prices don't change within a page visit. */ +const priceCache = new Map>(); + +function lookupPrice(model: string): Promise { + const cached = priceCache.get(model); + if (cached) return cached; + const promise = lookupModelCapability(model) + .then((cap) => + cap && + (cap.inputPerMToken !== undefined || cap.outputPerMToken !== undefined) + ? { + ...(cap.inputPerMToken !== undefined + ? { inputPerMToken: cap.inputPerMToken } + : {}), + ...(cap.outputPerMToken !== undefined + ? { outputPerMToken: cap.outputPerMToken } + : {}), + } + : null, + ) + .catch(() => null); + priceCache.set(model, promise); + return promise; +} + +/** Resolve per-M-token prices for the given models (null = no pricing data). */ +function useModelPrices( + models: readonly string[], +): Record { + const [prices, setPrices] = useState>({}); + useEffect(() => { + let cancelled = false; + const missing = models.filter((m) => m !== UNKNOWN_MODEL && !(m in prices)); + if (missing.length === 0) return; + void Promise.all( + missing.map(async (m) => [m, await lookupPrice(m)] as const), + ).then((entries) => { + if (cancelled) return; + setPrices((prev) => ({ ...prev, ...Object.fromEntries(entries) })); + }); + return () => { + cancelled = true; + }; + }, [models, prices]); + return prices; +} + +function costUsd(agg: ModelAgg, price: ModelPrice): number { + return ( + (agg.inputTokens / 1_000_000) * (price.inputPerMToken ?? 0) + + (agg.outputTokens / 1_000_000) * (price.outputPerMToken ?? 0) + ); } function fmt(n: number): string { @@ -143,6 +245,27 @@ export function CostPanel({ 1, ...model.byRuntime.map((r) => r.inputTokens + r.outputTokens), ); + const modelIds = useMemo( + () => model.byModel.map((m) => m.model), + [model.byModel], + ); + const prices = useModelPrices(modelIds); + const cost = useMemo(() => { + let usd = 0; + let pricedTokens = 0; + let unpricedTokens = 0; + for (const agg of model.byModel) { + const price = agg.model === UNKNOWN_MODEL ? null : prices[agg.model]; + const tokens = agg.inputTokens + agg.outputTokens; + if (price) { + usd += costUsd(agg, price); + pricedTokens += tokens; + } else { + unpricedTokens += tokens; + } + } + return { usd, pricedTokens, unpricedTokens }; + }, [model.byModel, prices]); if (model.totalCalls === 0) { return ( @@ -200,15 +323,53 @@ export function CostPanel({ label={t("debugger.cost.calls", "LLM calls")} value={fmt(model.totalCalls)} /> + {cost.pricedTokens > 0 && ( + 0 ? "≥ " : "≈ "}$${cost.usd.toFixed(4)}`} + /> + )}

{t( "debugger.cost.note", - "Token sums from llm.responded / gateway.responded. USD cost is a pending follow-up (model id not yet in the trace payload).", + "Token sums from llm.responded / gateway.responded. USD cost estimated from model-db prices; usage without a model id or price is excluded (cost shown as a lower bound).", )}

+ {/* By model */} + {model.byModel.length > 0 && ( +
+

+ {t("debugger.cost.byModel", "By model")} +

+
+ {model.byModel.map((m) => { + const price = m.model === UNKNOWN_MODEL ? null : prices[m.model]; + return ( +
+ + {m.model === UNKNOWN_MODEL + ? t("debugger.cost.unknownModel", "(unknown model)") + : m.model} + + + {fmt(m.inputTokens)} → {fmt(m.outputTokens)} · {m.calls}× ·{" "} + {price + ? `$${costUsd(m, price).toFixed(4)}` + : t("debugger.cost.noPrice", "no price")} + +
+ ); + })} +
+
+ )} + {/* By runtime */}

diff --git a/apps/web/src/routes/debug/__tests__/-cost-panel.test.ts b/apps/web/src/routes/debug/__tests__/-cost-panel.test.ts index 5975da43..666b3463 100644 --- a/apps/web/src/routes/debug/__tests__/-cost-panel.test.ts +++ b/apps/web/src/routes/debug/__tests__/-cost-panel.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type * as api from "@/services/api.js"; -import { aggregate } from "../-cost-panel.js"; +import { aggregate, UNKNOWN_MODEL } from "../-cost-panel.js"; import type { VisibleTurn } from "../-debug-page-model.js"; function evt(type: string, payload: Record): api.TraceEvent { @@ -99,5 +99,91 @@ describe("cost-panel aggregate", () => { expect(m.totalCalls).toBe(0); expect(m.byRuntime).toHaveLength(0); expect(m.byTurn).toHaveLength(0); + expect(m.byModel).toHaveLength(0); + }); + + it("attributes llm.responded usage to the preceding llm.calling model", () => { + const turns = [ + turn(1, "turn-1", [ + evt("llm.calling", { runtimeId: "narrator", model: "deepseek-chat" }), + evt("llm.responded", { + runtimeId: "narrator", + pluginId: "narrator", + ...usage(100, 50), + }), + // Second tool-loop round on the same runtime, different model — + // sequential pairing must pick the latest calling event. + evt("llm.calling", { runtimeId: "narrator", model: "gpt-4o" }), + evt("llm.responded", { + runtimeId: "narrator", + pluginId: "narrator", + ...usage(30, 10), + }), + ]), + ]; + + const m = aggregate(turns); + + expect(m.byModel).toHaveLength(2); + const deepseek = m.byModel.find((x) => x.model === "deepseek-chat")!; + expect(deepseek.inputTokens).toBe(100); + expect(deepseek.outputTokens).toBe(50); + const gpt = m.byModel.find((x) => x.model === "gpt-4o")!; + expect(gpt.inputTokens).toBe(30); + expect(gpt.calls).toBe(1); + }); + + it("groups unattributable usage under the unknown-model bucket", () => { + const turns = [ + turn(1, "turn-1", [ + // gateway.responded carries no model id. + evt("gateway.responded", { + runtimeId: "img", + pluginId: "img", + ...usage(5, 1), + }), + // llm.responded with no prior llm.calling for that runtime. + evt("llm.responded", { + runtimeId: "orphan", + pluginId: "orphan", + ...usage(7, 2), + }), + ]), + ]; + + const m = aggregate(turns); + + expect(m.byModel).toHaveLength(1); + expect(m.byModel[0]!.model).toBe(UNKNOWN_MODEL); + expect(m.byModel[0]!.inputTokens).toBe(12); + expect(m.byModel[0]!.calls).toBe(2); + }); + + it("does not leak model pairing across turns", () => { + const turns = [ + turn(1, "turn-1", [ + evt("llm.calling", { runtimeId: "narrator", model: "deepseek-chat" }), + evt("llm.responded", { + runtimeId: "narrator", + pluginId: "narrator", + ...usage(10, 5), + }), + ]), + turn(2, "turn-2", [ + // No llm.calling this turn — must NOT inherit turn-1's model. + evt("llm.responded", { + runtimeId: "narrator", + pluginId: "narrator", + ...usage(20, 8), + }), + ]), + ]; + + const m = aggregate(turns); + + const deepseek = m.byModel.find((x) => x.model === "deepseek-chat")!; + expect(deepseek.inputTokens).toBe(10); + const unknown = m.byModel.find((x) => x.model === UNKNOWN_MODEL)!; + expect(unknown.inputTokens).toBe(20); }); }); diff --git a/apps/web/src/services/api/llm.ts b/apps/web/src/services/api/llm.ts index 636669bb..7355049e 100644 --- a/apps/web/src/services/api/llm.ts +++ b/apps/web/src/services/api/llm.ts @@ -37,6 +37,14 @@ export interface ModelCapabilityInfo { contextWindow?: number; maxOutputTokens?: number; pricing?: ModelPricing; + /** + * Flat per-M-token prices as actually returned by + * `GET /api/model-db/lookup` (see routes/model-db.ts) — that endpoint + * sends these alongside contextWindow/maxOutputTokens instead of a + * nested `pricing` object. + */ + inputPerMToken?: number; + outputPerMToken?: number; } export interface LlmSlotInfo { @@ -92,32 +100,10 @@ export interface ModelDbInfo { source?: string; } -export interface ModelDbSearchResult { - id: string; - input: InputModality[]; - output: OutputModality[]; - features: ModelFeature[]; - contextWindow: number; - maxOutputTokens: number; - mode: string; - inputPerMToken?: number; - outputPerMToken?: number; -} - export async function fetchModelDbInfo(): Promise { return request("/api/model-db"); } -export async function searchModelDb( - query: string, - limit = 20, -): Promise { - const res = await request<{ results: ModelDbSearchResult[] }>( - `/api/model-db/search?q=${encodeURIComponent(query)}&limit=${limit}`, - ); - return res.results; -} - export async function lookupModelCapability( model: string, provider?: string, diff --git a/docs/guide/env-registry.md b/docs/guide/env-registry.md index cd8c0c8c..9e5fd08d 100644 --- a/docs/guide/env-registry.md +++ b/docs/guide/env-registry.md @@ -10,16 +10,16 @@ Covel 的环境变量清单由 `packages/shared/src/env/registry.ts` 维护。 ## 分组 -| group | 用途 | -| ----------- | ------------------------------------------------------------ | -| `storage` | `STORE_BACKEND`、`DATABASE_URL`、SQLite / PostgreSQL 配置 | -| `server` | 端口、CORS、静态资源、部署层级、限流 | -| `desktop` | Electron 注入给 server sidecar 的路径与桌面模式配置 | -| `ai` | LLM 配置、provider keys、Langfuse、模型数据库、prompt 根目录 | -| `feature` | 运行期功能开关 | -| `web` | Vite dev proxy 与浏览器侧公开变量 | -| `test` | Playwright、live provider tests、e2e harness、开发脚本 | -| `packaging` | Electron 签名、公证、updater 密钥 | +| group | 用途 | +| ----------- | --------------------------------------------------------- | +| `storage` | `STORE_BACKEND`、`DATABASE_URL`、SQLite / PostgreSQL 配置 | +| `server` | 端口、CORS、静态资源、部署层级、限流 | +| `desktop` | Electron 注入给 server sidecar 的路径与桌面模式配置 | +| `ai` | LLM 配置、provider keys、模型数据库、prompt 根目录 | +| `feature` | 运行期功能开关 | +| `web` | Vite dev proxy 与浏览器侧公开变量 | +| `test` | Playwright、live provider tests、e2e harness、开发脚本 | +| `packaging` | Electron 签名、公证、updater 密钥 | ## 状态 @@ -57,6 +57,7 @@ Covel 的环境变量清单由 `packages/shared/src/env/registry.ts` 维护。 - `COVEL_BIND_HOST` 默认 `127.0.0.1`(audit S-02):本地 / 桌面部署只监听回环接口,网络上不可达。容器或多 pod 部署需显式设置 `COVEL_BIND_HOST=0.0.0.0`(`docker/docker-compose.yml` 已内置)——这是一次显式的部署决策,公开监听前请确认 `DEPLOYMENT_TIER` 与鉴权配置。 - `TRUSTED_PROXY_IPS`、`COVEL_LLM_REPLAY`、`COVEL_LLM_REPLAY_DIR`、`COVEL_ALLOWED_LLM_HOSTS` 目前标记为 `documented`,后续实现可以直接提升为 `active`。 - `COVEL_TRACE_TRUNCATE` 标记为 `planned`,对应 debug trace 设计文档中的未来开关。 +- `COVEL_COMPACTOR_CONTEXT_WINDOW` 为**可选的显式覆盖**:未设置时,压缩阈值与 prompt 硬裁剪预算按当前叙事 slot(`default`,缺省为 llm.toml 首个 slot)的模型 capability `contextWindow` 动态解析(llm.toml 热重载即时生效),capability 也缺失时回退 `32768`。设置后固定使用该值,不再查 capability。 - `COVEL_SNAPSHOT_INTERVAL_TURNS` 默认 `5`,控制 `kind=auto` 快照的 checkpoint 节奏:`turnCount <= 1`(pre-game 与首个正式回合)总是写入,其后每 N 回合写一份;`1` 表示每回合。resume 路径无视该节流强制写入。构建快照 payload 需要全量读取消息历史与全部 session-scoped 集合,逐回合写入会导致 O(T²) 成本与存储膨胀(audit 2026-07-11 R-04)。 - `COVEL_MEDIA_CLEANUP_ENABLED` 默认 `false`,控制 `POST /api/media/cleanup` 的可用性。即使设为 `true`,`DEPLOYMENT_TIER=commercial` 时该端点仍强制 503,等待管理员鉴权中间件接入。详见 [`docs/reference/api.md`](../reference/api.md) 媒体管理章节。 - `COVEL_PG_LOCK_POOL_MAX` 默认 `16`,控制 PG advisory session-lock 专用连接池的 `max`。每个进行中的 turn 占用一条 reserved 连接;多并发 pod 可按峰值并发会话数调大。锁获取超时(30s)覆盖连接池排队 + advisory lock 轮询全程。 diff --git a/packages/context/src/budget.ts b/packages/context/src/budget.ts index 171f82c5..ba79f1e1 100644 --- a/packages/context/src/budget.ts +++ b/packages/context/src/budget.ts @@ -36,6 +36,28 @@ function flattenContent(content: string | readonly ContentPart[]): string { */ export type TokenEstimator = (text: string) => number; +/** + * CJK ranges: radicals/kana/ideographs (2E80–9FFF), Hangul syllables + * (AC00–D7AF), compatibility ideographs (F900–FAFF), fullwidth forms + * (FF00–FFEF). + */ +const CJK_RE = /[⺀-鿿가-힯豈-﫿＀-￯]/g; + +/** + * Default character-heuristic token estimator, CJK-aware. + * + * The naive `chars / 4` rule undercounts CJK text ~3× (CJK runs at roughly + * 1–1.7 characters per token across common tokenizers), which let Chinese + * sessions blow past the real model window long before the estimate tripped + * any threshold. Count CJK characters at 1 token each (deliberately on the + * high side — overestimating triggers compaction early, which is the safe + * direction) and everything else at 4 chars per token. + */ +export function estimateTokens(text: string): number { + const cjkCount = text.match(CJK_RE)?.length ?? 0; + return cjkCount + Math.ceil((text.length - cjkCount) / 4); +} + /** Configuration for a single {@link applyBudget} call. */ export interface BudgetOptions { /** diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 34702adc..44a04170 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -27,7 +27,7 @@ export { export type { PromptSegments } from "./prompt-assembler.js"; // ── Token Budget ──────────────────────────────────────────────── -export { applyBudget } from "./budget.js"; +export { applyBudget, estimateTokens } from "./budget.js"; export type { TokenEstimator, BudgetOptions, BudgetResult } from "./budget.js"; // ── Prompt Loader ──────────────────────────────────────────────── diff --git a/packages/context/tests/budget.test.ts b/packages/context/tests/budget.test.ts index a9ef3b4a..1d9c5ca1 100644 --- a/packages/context/tests/budget.test.ts +++ b/packages/context/tests/budget.test.ts @@ -1,7 +1,28 @@ import { describe, it, expect } from "vitest"; -import { applyBudget } from "@covel/context"; +import { applyBudget, estimateTokens } from "@covel/context"; import type { TokenEstimator } from "@covel/context"; +describe("estimateTokens", () => { + it("estimates ASCII text at 4 chars per token", () => { + expect(estimateTokens("a".repeat(40))).toBe(10); + }); + + it("estimates CJK text at 1 token per character", () => { + expect(estimateTokens("春眠不觉晓")).toBe(5); + expect(estimateTokens("こんにちは")).toBe(5); + expect(estimateTokens("안녕하세요")).toBe(5); + }); + + it("handles mixed CJK and ASCII content", () => { + // 4 CJK chars (4 tokens) + 8 ASCII chars (2 tokens) + expect(estimateTokens("你好世界hello wo")).toBe(6); + }); + + it("returns 0 for empty string", () => { + expect(estimateTokens("")).toBe(0); + }); +}); + // Deterministic mock estimator: 1 token per 4 characters (rounded up). const mockEstimator: TokenEstimator = (text) => Math.ceil(text.length / 4); diff --git a/packages/shared/src/env/groups/feature.ts b/packages/shared/src/env/groups/feature.ts index fd9a247a..7e4db42a 100644 --- a/packages/shared/src/env/groups/feature.ts +++ b/packages/shared/src/env/groups/feature.ts @@ -6,8 +6,9 @@ export const FEATURE_ENV_VARS = [ group: "feature", type: "integer", status: "active", - defaultValue: "32768", - description: "Compactor context window used for threshold comparisons.", + defaultValue: "(narrative slot model capability, else 32768)", + description: + "Explicit override for the context window used by the compactor and prompt budget. When unset, the active narrative slot's model capability contextWindow is used.", }, { name: "COVEL_SNAPSHOT_INTERVAL_TURNS", diff --git a/packages/shared/src/env/registry-readers.ts b/packages/shared/src/env/registry-readers.ts index b12c915e..c6d179ce 100644 --- a/packages/shared/src/env/registry-readers.ts +++ b/packages/shared/src/env/registry-readers.ts @@ -198,10 +198,21 @@ export function readRuntimeEnv(source: EnvSource = defaultSource()) { logsDir: readEnvString("COVEL_LOGS_DIR", undefined, source), modelDbPath: readEnvString("COVEL_MODEL_DB_PATH", undefined, source), promptsDir: readEnvString("COVEL_PROMPTS_DIR", undefined, source), - compactorContextWindow: readEnvInt( + // Optional explicit override. When unset (undefined) the server derives + // the compaction window from the active narrative slot's model capability. + compactorContextWindow: readEnvIntOptional( "COVEL_COMPACTOR_CONTEXT_WINDOW", - 32768, source, ), }; } + +function readEnvIntOptional( + name: string, + source: EnvSource, +): number | undefined { + const raw = readEnvString(name, undefined, source); + if (raw === undefined) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/packages/shared/tests/env-registry.test.ts b/packages/shared/tests/env-registry.test.ts index 696b7840..3c592814 100644 --- a/packages/shared/tests/env-registry.test.ts +++ b/packages/shared/tests/env-registry.test.ts @@ -213,7 +213,9 @@ describe("env registry", () => { expect(env.nodeEnv).toBe("development"); expect(env.serverPort).toBe(3001); expect(env.rateLimitRpm).toBe(60); - expect(env.compactorContextWindow).toBe(32768); + // Invalid → undefined (unset): the compactor then derives the window from + // the narrative slot's model capability instead of a fixed constant. + expect(env.compactorContextWindow).toBeUndefined(); }); it("lowercase-normalizes DEPLOYMENT_TIER", () => { From f275a3d19af3e490b03ed54ee18377686c888fe3 Mon Sep 17 00:00:00 2001 From: ackness Date: Fri, 17 Jul 2026 11:03:31 +0800 Subject: [PATCH 02/10] refactor: remove dead code and legacy residue across framework and apps Deletions verified zero-reference (.ts and .js cross-checked; plugins are plain JS): - memory: unused compactor chain (production compaction lives in @covel/context maybeCompact) incl. CompactionConfig/Result types - ai-provider: never-wired Langfuse trace hook + LANGFUSE_* env entries - store: 18 superseded row-mapper re-exports (both SQL backends), mediaObjectKey, dead ALL_TABLE_NAMES re-export - shared: orphan plugin type modules (character-presence, branch-reply, living-world-rules) - runtime: HookPipelineRun, backward-compat re-export block in index.ts, collectEventsFrom export demoted to private - create: createPlugin chain (CLI scaffold is an independent script); createWorld path kept - plugin-test-utils: createTestHarness retired (documented rejection in scene-stage integration test); unused deps dropped, lockfile synced - web: lorebook client (backend routes stay), schema-block-renderer, searchModelDb, fetchPluginDocs, getPluginData, syncSessionDimensions, legacy fetchTraceEvents, browser media-store, LEGACY_EVENTS fallback, LEGACY_SLOTS renamed to DEFAULT_FALLBACK_SLOTS - server: pathExists, ui-spec-schema dead exports demoted, loadWorldPackages export demoted, withSessionLock ghost comments fixed - root configs: stale apps/desktop-tauri entries - docs: plugin-testing guide rewritten around the patterns actually in use; flow/authoring guides and create-plugin skill references synced; CHANGELOG CI wording matched to the real workflow --- .claude/skills/create-plugin/SKILL.md | 2 +- .../references/plugin-testing.md | 84 +--- .gitignore | 1 - .pre-commit-config.yaml | 2 +- .prettierignore | 2 - apps/server/src/routes/api/install/shared.ts | 18 +- apps/server/src/routes/api/resume.ts | 6 +- .../src/routes/misc-api/ui-spec-schema.ts | 8 +- apps/server/src/world-seed-loader.ts | 4 +- .../tests/api/chat-mode-http-e2e.test.ts | 2 +- .../src/components/blocks/block-renderer.tsx | 49 +-- .../blocks/schema-block-renderer.tsx | 392 ----------------- apps/web/src/lib/desktop-bridge.ts | 35 +- .../__tests__/api-explicit-auth.test.ts | 2 +- apps/web/src/services/api.ts | 1 - apps/web/src/services/api/lorebook.ts | 83 ---- apps/web/src/services/api/packages.ts | 18 - apps/web/src/services/api/plugin-data.ts | 17 - apps/web/src/services/api/traces.ts | 9 - apps/web/src/services/api/worlds.ts | 12 - apps/web/src/services/storage/index.ts | 1 - apps/web/src/services/storage/media-store.ts | 11 - .../src/settings/panes/LlmAdvancedPane.tsx | 4 +- docs/CHANGELOG.md | 2 +- docs/architecture/flow.md | 4 +- docs/guide/plugin-authoring-advanced.md | 76 +--- docs/guide/plugin-authoring-agent.md | 92 +--- docs/guide/plugin-testing.md | 61 ++- packages/ai-provider/src/index.ts | 3 - packages/ai-provider/src/trace/langfuse.ts | 141 ------ packages/context/src/message-insertion.ts | 2 +- packages/create/src/create-plugin.test.ts | 201 --------- packages/create/src/create-plugin.ts | 410 ------------------ packages/create/src/index.ts | 6 - packages/create/src/world-writer.ts | 4 +- packages/memory/src/compactor.ts | 207 --------- packages/memory/src/index.ts | 5 +- packages/memory/src/memory-system.ts | 39 +- packages/memory/src/types.ts | 59 +-- packages/plugin-test-utils/package.json | 4 +- packages/plugin-test-utils/src/index.ts | 2 - .../plugin-test-utils/src/test-harness.ts | 147 ------- .../tests/plugin-test-utils.test.ts | 38 -- packages/runtime/src/hooks/types.ts | 16 - packages/runtime/src/index.ts | 7 - .../runtime/src/trigger/turn-event-chain.ts | 2 +- packages/shared/src/env/groups/ai.ts | 23 - packages/shared/src/types/branch-reply.ts | 90 ---- .../shared/src/types/character-presence.ts | 31 -- packages/shared/src/types/index.ts | 32 -- .../shared/src/types/living-world-rules.ts | 55 --- packages/store/src/media-store/utils.ts | 6 - .../store/src/postgres/pg-store-mappers.ts | 89 +--- .../store/src/sqlite/sqlite-store-mappers.ts | 88 +--- pnpm-lock.yaml | 6 - 55 files changed, 140 insertions(+), 2571 deletions(-) delete mode 100644 apps/web/src/components/blocks/schema-block-renderer.tsx delete mode 100644 apps/web/src/services/api/lorebook.ts delete mode 100644 apps/web/src/services/storage/media-store.ts delete mode 100644 packages/ai-provider/src/trace/langfuse.ts delete mode 100644 packages/create/src/create-plugin.test.ts delete mode 100644 packages/create/src/create-plugin.ts delete mode 100644 packages/memory/src/compactor.ts delete mode 100644 packages/plugin-test-utils/src/test-harness.ts delete mode 100644 packages/shared/src/types/branch-reply.ts delete mode 100644 packages/shared/src/types/character-presence.ts delete mode 100644 packages/shared/src/types/living-world-rules.ts diff --git a/.claude/skills/create-plugin/SKILL.md b/.claude/skills/create-plugin/SKILL.md index d45c5f1c..5bb83250 100644 --- a/.claude/skills/create-plugin/SKILL.md +++ b/.claude/skills/create-plugin/SKILL.md @@ -198,7 +198,7 @@ console.log('OK'); | ----------------------------------------------- | -------------------------------------------------------------------------- | | 只有 PLUGIN.md(agent runtime + builtin tools) | 只跑 schema 校验(step 4)即可 | | 有 `tools/*.js` / `handler.js` / `hooks/*.js` | + L2 单元测试(vitest,mock store) | -| 有 `input.inject` / 多 runtime / event 链 | + L3 集成测试(`createTestHarness` + `MockLLM`)或 L4 runtime case | +| 有 `input.inject` / 多 runtime / event 链 | + L3 集成测试(手搓 turn-executor + `MockLLM`)或 L4 runtime case | | 准备发布对外(社区插件) | + L4 `pnpm test:runtime` mock/live;必要时 L5 HTTP E2E,**live 不要进 CI** | 测试文件放 `plugins//tests/*.test.{js,ts}`,跑: diff --git a/.claude/skills/create-plugin/references/plugin-testing.md b/.claude/skills/create-plugin/references/plugin-testing.md index eee8b48f..9efdb7f9 100644 --- a/.claude/skills/create-plugin/references/plugin-testing.md +++ b/.claude/skills/create-plugin/references/plugin-testing.md @@ -6,7 +6,7 @@ | ------------------ | ------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | | L1 Schema | `validatePluginManifest` | 即时 | 每个 `PLUGIN.md` | | L2 单元 | Vitest + `@covel/plugin-test-utils` | <1s | 写了 `tools/*.js`、`handler.js`、`hooks/*.js` | -| L3 In-process turn | `createTestHarness` + `MockLLM` | 1-3s | agent runtime、tool loop、`input.inject`、多 runtime/event 链 | +| L3 In-process turn | 手搓 turn-executor + `MockLLM` | 1-3s | agent runtime、tool loop、`input.inject`、多 runtime/event 链 | | L4 Runtime cases | `pnpm test:runtime` (`@covel/test-runtime`) | 1-10s mock / 真实 provider 更慢 | 第三方插件、手动 runtime、后台 follower、外部 `~/.covel/plugins` | | L5 HTTP E2E | `scripts/e2e-plugin-verify.ts` | 30s+ | 发布前验证 API/SSE/store/approval 全链路 | @@ -185,81 +185,25 @@ describe("trigger", () => { --- -## L3 — `createTestHarness` + `MockLLM` +## L3 — 手搓 turn-executor + `MockLLM` -用于跑完整 runtime pipeline:发现插件 → 排序 manifests → 组装 context → 调 LLM/tool loop → commit proposals → 写 MemoryStore。 +> 旧的 `createTestHarness` 已退役(功能不足:无法注入合成 runtime,也不 commit proposal)。需要 in-process 跑完整 turn 时,用 `@covel/runtime` 公开导出手工组装;多数插件级需求优先走 L4(`pnpm test:runtime`)。 -```ts -import { describe, expect, it } from "vitest"; -import path from "node:path"; -import { - MockLLM, - createTestHarness, - expectAssetGenerated, -} from "@covel/plugin-test-utils"; - -const PLUGINS_DIR = path.resolve(import.meta.dirname, "../../../plugins"); - -describe("my-plugin integration", () => { - it("runs one turn and writes plugin-data", async () => { - const llm = new MockLLM({ - responses: [ - { - content: null, - finishReason: "tool_calls", - toolCalls: [ - { - id: "tc-1", - name: "plugin-data-set", - arguments: - '{"namespace":"notes","key":"intro","value":{"title":"Intro"}}', - }, - ], - usage: { inputTokens: 50, outputTokens: 10 }, - }, - { - content: "Done.", - finishReason: "stop", - toolCalls: [], - usage: { inputTokens: 60, outputTokens: 4 }, - }, - ], - }); +组装步骤(完整可运行范例:`packages/runtime/tests/scene-stage-integration.test.ts` 与 `packages/runtime/tests/emit-event-integration.test.ts`): - const harness = await createTestHarness({ - pluginsDir: PLUGINS_DIR, - activePlugins: ["my-plugin"], - llm, - }); - - const result = await harness.executeTurn("continue"); - const rows = await harness.store.listPluginData( - "sess-harness", - "my-plugin", - "notes", - ); - - expect(result.runtimeResults[0]?.status).toBe("success"); - expect(llm.calls.map((call) => call.toolNames ?? [])).toContainEqual( - expect.arrayContaining(["plugin-data-set"]), - ); - expect(rows[0]?.key).toBe("intro"); - }); -}); -``` +1. `discoverPlugins` / `loadPluginManifest` / `loadRuntime`(`@covel/plugin-loader`)加载真实插件 runtime;需要合成 runtime(如模拟 narrator 发 event)时手写 `RuntimeManifest` + `LLMAdapter`。 +2. `createMemoryStore()`(`@covel/store`)做后端,按需 `setPluginData` 预置数据。 +3. `createToolExecutor` + `executeTurn`(`@covel/runtime`)执行 turn,LLM 用 `MockLLM`(`responses[]` 按调用顺序消费,耗尽后回落 `defaultResponse`,多步 tool loop 优先用它)。 +4. 对每个 runtime result 调 `processRuntimeResult`(`@covel/runtime`)把 proposal commit 进 store,再断言 `store.listPluginData(...)` 等持久化结果。 常用断言: -| 目标 | 写法 | -| --------------------- | -------------------------------------------------------------------- | -| prompt 里包含注入内容 | `llm.calls[n].messages` | -| 工具顺序 | `result.runtimeResults[0].toolCalls.map((c) => c.name)` | -| plugin_data 写入 | `harness.store.listPluginData("sess-harness", pluginId, namespace)` | -| asset.generate 输出 | `expectAssetGenerated(result, { modality: "image" })` | -| 手动触发 | `harness.executeTurn("", { manualTrigger: { runtimeId, payload } })` | -| userSettings | `harness.executeTurn("", { userSettings: { [pluginId]: {...} } })` | - -`MockLLM` 的 `responses[]` 会按调用顺序消费,耗尽后回落到 `defaultResponse`。多步 tool loop 优先用 `responses[]`,不用手写 subclass。 +| 目标 | 写法 | +| --------------------- | ------------------------------------------------------- | +| prompt 里包含注入内容 | `llm.calls[n].messages` | +| 工具顺序 | `result.runtimeResults[0].toolCalls.map((c) => c.name)` | +| plugin_data 写入 | `store.listPluginData(sessionId, pluginId, namespace)` | +| asset.generate 输出 | `expectAssetGenerated(result, { modality: "image" })` | --- diff --git a/.gitignore b/.gitignore index 80c3808f..936c3cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -116,7 +116,6 @@ tmp/ *.png !apps/web/public/icon.png !apps/desktop/resources/icon.png -!apps/desktop-tauri/src-tauri/icons/**/*.png # World character portraits and scene backdrops are tracked assets (generated once, reused). !worlds/**/media/portraits/*.png !worlds/**/media/scenes/*.png diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b02bd8e6..85fdf7f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: destroyed-symlinks - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - exclude: ^(\.claude/|apps/desktop-tauri/src-tauri/src/main\.rs$) + exclude: ^\.claude/ - id: check-added-large-files # 3 MB: covers tracked world character portraits (~1.7–2.4 MB PNGs) # while still catching accidental large blobs (videos, dumps, bundles). diff --git a/.prettierignore b/.prettierignore index 8d6a56de..2b8c92e1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,9 +13,7 @@ output/ audits/ debugs/ -apps/desktop-tauri/src-tauri/resources/ apps/desktop/staging/ -apps/desktop-tauri/src-tauri/target/ apps/web/src/routeTree.gen.ts pnpm-lock.yaml diff --git a/apps/server/src/routes/api/install/shared.ts b/apps/server/src/routes/api/install/shared.ts index d420ce61..db222d6f 100644 --- a/apps/server/src/routes/api/install/shared.ts +++ b/apps/server/src/routes/api/install/shared.ts @@ -10,14 +10,7 @@ * - Target directory must not already exist (409) — upgrades require manual removal. */ -import { - mkdir, - mkdtemp, - rename, - rm, - writeFile, - access, -} from "node:fs/promises"; +import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import yauzl, { type Entry, type ZipFile } from "yauzl"; import { errorBody } from "../../../api-error.js"; @@ -72,15 +65,6 @@ export interface ExtractedEntry { readonly content: Buffer; } -export async function pathExists(p: string): Promise { - try { - await access(p); - return true; - } catch { - return false; - } -} - /** * Inspect the Unix file mode encoded in a zip entry's external attributes. * Returns `null` when the entry was produced by a non-Unix tool (mode=0). diff --git a/apps/server/src/routes/api/resume.ts b/apps/server/src/routes/api/resume.ts index 5e9a52c3..feb1bdee 100644 --- a/apps/server/src/routes/api/resume.ts +++ b/apps/server/src/routes/api/resume.ts @@ -12,8 +12,10 @@ * before entering the LLM tool loop. Concurrent requests with the same * suspensionId lose the race and receive 409. This guarantees * exactly-once execution of a suspended runtime. - * - The pipeline also runs under `withSessionLock(sessionId)` so sequential - * resumes for the same session do not interleave with turn execution. + * - The pipeline also runs under the injected `sessionLock` (see + * `env.d.ts`; historically a `withSessionLock` import, now DI-provided) + * so sequential resumes for the same session do not interleave with turn + * execution. * * Expiry (S4-T4.c): the suspension-touching routes opportunistically fire a * time-gated, best-effort global sweep of stale (unresolved, older-than-TTL) diff --git a/apps/server/src/routes/misc-api/ui-spec-schema.ts b/apps/server/src/routes/misc-api/ui-spec-schema.ts index 65e7d42c..a0dbd8f1 100644 --- a/apps/server/src/routes/misc-api/ui-spec-schema.ts +++ b/apps/server/src/routes/misc-api/ui-spec-schema.ts @@ -20,7 +20,7 @@ import type { UiSlotName } from "./shared.js"; * explicit diagnostic, so a newer plugin pack on an older server degrades * loudly instead of silently rendering a broken panel. */ -export const CURRENT_UI_SPEC_VERSION = 1; +const CURRENT_UI_SPEC_VERSION = 1; const i18nTextSchema = z.union([z.string(), z.record(z.string(), z.string())]); @@ -29,7 +29,7 @@ const i18nTextSchema = z.union([z.string(), z.record(z.string(), z.string())]); * (forward compatibility) — callers pass the ORIGINAL spec through to the * frontend untouched, so Zod's stripping never mutates what ships. */ -export const uiSpecSchema = z +const uiSpecSchema = z .object({ specVersion: z .number() @@ -89,7 +89,7 @@ export interface UiSpecDiagnostic { } /** Pull a spec's declared `id` for diagnostic labelling, if any. */ -export function uiSpecId(spec: unknown): string | undefined { +function uiSpecId(spec: unknown): string | undefined { if (spec && typeof spec === "object") { const id = (spec as Record).id; if (typeof id === "string" && id.length > 0) return id; @@ -98,7 +98,7 @@ export function uiSpecId(spec: unknown): string | undefined { } /** Validate one spec, returning concrete per-field issues on failure. */ -export function validateUiSpec( +function validateUiSpec( spec: unknown, ): { ok: true } | { ok: false; issues: UiSpecIssue[] } { const result = uiSpecSchema.safeParse(spec); diff --git a/apps/server/src/world-seed-loader.ts b/apps/server/src/world-seed-loader.ts index 2053de55..fc71b5a8 100644 --- a/apps/server/src/world-seed-loader.ts +++ b/apps/server/src/world-seed-loader.ts @@ -374,9 +374,7 @@ async function loadCharacterBlueprints( * Validates each world.yaml against worldManifestSchema. * Returns WorldRecord[] ready for upsert. */ -export async function loadWorldPackages( - worldsDir: string, -): Promise { +async function loadWorldPackages(worldsDir: string): Promise { if (!(await fileExists(worldsDir))) return []; const entries = await readdir(worldsDir, { withFileTypes: true }); diff --git a/apps/server/tests/api/chat-mode-http-e2e.test.ts b/apps/server/tests/api/chat-mode-http-e2e.test.ts index b1052ce8..817f9177 100644 --- a/apps/server/tests/api/chat-mode-http-e2e.test.ts +++ b/apps/server/tests/api/chat-mode-http-e2e.test.ts @@ -6,8 +6,8 @@ import type { LLMAdapter, LLMResponse, LLMToolDefinition, - LLMMessage, } from "@covel/runtime"; +import type { LLMMessage } from "@covel/shared"; import { createMemoryStore } from "@covel/store"; import { bootstrapApi } from "../../src/routes/api/bootstrap.js"; import { loadSingleWorld } from "../../src/world-seed-loader.js"; diff --git a/apps/web/src/components/blocks/block-renderer.tsx b/apps/web/src/components/blocks/block-renderer.tsx index a9606fe9..1c60a7e5 100644 --- a/apps/web/src/components/blocks/block-renderer.tsx +++ b/apps/web/src/components/blocks/block-renderer.tsx @@ -1,35 +1,19 @@ /** - * Block renderer — schema + raw fallback only. + * Block schema registry. * * 消息渲染已经全部走 json-render + plugin-data 数据流(见 - * `apps/web/src/components/session/chat-messages.tsx`)。本模块只保留两个 - * 对外契约: - * - * 1. `setBlockSchemas(schemas)` — 供 session-store 在 boot 时注入插件声明的 - * blockSchemas,schema-block-renderer 用它为没有 json-render spec 的历史 - * block 兜底渲染。 - * 2. `getBlockRenderer(blockType)` — 返回 schema-driven renderer(若有) - * 或 null;不再维护硬编码的 kebab→React 组件映射。 + * `apps/web/src/components/session/chat-messages.tsx`)。本模块只保留一个 + * 对外契约:`setBlockSchemas(schemas)` — 供 session-store 在 boot 时注入插件 + * 声明的 blockSchemas。 * * 所有自定义 React block 组件(action-guide-block / codex-entry-block / …) * 已被删除——插件用自己的 `ui/*.json` spec 通过 json-render 渲染。 */ -import { SchemaBlockRenderer } from "./schema-block-renderer.js"; import type { BlockSchemaDeclaration } from "@covel/shared"; -export interface BlockRendererProps { - data: Record; - onSubmit: (value: string) => void; - onSelect?: (value: string) => void; - selectedValue?: string | null; - disabled?: boolean; -} - -type BlockRendererComponent = React.ComponentType; - // Module-level mutable state: a singleton registry updated once at boot -// (via setBlockSchemas) and read by getBlockRenderer during render. +// (via setBlockSchemas). let blockSchemas: Record = {}; export function setBlockSchemas( @@ -37,26 +21,3 @@ export function setBlockSchemas( ) { blockSchemas = schemas; } - -/** - * Returns a component that can render the given block type using the - * plugin-declared schema. Returns null when no schema is registered — the - * caller should fall back to json-render (chat-messages.tsx) or raw JSON. - */ -export function getBlockRenderer( - blockType: string, -): BlockRendererComponent | null { - const schema = blockSchemas[blockType]; - if (!schema) return null; - - return function SchemaBlock(props: BlockRendererProps) { - return ( - - ); - }; -} diff --git a/apps/web/src/components/blocks/schema-block-renderer.tsx b/apps/web/src/components/blocks/schema-block-renderer.tsx deleted file mode 100644 index 76ea0a5d..00000000 --- a/apps/web/src/components/blocks/schema-block-renderer.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import { useState } from "react"; -import i18n from "@/i18n"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import type { BlockSchemaDeclaration } from "@covel/shared"; - -export interface SchemaBlockRendererProps { - schema: BlockSchemaDeclaration; - data: Record; - onSubmit: (value: string) => void; - disabled?: boolean; -} - -export function SchemaBlockRenderer({ - schema, - data, - onSubmit, - disabled, -}: SchemaBlockRendererProps) { - const [formValues, setFormValues] = useState>({}); - - if (schema.interactive && schema.submitSchema) { - return ( - - ); - } - - return ; -} - -// ── Display (read-only) ─────────────────────────────────────────── - -function DisplaySchemaBlock({ - schema, - data, -}: { - schema: BlockSchemaDeclaration; - data: Record; -}) { - const properties = (schema.dataSchema.properties ?? {}) as Record< - string, - Record - >; - - return ( - - - - {resolveDisplayName(schema.meta.displayName ?? "")} - - - - {Object.entries(properties).map(([key, propSchema]) => - renderDisplayField(key, propSchema, data[key]), - )} - - - ); -} - -function renderDisplayField( - key: string, - propSchema: Record, - value: unknown, -): React.ReactNode { - if (value === undefined || value === null) return null; - - const type = propSchema.type as string; - - if (type === "string") { - return ( -
-

- {key} -

-

{String(value)}

-
- ); - } - - if (type === "number" || type === "integer") { - return ( -
-

- {key} -

-

{String(value)}

-
- ); - } - - if (type === "boolean") { - return ( -
-

- {key} -

- - {value ? i18n.t("common.yes", "Yes") : i18n.t("common.no", "No")} - -
- ); - } - - if (type === "array" && Array.isArray(value)) { - const itemSchema = (propSchema.items ?? {}) as Record; - return ( -
-

- {key} -

-
- {/* Items lack stable IDs; index key is acceptable for static display */} - {value.map((item, i) => ( -
- {typeof item === "object" && item !== null - ? renderObjectFields( - item as Record, - itemSchema, - ) - : String(item)} -
- ))} -
-
- ); - } - - if (type === "object" && typeof value === "object") { - return ( -
-

- {key} -

-
- {renderObjectFields(value as Record, propSchema)} -
-
- ); - } - - return ( -
-

- {key} -

-

- {JSON.stringify(value)} -

-
- ); -} - -function renderObjectFields( - obj: Record, - schema: Record, -) { - const props = (schema.properties ?? {}) as Record< - string, - Record - >; - const keys = - Object.keys(props).length > 0 ? Object.keys(props) : Object.keys(obj); - return ( -
- {keys.map((k) => { - const val = obj[k]; - if (val === undefined || val === null) return null; - return ( -
- {k}: - - {typeof val === "object" ? JSON.stringify(val) : String(val)} - -
- ); - })} -
- ); -} - -// ── Interactive (form) ──────────────────────────────────────────── - -function InteractiveSchemaBlock({ - schema, - data, - formValues, - setFormValues, - onSubmit, - disabled, -}: { - schema: BlockSchemaDeclaration; - data: Record; - formValues: Record; - setFormValues: React.Dispatch>>; - onSubmit: (value: string) => void; - disabled?: boolean; -}) { - const properties = (schema.dataSchema.properties ?? {}) as Record< - string, - Record - >; - - const title = data.title as string | undefined; - const description = data.description as string | undefined; - - const handleFieldChange = (key: string, value: unknown) => { - setFormValues((prev) => ({ ...prev, [key]: value })); - }; - - const handleSubmit = () => { - const result = Object.keys(formValues).length > 0 ? formValues : data; - onSubmit(JSON.stringify(result)); - }; - - // Render interactive fields from data's array-type fields - const arrayFields = Object.entries(properties).filter( - ([, ps]) => ps.type === "array", - ); - - return ( - - - - {title ?? resolveDisplayName(schema.meta.displayName ?? "")} - - {description && ( -

{description}

- )} -
- - {/* Render simple string/number fields from data */} - {Object.entries(properties).map(([key, propSchema]) => { - if (propSchema.type === "array") return null; - const value = data[key]; - if (value === undefined || value === null) return null; - return renderDisplayField(key, propSchema, value); - })} - - {/* Render interactive form fields from array data */} - {arrayFields.map(([key, propSchema]) => { - const items = data[key] as Array> | undefined; - if (!items || !Array.isArray(items)) return null; - const itemSchema = (propSchema.items ?? {}) as Record< - string, - unknown - >; - - return ( -
- {items.map((item, idx) => { - const fieldKey = - (item.key as string) ?? (item.id as string) ?? String(idx); - const label = (item.label as string) ?? fieldKey; - const fieldType = (item.type as string) ?? "text"; - const placeholder = item.placeholder as string | undefined; - const options = item.options as string[] | undefined; - const required = item.required as boolean | undefined; - - return ( -
- - {renderFormInput({ - id: `schema-field-${fieldKey}`, - fieldType, - placeholder, - options, - value: formValues[fieldKey] as string | undefined, - onChange: (v) => handleFieldChange(fieldKey, v), - disabled, - })} -
- ); - })} -
- ); - })} - - -
-
- ); -} - -function renderFormInput({ - id, - fieldType, - placeholder, - options, - value, - onChange, - disabled, -}: { - id: string; - fieldType: string; - placeholder?: string; - options?: string[]; - value?: string; - onChange: (value: string) => void; - disabled?: boolean; -}) { - const baseClass = - "w-full bg-background border border-border px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary transition-all disabled:opacity-50"; - - if (fieldType === "select" && options) { - return ( - - ); - } - - if (fieldType === "textarea") { - return ( -