diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..8b52c31b --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,6 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "TaleSpinner_v1" + +[setup] +script = "yarn install:all" diff --git a/AGENTS.md b/AGENTS.md index a31a5a63..fa910096 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,99 +1,134 @@ # AGENTS.md - TaleSpinner -Purpose: repository-specific instructions for Codex agents. -(Назначение: правила работы агента именно в этом репозитории.) -## Quick Commands (run from repo root) -- Install deps: `yarn install:all` and `yarn install:docs` -- Dev (server + web): `yarn dev` -- Dev (docs): `yarn docs:dev` -- Build all app parts: `yarn build` +Repository contract for coding agents working in this repo. +Keep this file outcome-first: preserve the project architecture, make the requested change safely, validate what changed, and stop when the user's goal is handled. + +## Project Identity +- TaleSpinner is a local LLM application for storytelling, roleplay, and multi-agent scenario building. +- The product is built around isolated profiles, chats and branches, world info, instructions, operation pipelines, and optional RAG flows. +- The goal is not "one big prompt". The goal is a reproducible, controllable LLM workflow with clean context boundaries. + +## Stack And Commands +- Monorepo: `server` (Node.js + Express + TypeScript), `web` (Vite + React + TypeScript + Effector + Mantine), `docs` (Docusaurus), `shared` (shared contracts and utils), `data` (runtime data). +- Package manager: Yarn Classic 1.x only. Never switch a task to npm or pnpm. +- Docs require Node `>=20`. +- Install app deps: `yarn install:all` +- Install docs deps: `yarn install:docs` +- Run app: `yarn dev` +- Run docs: `yarn docs:dev` +- Build app: `yarn build` - Build docs: `yarn docs:build` - -## Stack Snapshot -- Monorepo: `server` (Node.js + Express + TypeScript), `web` (Vite + React + TypeScript + Effector + Mantine), `docs` (Docusaurus), `shared` (shared TS contracts). -- Package manager: Yarn Classic (1.x). Do not switch to npm/pnpm in tasks. -- Runtime note: docs require Node >= 20 (`docs/package.json` engines). +- Backend checks: `yarn typecheck:server`, `yarn lint:server`, `yarn --cwd server test`, `yarn build:server` +- Frontend checks: `yarn typecheck:web`, `yarn lint:web`, `yarn build:web` +- Higher-level checks: `yarn verify:server`, `yarn verify:web`, `yarn docs:check`, `yarn docs:generate:api` ## Repo Map -- Backend entrypoint: `server/src/index.ts` -- API registry: `server/src/api/_routes_.ts` -- Frontend entrypoint: `web/src/App.tsx`, bootstrap `web/src/main.tsx` -- Frontend state: `web/src/model/*` -- Docs RU: `docs/docs/**` -- Docs EN: `docs/i18n/en/docusaurus-plugin-content-docs/current/**` -- Shared contracts: `shared/**` -- Legacy reference only: `server/src/legacy/**`, `web/src/legacy/**` +- `server/src/index.ts`: backend entrypoint. +- `server/src/api/_routes_.ts`: API route registry. +- `server/src/application/**`: use-cases and application orchestration. +- `server/src/services/**`: domain services and repositories. +- `server/src/core/**`: shared backend infrastructure, middleware, gateway, factories, errors, logging. +- `server/src/core/llm-gateway/**`: LLM provider gateway and adapter layer. +- `server/src/db/schema/**`: database schema. +- `web/src/main.tsx` and `web/src/App.tsx`: frontend bootstrap. +- `web/src/model/**`: default home for frontend business state. +- `web/src/features/**`: UI features. +- `web/src/api/**`: frontend API clients. +- `web/src/ui/**`: shared UI and form primitives. +- `web/src/i18n/**`: localization setup and RU/EN resources. +- `shared/**`: source of truth for cross-layer contracts. +- `docs/docs/**`: RU docs. +- `docs/i18n/en/docusaurus-plugin-content-docs/current/**`: EN docs. +- `server/src/legacy/**` and `web/src/legacy/**`: reference only. Do not edit unless the task explicitly requires legacy migration. ## Working Rules -- Keep changes scoped to the user request. No opportunistic refactors. -- Prefer `rg` / `rg --files` for search. -- Before changing code, inspect existing local patterns in neighboring files. -- Do not edit `legacy` folders unless the task explicitly asks for legacy migration. -- Never expose secrets or token values in output. -- Keep code comments in English when adding comments. - -## Worktree Startup Rule -- If current repo folder name starts with `TaleSpinner_` and is not `TaleSpinner_v1`, treat it as a git worktree for tasks. -- At the start of each new task in such worktree: +- Before edits, inspect the smallest relevant slice of neighboring implementation, tests, contracts, and docs needed to avoid breaking local patterns. +- Keep changes scoped to the user's request. Avoid opportunistic refactors and unrelated formatting churn. +- Prefer `rg` and `rg --files` for search when available. +- Preserve existing architecture. Add abstractions only when they remove real complexity or match established local patterns. +- If a touched production file is already oversized, extract the concern you are modifying instead of adding more logic to it. +- Use explicit domain names, narrow types, small helpers, and flat control flow. +- Do not leave dead branches, commented-out code, placeholder TODO logic, or temporary debug flows in final changes. +- Code comments must be in English. + +## Size Guidelines +- Production files should stay near or below 300 lines and must not exceed 400 lines without a strong local reason. +- Functions should stay near or below 30 logical lines and must not exceed 50 lines. +- React components should stay near or below 150 lines and must not exceed 200 lines. +- Generated files, translation dictionaries, migrations, fixtures, and exhaustive tests are exempt. + +## Backend +- Routes are HTTP wiring: parse, validate, call the application/service layer, and map the response. +- Business rules belong in use-cases or services, not Express handlers. +- Persistence belongs in repository-style modules. +- LLM provider access goes through `server/src/core/llm-gateway/**` or established service abstractions. +- Validate inputs at the edge with existing validation patterns such as `zod`, `validate`, and route schemas. +- Keep request/response flow typed end to end. Avoid `any`; if a dynamic boundary requires it, keep it local and justified. +- Use structured error handling. Do not swallow errors. +- Never expose secrets, token values, encryption keys, cookies, raw auth headers, or env-backed credentials in code, logs, screenshots, tests, or reports. +- Never log raw request bodies that may contain secrets, tokens, or untrusted large payloads. +- Treat user markdown, HTML, imported cards, templates, and external provider payloads as untrusted input. + +## Frontend +- Effector is the default state layer for app state, business state, async flows, and cross-component coordination. +- Business logic belongs in `web/src/model/**`, domain utilities, or clearly scoped feature-model modules. +- React components should stay focused on presentation and local interaction wiring. +- `useState` is for leaf-local ephemeral UI state such as disclosures, local tabs, and transient modals. +- Use `useUnit` for subscribing to Effector stores and binding events/effects in React. +- Prefer declarative Effector flows with `sample`, events, stores, and effects. Keep `.watch` for debug or narrow side-effect bridges. +- Forms should use `react-hook-form` and shared primitives from `web/src/ui/form-components/**` when they fit. +- Keep form subscriptions narrow. Isolate validation, serialization, and submission mapping from presentation. +- User-facing UI changes must be responsive for phone, tablet, and desktop. +- Inputs, buttons, icon buttons, menus, tabs, and dialogs need accessible labels and predictable keyboard behavior. +- Include empty, loading, error, and disabled states when the feature can reach them. +- Avoid unnecessary rerenders in heavy panels, message lists, editors, and forms. + +## Localization +- All user-facing text belongs in localization resources, not hardcoded in components, stores, helpers, or API mappers. +- Update Russian and English resources together. +- Tests, internal debug-only output, and truly non-user-facing diagnostics may use inline text. + +## Shared Contracts +- Shared request/response shapes that matter to both backend and frontend belong in `shared/**`. +- Do not duplicate shared shapes in `server` and `web`. +- Treat shared contract changes as public interface changes inside the repo. +- Validate contract changes in both app layers and update docs when the behavior is externally visible. + +## Docs +- Touch docs only when the task explicitly requires docs work or the code change would otherwise leave affected docs knowingly stale. +- Keep RU and EN docs structurally aligned. +- If docs are touched and API routes or shared contract surface changed, run `yarn docs:generate:api` before `yarn docs:check`. + +## Testing And Verification +- For backend/frontend behavior changes, write or update the most relevant failing test first, make it pass, then refactor. +- Pure logic gets unit tests. +- Service orchestration, repositories, route contracts, complex state transitions, and regression-prone UI flows get integration-style coverage. +- For docs-only, config-only, formatting-only, or mechanical changes where tests add no signal, state why targeted tests were not needed. +- After changes, run the narrowest validation that proves the touched behavior: + - targeted tests for changed behavior + - type checks or lint checks for affected packages + - build checks when packaging, shared contracts, or bundling may be affected + - a smoke test when full validation is too expensive +- Run the full relevant suite before claiming broad readiness, after cross-layer changes, or when the blast radius is unclear. +- If validation cannot be run, explain why and name the next best check. + +## Worktree Startup +- If the current repo folder name starts with `TaleSpinner_` and is not exactly `TaleSpinner_v1`, treat it as a git worktree. +- At the start of a new task in such a worktree: - run `git fetch origin dev` - - if current branch is `dev`, `main`, or detached `HEAD`, create and switch to a task branch from `origin/dev` before edits - - run `sync-db-from-main.bat --no-extra --no-pause` to refresh local DB from the main folder - -## Area-Specific Guidance - -### Backend (`server/**`) -- Follow existing API style in `server/src/api/*` and shared middleware patterns (`asyncHandler`, `errorHandler`, `validate` where applicable). -- For new or changed endpoints, prefer typed request/response flow and avoid `any`. -- If endpoint behavior changes, ensure API docs inventory can be regenerated. - -Required checks after backend changes: -- `yarn typecheck:server` -- Run focused tests in server when logic is touched: - - all tests: `yarn --cwd server test` - - or targeted: `yarn --cwd server test -- ` - -### Frontend (`web/**`) -- Respect FSD-style boundaries already used in repo (`features`, `model`, `ui`, `api`, `utils`). -- Keep business logic in `model/*` or utilities, not large UI handlers. -- Reuse existing aliases and local conventions. - -Required checks after frontend changes: -- `yarn typecheck:web` -- If build/tooling/entrypoint changed: `yarn build:web` - -### Docs (`docs/**`) -- Docs are code-adjacent and must match current behavior. -- Keep RU/EN structure parity. -- If API routes changed: regenerate API endpoints docs. - -Required checks after docs or API-doc changes: -- `yarn docs:generate:api` (only when API surface changed) -- `yarn docs:check` - -### Shared (`shared/**`) -- Any contract change must be validated in both app layers. - -Required checks after shared changes: -- `yarn typecheck:server` -- `yarn typecheck:web` - -## Definition of Done -- Requested behavior implemented and consistent with neighboring architecture. -- Required scope checks passed. -- If docs touched, RU/EN parity preserved. -- Final report includes: - - changed files - - why change was made - - commands executed - - test/check results - -## Safety & Data Handling -- Treat `server/data/config/*` and env-backed credentials as sensitive. -- Do not add endpoints that return raw secrets. -- Keep static serving restricted to intended public media paths. -- Treat user-provided markdown/HTML as untrusted content. - -## Instruction Hygiene (for future edits of this file) -- Keep this file concise, concrete, and command-first. -- Prefer actionable rules over broad style essays. -- If rules become large, split by subdirectories with additional local `AGENTS.md` files. + - if the current branch is `dev`, `main`, or detached `HEAD`, create and switch to a task branch from `origin/dev` + - run `sync-db-from-main.bat --no-extra --no-pause` + +## Definition Of Done +- The requested behavior is implemented in the correct layer and follows neighboring architecture. +- Relevant tests and checks pass, or any skipped validation is explicitly justified. +- Security, file-access, and logging boundaries are preserved. +- RU/EN localization parity is preserved for user-facing text. +- RU/EN docs parity is preserved when docs are touched. +- The final report includes changed files, why the change was made, commands executed, and test/check results. + +## Stop Rules +- Ask a narrow clarification only when missing information would materially change the implementation or create meaningful risk. +- Stop searching once the core request can be answered or implemented with enough local evidence. +- Stop editing once the requested behavior and Definition of Done are satisfied. +- If a required change would violate these rules, report the conflict before proceeding. diff --git a/AGENTS.old.md b/AGENTS.old.md new file mode 100644 index 00000000..77076141 --- /dev/null +++ b/AGENTS.old.md @@ -0,0 +1,197 @@ +# AGENTS.md - TaleSpinner + +Repository contract for coding agents working in this repo. +This file is strict on purpose. Follow it literally. + +## Project Identity +- TaleSpinner is a local LLM application for storytelling, roleplay, and multi-agent scenario building. +- The product is built around isolated profiles, chats and branches, world info, instructions, operation pipelines, and optional RAG flows. +- The goal is not "one big prompt". The goal is a reproducible, controllable LLM workflow with clean context boundaries. + +## Quick Commands +- Install app deps: `yarn install:all` +- Install docs deps: `yarn install:docs` +- Run backend + frontend: `yarn dev` +- Run docs: `yarn docs:dev` +- Build app: `yarn build` +- Build docs: `yarn docs:build` +- Typecheck backend: `yarn typecheck:server` +- Typecheck frontend: `yarn typecheck:web` +- Lint backend: `yarn lint:server` +- Lint frontend: `yarn lint:web` +- Run backend tests: `yarn --cwd server test` +- Verify backend: `yarn verify:server` +- Verify frontend: `yarn verify:web` +- Check docs: `yarn docs:check` +- Regenerate API docs: `yarn docs:generate:api` + +## Stack Snapshot +- Monorepo: `server` (Node.js + Express + TypeScript), `web` (Vite + React + TypeScript + Effector + Mantine), `docs` (Docusaurus), `shared` (shared contracts and utils), `data` (runtime data). +- Package manager: Yarn Classic 1.x only. NEVER switch a task to npm or pnpm. +- Runtime note: docs require Node `>=20`. + +## Repo Topology +- `server/src/index.ts`: backend entrypoint. +- `server/src/api/_routes_.ts`: API route registry. +- `server/src/application/**`: use-cases and application orchestration. +- `server/src/services/**`: domain services and repositories. +- `server/src/core/**`: shared backend infrastructure, middleware, gateway, factories, errors, logging. +- `server/src/db/schema/**`: database schema. +- `web/src/main.tsx` and `web/src/App.tsx`: frontend bootstrap. +- `web/src/model/**`: Effector business state. This is the default home for frontend domain logic. +- `web/src/features/**`: UI features. +- `web/src/api/**`: frontend API clients. +- `web/src/ui/**`: shared UI and form primitives. +- `web/src/i18n/**`: localization setup and RU/EN resources. +- `shared/**`: source of truth for cross-layer contracts. If backend and frontend share a shape, it belongs here. +- `docs/docs/**`: RU docs. +- `docs/i18n/en/docusaurus-plugin-content-docs/current/**`: EN docs. +- `server/src/legacy/**` and `web/src/legacy/**`: reference only. Do not edit unless the task is explicitly about legacy migration. + +## Global Non-Negotiables +- MUST inspect neighboring code, tests, and docs before changing anything. +- MUST keep changes tightly scoped to the request. No opportunistic refactors. +- MUST prefer `rg` and `rg --files` for search when available. +- MUST preserve existing architecture instead of inventing a parallel one. +- MUST extract the touched slice before adding more logic to an oversized file. +- NEVER edit `legacy/**` unless the task explicitly requires it. +- NEVER expose secrets, token values, encryption keys, cookies, raw auth headers, or env-backed credentials in code, logs, screenshots, tests, or reports. +- NEVER add comments in Russian inside code. Code comments must stay in English. +- NEVER keep dead branches, commented-out code, placeholder TODO logic, or "temporary" debug flows in final changes. +- NEVER solve a structural problem by adding more nesting, more flags, or more copy-pasted branches if extraction is the correct fix. + +## Code Size And Readability +- Production files SHOULD stay at or below `300` lines and MUST NOT exceed `400` lines. +- Functions SHOULD stay at or below `30` logical lines and MUST NOT exceed `50` lines. +- React components SHOULD stay at or below `150` lines and MUST NOT exceed `200` lines. +- A function should do one job. If it coordinates multiple concerns, split it. +- Names MUST describe domain meaning, not implementation trivia. +- Prefer explicit types, small helpers, and flat control flow over deeply nested conditions. +- Exemptions are limited to generated files, translation dictionaries, migrations, fixtures, and exhaustive tests. +- If a touched file already exceeds the limit, do not keep stacking logic into it. Extract the modified concern into smaller modules in the same task. + +## Backend Rules (`server/**`) + +### Architecture +- Routes MUST stay thin. +- Route files are for HTTP wiring only: parse request, validate input, call the application/service layer, map the response. +- Business rules MUST live in use-cases or services, not inside Express handlers. +- Persistence MUST live in repository-style modules, not inside routes or random helpers. +- LLM provider access MUST go through the existing gateway/adapter layers in `core/llm-gateway` or the established service abstractions. Do not call providers ad hoc from unrelated modules. +- Shared request/response contracts that matter to both layers MUST live in `shared/**`. + +### Validation And Types +- Validate inputs at the edge with the existing validation patterns (`zod`, `validate`, route schemas). +- Keep request/response flow typed end to end. +- Avoid `any`. Use it only when the boundary is truly dynamic and no narrower type is reasonable. If `any` is unavoidable, keep it local and justify it in code. +- Prefer explicit DTOs and domain types over loose object literals passed through many layers. + +### Errors, Logging, And Security +- Use structured error handling. Do not swallow errors. +- Never use an empty `catch`. If a catch intentionally suppresses an error, document why and keep the fallback explicit. +- Never log raw request bodies if they may contain tokens, secrets, or untrusted large payloads. +- Redact sensitive values in logs and error contexts. +- Keep file access constrained to intended safe paths. +- Treat user markdown, HTML, imported cards, templates, and external provider payloads as untrusted input. + +### Backend Testing +- Backend work MUST follow TDD: write the failing test first, make it pass, then refactor. +- Every backend feature or bug fix MUST add or update tests at the correct level. +- Pure logic gets unit tests. +- Service orchestration, repositories, and route contracts get integration or API tests. +- User-visible backend flows and regression-prone orchestration paths get smoke or e2e coverage when behavior changes. + +## Frontend Rules (`web/**`) + +### State And Architecture +- Effector is the default state layer for app state, business state, async flows, and cross-component coordination. +- Business logic MUST live in `web/src/model/**`, domain utilities, or clearly scoped feature-model modules. +- React components MUST stay focused on presentation and local interaction wiring. +- `useState` is allowed only for leaf-local ephemeral UI state such as a disclosure toggle, a local tab, or a transient modal flag. +- NEVER lift chains of `useState` up the tree for business state or form state. +- NEVER put large app state into React component trees when Effector should own it. +- If state is shared across features, persisted, async-driven, or affects multiple subtrees, it belongs in Effector. + +### Effector Rules +- Use `useUnit` for subscribing to stores and binding events/effects in React. +- Prefer small, atomic stores over monolithic "god stores". +- Use declarative flows with `sample`, events, stores, and effects. +- Keep `.watch` for debug or narrow side-effect bridges only. NEVER build core business logic around `.watch`. +- NEVER place business rules directly inside React event handlers when they can be expressed as Effector events/effects. +- NEVER subscribe a large top-level component to broad state if that causes avoidable rerenders of most of the app. +- When a model file grows beyond the size limits, split by concern: state, effects, derived state, orchestration, adapters. + +### Forms And Render Performance +- Forms MUST use `react-hook-form`. +- Reuse shared form primitives from `web/src/ui/form-components/**` when they fit the task. +- NEVER build production forms from chained `useState`, manual value syncing, or parent-driven controlled trees unless there is a proven exceptional reason. +- Form subscriptions MUST be as narrow as possible. Prefer field-scoped subscriptions and render-conscious composition. +- Form validation, serialization, and submission mapping should be isolated from presentational layout. +- Large forms MUST be split into sections and extracted components before they become monoliths. + +### UI, UX, And Accessibility +- Every user-facing UI change MUST be responsive for phone, tablet, and desktop. +- Design MUST feel production-ready: consistent spacing, readable hierarchy, obvious states, and clear interaction affordances. +- Do not ship desktop-only layouts, clipped drawers, overflowing modals, or controls that become unusable on smaller screens. +- Inputs, buttons, icon buttons, menus, tabs, and dialogs MUST have accessible labels and predictable keyboard behavior. +- Empty, loading, error, and disabled states are required when the feature can reach them. +- Avoid unnecessary rerenders in heavy panels, message lists, editors, and forms. + +### Localization +- All user-facing text MUST be localized in both Russian and English. +- Keep RU and EN resource trees in sync. +- Do not hardcode UI text in components, stores, helpers, or API mappers, except in tests, internal debug-only output, or truly non-user-facing diagnostics. +- When adding a new feature, update both locales in the same task. + +### Frontend Testing +- Frontend work MUST follow TDD the same way backend work does. +- Missing frontend test infrastructure is not a waiver. +- If a frontend task adds business logic, render-critical behavior, or regression-prone UI flows, the task MUST include adding or using appropriate frontend test coverage. +- Pure utilities and transforms get unit tests. +- Complex state transitions, model orchestration, and critical UI behavior get integration-style coverage. + +## Shared Contracts (`shared/**`) +- Shared types are the contract between backend and frontend. Treat changes here as public interface changes inside the repo. +- Do not duplicate shared shapes in `server` and `web`. +- Any contract change MUST be validated in both app layers and reflected in docs when externally visible. + +## Docs Rules (`docs/**`) +- Docs are currently outside the default verification scope. +- Touch docs only when the task explicitly requires docs work. +- If docs are touched, RU and EN docs must stay structurally aligned. +- If docs are touched and API surface or shared contract behavior changes, regenerate API docs before docs checks. +- Do not leave docs knowingly stale when docs are part of the task. + +## TDD And Mandatory Verification +- TDD is mandatory: red -> green -> refactor. +- "I will add tests later" is not acceptable. +- After each completed feature, all required tests and checks must be green. +- Mandatory post-feature full-suite commands: + - `yarn typecheck:server` + - `yarn lint:server` + - `yarn --cwd server test` + - `yarn build:server` + - `yarn typecheck:web` + - `yarn lint:web` + - `yarn build:web` +- If docs are touched and API routes or shared contract surface changed, run `yarn docs:generate:api` before `yarn docs:check`. + +## Worktree Startup Rule +- If the current repo folder name starts with `TaleSpinner_` and is not exactly `TaleSpinner_v1`, treat it as a git worktree. +- At the start of each new task in such a worktree: + - run `git fetch origin dev` + - if the current branch is `dev`, `main`, or detached `HEAD`, create and switch to a task branch from `origin/dev` before edits + - run `sync-db-from-main.bat --no-extra --no-pause` + +## Definition Of Done +- Requested behavior is implemented in the correct layer and consistent with neighboring architecture. +- Tests are written first and end green. +- Required full-suite checks are green. +- RU/EN localization parity is preserved for user-facing text. +- RU/EN docs parity is preserved when docs are touched. +- Final report must include changed files, why the change was made, commands executed, and test/check results. + +## Instruction Hygiene +- Keep this file concise, directive, and enforceable. +- Prefer hard rules over taste-based advice. +- Add new rules only when they materially improve agent behavior on this repo. diff --git a/FUNCTIONAL_REVIEW_REPORT_2026-04-25.md b/FUNCTIONAL_REVIEW_REPORT_2026-04-25.md new file mode 100644 index 00000000..46dd76f2 --- /dev/null +++ b/FUNCTIONAL_REVIEW_REPORT_2026-04-25.md @@ -0,0 +1,118 @@ +# Functional Review Report - TaleSpinner + +Дата ревью: 2026-04-25 +Область: frontend UX, логика пользовательских сценариев, наблюдаемая работа локального dev-окружения. +Исключено из оценки: безопасность, хранение секретов, threat model. + +## Краткий вывод + +Приложение запускается, frontend typecheck/lint/tests проходят. Основные проблемы не в компиляции, а в UX-структуре: контекст текущего чата почти скрыт, мобильный чат перекрывается композером, ключевые операции спрятаны в маленьком меню, а часть интерфейса использует системные confirm-диалоги и сыроватые тексты. + +## Приоритетные проблемы + +### P1. На мобильном экране композер перекрывает последнее сообщение + +**Где:** `web/src/index.css:304`, `web/src/index.css:311`, `web/src/index.css:713`; `web/src/features/chat-window/index.tsx`. + +**Что видно:** при viewport `390x844` нижняя часть последнего сообщения уходит под sticky-композер. Пользователь видит поле ввода поверх текста и не может нормально дочитать нижний фрагмент без постоянной прокрутки. + +**Почему это нелогично:** чат - главный сценарий приложения. Ввод должен быть закреплен, но scrollable-контент обязан иметь нижний отступ минимум на высоту композера плюс safe-area. + +**Рекомендация:** вынести композер из scroll-контейнера или добавить управляемый bottom spacer внутри списка сообщений. Для мобильного режима отдельно проверить `safe-area-inset-bottom`, высоту кнопок и многострочный textarea. + +### P1. Текущий чат и ветка не отображаются в основной рабочей области + +**Где:** `web/src/features/chat-window/chat-header.tsx` существует, но `rg "ChatHeader" web/src` показывает только его объявление; в `web/src/features/chat-window/index.tsx` компонент не используется. + +**Что видно:** на рабочем экране нет явного заголовка с профилем, чатом и веткой. Эти данные доступны только в скрытом меню управления чатом. + +**Почему это нелогично:** TaleSpinner опирается на профили, чаты и ветки. Если пользователь не видит активный контекст постоянно, легко писать не туда, перепутать ветку или не заметить переключение. + +**Рекомендация:** вернуть `ChatHeader` в верхнюю часть chat window и разместить в нем активный профиль, чат, ветку, быстрый переключатель/rename и состояние генерации. + +### P2. Ключевые действия чата спрятаны в маленьком меню под полем ввода + +**Где:** `web/src/features/chat-window/input/chat-management-menu.tsx:139-180`. + +**Что видно:** создание чата, управление чатами, управление ветками, bulk delete, привязка World Info и диагностика активаций находятся в одном меню за иконкой настроек в композере. + +**Почему это нелогично:** создание/выбор чата и ветки - первичные навигационные действия, а World Info diagnostics - вторичный debug/inspection сценарий. В одном меню они конкурируют и плохо обнаруживаются, особенно на мобильном экране. + +**Рекомендация:** вынести чат/ветку в header, bulk delete оставить как явный режим в панели сообщений, World Info diagnostics переместить в отдельную секцию/инспектор. + +### P2. Нативные `window.confirm` ломают единый UX подтверждений + +**Где:** множественные места, например `web/src/features/chat-window/input/chat-management-menu.tsx:343`, `web/src/features/chat-window/input/chat-management-menu.tsx:471`, `web/src/features/sidebars/world-info/index.tsx:223`, `web/src/features/llm-provider/llm-provider-panel.tsx:194`. + +**Что видно:** часть действий использует собственные `Dialog`, часть - браузерный confirm. Визуально и поведенчески это разные паттерны. + +**Почему это нелогично:** приложение уже имеет `@ui/dialog`, поэтому системные confirm-окна выглядят как инородные элементы, хуже локализуются по контексту и хуже подходят для сложных destructive flows. + +**Рекомендация:** унифицировать подтверждения через общий confirm-dialog service/component с текстом действия, именем сущности и явной destructive-кнопкой. + +### P2. Слишком крупные файлы затрудняют сопровождение пользовательских сценариев + +**Где:** `web/src/model/chat-entry-parts/index.ts` - 1850 строк, `web/src/features/chat-window/input/chat-management-menu.tsx` - 558 строк, `web/src/features/sidebars/instructions/index.tsx` - 722 строки, `web/src/features/sidebars/agent-cards/index.tsx` - 663 строки. + +**Что видно:** несколько разных сценариев живут в одном файле: списки, модалки, inline rename, World Info diagnostics, bulk actions, ветки. + +**Почему это нелогично:** это уже влияет на UX-эволюцию: чтобы поправить одну модалку или вынести один пункт меню, приходится держать в голове слишком много соседней логики. + +**Рекомендация:** выделять touched slices перед новыми изменениями: chat menu actions, chat list dialog, branch dialog, World Info activation dialog, bulk delete flow, entry parts effects. + +### P3. Мобильная левая рейка забирает слишком много полезной ширины + +**Где:** `web/src/index.css:713-719`. + +**Что видно:** на ширине 390px рейка остается вертикальной и занимает 54px. Для текста чата остается узкая колонка, строки становятся слишком короткими. + +**Почему это нелогично:** приложение текстоцентричное. На телефоне приоритет должен быть у чтения и ввода, а глобальная навигация может быть bottom nav, drawer-trigger или collapsible rail. + +**Рекомендация:** на mobile заменить постоянную рейку на нижнюю навигацию/кнопку меню или позволить ей скрываться. + +### P3. В RU-интерфейсе встречаются английские/технические формулировки + +**Где:** `web/src/App.tsx:65`, `web/src/i18n/resources/ru/app.ts:5`, `web/src/i18n/resources/ru/chat.ts:42`. + +**Что видно:** профиль по кнопке создается как `New profile ...`; пустое состояние говорит `Entity Profile`; в RU-строке World Info остается `Generation`. + +**Почему это нелогично:** локализация в целом есть, поэтому отдельные английские термины выглядят как незавершенность интерфейса. Для пользователя лучше единый словарь: "профиль персонажа/агента", "поколение/генерация", и локализованный шаблон имени. + +**Рекомендация:** вынести default profile name в i18n, согласовать термин `Entity Profile` в RU, заменить технические остатки в ресурсах. + +### P3. Ошибки API форматируются неодинаково + +**Где:** `web/src/api/api-json.ts:5`, `web/src/api/chat-core.ts:19`, `web/src/api/llm.ts:19`, `web/src/api/chat-entry-parts.ts:13`. + +**Что видно:** есть общий `getApiErrorMessage`, но часть API-клиентов использует свой `apiJson` и возвращает `body.error.message ?? HTTP error`. + +**Почему это нелогично:** пользовательские ошибки в разных разделах могут выглядеть по-разному: где-то поле валидации будет человекочитаемым, где-то останется серверный текст. + +**Рекомендация:** оставить один общий API helper с `getApiErrorMessage`, а специализированные клиенты свести к тонким оберткам. + +## Дополнительные наблюдения + +- Первый быстрый headless screenshot поймал только экран `Загрузка приложения...`; после ожидания через Chrome DevTools Protocol приложение загрузилось нормально. +- Vite стартовал на `http://localhost:5174/`, потому что `5173` уже был занят. +- Backend API на `http://localhost:5000/api/entity-profiles` отвечал `200`. +- В console/network во время проверки был только `404` на `favicon.ico`; функционально это не блокер. + +## Выполненные проверки + +- `git status --short --branch` +- `yarn dev` для локального запуска +- `Invoke-WebRequest http://localhost:5174/` +- `Invoke-WebRequest http://localhost:5000/api/entity-profiles` +- Headless Chrome/Edge screenshots для desktop/mobile +- Chrome DevTools Protocol snapshot после инициализации приложения +- `yarn typecheck:web` - passed +- `yarn lint:web` - passed +- `yarn --cwd web test` - passed, 31 files / 104 tests + +## Итоговый порядок исправлений + +1. Починить mobile-композер и вернуть видимый chat header. +2. Разнести chat management menu на header actions, chat/branch dialogs и debug/World Info inspector. +3. Заменить `window.confirm` на единый `Dialog`-based confirmation flow. +4. При следующих изменениях не расширять крупные файлы, а извлекать конкретный сценарий в отдельный модуль. +5. Пройтись по RU/EN словарю и унифицировать пользовательские термины. diff --git a/web/src/assets/bg.png b/default/backgrounds/default-bg.png similarity index 100% rename from web/src/assets/bg.png rename to default/backgrounds/default-bg.png diff --git a/default/backgrounds/manifest.json b/default/backgrounds/manifest.json new file mode 100644 index 00000000..353571ae --- /dev/null +++ b/default/backgrounds/manifest.json @@ -0,0 +1,9 @@ +{ + "items": [ + { + "id": "builtin:default", + "name": "Default background", + "fileName": "default-bg.png" + } + ] +} diff --git a/docs/docs/dev/backend/api-endpoints.md b/docs/docs/dev/backend/api-endpoints.md index 605d54fd..826b0ab1 100644 --- a/docs/docs/dev/backend/api-endpoints.md +++ b/docs/docs/dev/backend/api-endpoints.md @@ -17,6 +17,9 @@ description: Автогенерируемый инвентарь backend endpoin | Method | Path | Source file | Handler section | | --- | --- | --- | --- | +| USE | /api/app-settings | `server/src/api/app-settings.api.ts` | L7 | +| POST | /api/bundles/export | `server/src/api/bundles.core.api.ts` | L46 | +| POST | /api/bundles/import | `server/src/api/bundles.core.api.ts` | L69 | | DELETE | /api/chats/:id | `server/src/api/chats.core.api.ts` | L91 | | GET | /api/chats/:id | `server/src/api/chats.core.api.ts` | L29 | | PUT | /api/chats/:id | `server/src/api/chats.core.api.ts` | L76 | @@ -25,42 +28,43 @@ description: Автогенерируемый инвентарь backend endpoin | DELETE | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L178 | | PUT | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L159 | | POST | /api/chats/:id/branches/:branchId/activate | `server/src/api/chats.core.api.ts` | L135 | -| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L900 | -| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L975 | -| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L1167 | +| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L206 | +| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L230 | +| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L258 | | PUT | /api/chats/:id/instruction | `server/src/api/chats.core.api.ts` | L44 | -| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L1505 | -| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L1580 | -| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L290 | -| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L219 | -| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L227 | -| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L335 | -| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L244 | -| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L257 | -| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L355 | -| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L368 | -| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L300 | +| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L335 | +| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L350 | +| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L257 | +| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L142 | +| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L150 | +| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L258 | +| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L167 | +| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L180 | +| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L278 | +| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L291 | +| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L223 | | POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L62 | -| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L1981 | -| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L2085 | -| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L1449 | -| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L2195 | -| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L1308 | -| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L2181 | -| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L1604 | -| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L1621 | -| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L2128 | -| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L2168 | +| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L421 | +| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L436 | +| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L313 | +| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L507 | +| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L287 | +| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L493 | +| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L365 | +| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L383 | +| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L453 | +| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L480 | | GET | /api/files/metadata/:filename | `server/src/api/files/routes.ts` | L71 | | POST | /api/files/upload | `server/src/api/files/routes.ts` | L53 | | POST | /api/files/upload-card | `server/src/api/files/routes.ts` | L59 | | POST | /api/files/upload-image | `server/src/api/files/routes.ts` | L65 | | POST | /api/generations/:id/abort | `server/src/api/generations.core.api.ts` | L16 | -| GET | /api/instructions | `server/src/api/instructions.core.api.ts` | L42 | -| POST | /api/instructions | `server/src/api/instructions.core.api.ts` | L95 | -| DELETE | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L150 | -| PUT | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L120 | -| POST | /api/instructions/prerender | `server/src/api/instructions.core.api.ts` | L54 | +| GET | /api/instructions | `server/src/api/instructions.core.api.ts` | L44 | +| POST | /api/instructions | `server/src/api/instructions.core.api.ts` | L105 | +| DELETE | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L188 | +| PUT | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L146 | +| GET | /api/instructions/default-st-preset | `server/src/api/instructions.core.api.ts` | L56 | +| POST | /api/instructions/prerender | `server/src/api/instructions.core.api.ts` | L64 | | GET | /api/llm-preset-settings | `server/src/api/llm-presets.api.ts` | L185 | | PUT | /api/llm-preset-settings | `server/src/api/llm-presets.api.ts` | L201 | | GET | /api/llm-presets | `server/src/api/llm-presets.api.ts` | L77 | @@ -68,34 +72,35 @@ description: Автогенерируемый инвентарь backend endpoin | DELETE | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L132 | | PUT | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L108 | | POST | /api/llm-presets/:id/apply | `server/src/api/llm-presets.api.ts` | L150 | -| GET | /api/llm/models | `server/src/api/llm.api.ts` | L249 | -| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L47 | -| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L158 | -| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L168 | -| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L62 | -| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L80 | -| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L187 | -| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L205 | -| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L240 | -| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L226 | -| GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L34 | -| POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L42 | -| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L80 | -| GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L54 | -| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L65 | -| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L102 | -| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L127 | -| GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L52 | -| POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L60 | -| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L130 | -| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L104 | -| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L115 | -| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L144 | -| GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L74 | -| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L86 | -| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L194 | -| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L2294 | -| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L2344 | +| GET | /api/llm/models | `server/src/api/llm.api.ts` | L216 | +| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L53 | +| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L132 | +| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L103 | +| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L113 | +| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L68 | +| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L86 | +| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L154 | +| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | +| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L207 | +| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L193 | +| GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L33 | +| POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L41 | +| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L79 | +| GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L53 | +| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L64 | +| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L93 | +| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L118 | +| GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L36 | +| POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L44 | +| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L107 | +| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L81 | +| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L92 | +| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L121 | +| GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L58 | +| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L70 | +| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L135 | +| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L530 | +| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L545 | | GET | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L77 | | POST | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L84 | | DELETE | /api/rag/chroma/collections/:name | `server/src/api/rag-chroma.api.ts` | L96 | @@ -118,6 +123,7 @@ description: Автогенерируемый инвентарь backend endpoin | GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L50 | | PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L54 | | GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L68 | +| USE | /api/settings | `server/src/api/settings.api.ts` | L7 | | GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L124 | | POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L129 | | GET | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L105 | @@ -135,19 +141,19 @@ description: Автогенерируемый инвентарь backend endpoin | DELETE | /api/user-persons/:id | `server/src/api/user-persons.core.api.ts` | L93 | | GET | /api/user-persons/:id | `server/src/api/user-persons.core.api.ts` | L39 | | PUT | /api/user-persons/:id | `server/src/api/user-persons.core.api.ts` | L72 | -| GET | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L343 | -| PUT | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L357 | -| GET | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L167 | -| POST | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L177 | -| DELETE | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L226 | -| GET | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L188 | -| PUT | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L199 | -| POST | /api/world-info/books/:id/duplicate | `server/src/api/world-info.core.api.ts` | L237 | -| GET | /api/world-info/books/:id/export | `server/src/api/world-info.core.api.ts` | L303 | -| POST | /api/world-info/books/import | `server/src/api/world-info.core.api.ts` | L254 | -| POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L382 | -| GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L322 | -| PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L332 | +| GET | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L341 | +| PUT | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L355 | +| GET | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L165 | +| POST | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L175 | +| DELETE | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L224 | +| GET | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L186 | +| PUT | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L197 | +| POST | /api/world-info/books/:id/duplicate | `server/src/api/world-info.core.api.ts` | L235 | +| GET | /api/world-info/books/:id/export | `server/src/api/world-info.core.api.ts` | L301 | +| POST | /api/world-info/books/import | `server/src/api/world-info.core.api.ts` | L252 | +| POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L372 | +| GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L320 | +| PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L330 | | USE | /media | `server/src/api/static.api.ts` | L21 | ## Notes diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md index 6712b107..e91da75e 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md @@ -17,6 +17,9 @@ Sources: | Method | Path | Source file | Handler section | | --- | --- | --- | --- | +| USE | /api/app-settings | `server/src/api/app-settings.api.ts` | L7 | +| POST | /api/bundles/export | `server/src/api/bundles.core.api.ts` | L46 | +| POST | /api/bundles/import | `server/src/api/bundles.core.api.ts` | L69 | | DELETE | /api/chats/:id | `server/src/api/chats.core.api.ts` | L91 | | GET | /api/chats/:id | `server/src/api/chats.core.api.ts` | L29 | | PUT | /api/chats/:id | `server/src/api/chats.core.api.ts` | L76 | @@ -25,42 +28,43 @@ Sources: | DELETE | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L178 | | PUT | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L159 | | POST | /api/chats/:id/branches/:branchId/activate | `server/src/api/chats.core.api.ts` | L135 | -| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L900 | -| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L975 | -| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L1167 | +| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L206 | +| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L230 | +| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L258 | | PUT | /api/chats/:id/instruction | `server/src/api/chats.core.api.ts` | L44 | -| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L1505 | -| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L1580 | -| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L290 | -| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L219 | -| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L227 | -| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L335 | -| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L244 | -| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L257 | -| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L355 | -| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L368 | -| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L300 | +| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L335 | +| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L350 | +| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L257 | +| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L142 | +| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L150 | +| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L258 | +| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L167 | +| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L180 | +| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L278 | +| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L291 | +| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L223 | | POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L62 | -| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L1981 | -| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L2085 | -| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L1449 | -| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L2195 | -| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L1308 | -| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L2181 | -| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L1604 | -| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L1621 | -| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L2128 | -| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L2168 | +| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L421 | +| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L436 | +| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L313 | +| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L507 | +| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L287 | +| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L493 | +| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L365 | +| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L383 | +| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L453 | +| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L480 | | GET | /api/files/metadata/:filename | `server/src/api/files/routes.ts` | L71 | | POST | /api/files/upload | `server/src/api/files/routes.ts` | L53 | | POST | /api/files/upload-card | `server/src/api/files/routes.ts` | L59 | | POST | /api/files/upload-image | `server/src/api/files/routes.ts` | L65 | | POST | /api/generations/:id/abort | `server/src/api/generations.core.api.ts` | L16 | -| GET | /api/instructions | `server/src/api/instructions.core.api.ts` | L42 | -| POST | /api/instructions | `server/src/api/instructions.core.api.ts` | L95 | -| DELETE | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L150 | -| PUT | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L120 | -| POST | /api/instructions/prerender | `server/src/api/instructions.core.api.ts` | L54 | +| GET | /api/instructions | `server/src/api/instructions.core.api.ts` | L44 | +| POST | /api/instructions | `server/src/api/instructions.core.api.ts` | L105 | +| DELETE | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L188 | +| PUT | /api/instructions/:id | `server/src/api/instructions.core.api.ts` | L146 | +| GET | /api/instructions/default-st-preset | `server/src/api/instructions.core.api.ts` | L56 | +| POST | /api/instructions/prerender | `server/src/api/instructions.core.api.ts` | L64 | | GET | /api/llm-preset-settings | `server/src/api/llm-presets.api.ts` | L185 | | PUT | /api/llm-preset-settings | `server/src/api/llm-presets.api.ts` | L201 | | GET | /api/llm-presets | `server/src/api/llm-presets.api.ts` | L77 | @@ -68,34 +72,35 @@ Sources: | DELETE | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L132 | | PUT | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L108 | | POST | /api/llm-presets/:id/apply | `server/src/api/llm-presets.api.ts` | L150 | -| GET | /api/llm/models | `server/src/api/llm.api.ts` | L249 | -| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L47 | -| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L158 | -| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L168 | -| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L62 | -| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L80 | -| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L187 | -| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L205 | -| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L240 | -| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L226 | -| GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L34 | -| POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L42 | -| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L80 | -| GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L54 | -| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L65 | -| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L102 | -| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L127 | -| GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L52 | -| POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L60 | -| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L130 | -| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L104 | -| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L115 | -| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L144 | -| GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L74 | -| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L86 | -| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L194 | -| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L2294 | -| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L2344 | +| GET | /api/llm/models | `server/src/api/llm.api.ts` | L216 | +| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L53 | +| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L132 | +| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L103 | +| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L113 | +| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L68 | +| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L86 | +| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L154 | +| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | +| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L207 | +| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L193 | +| GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L33 | +| POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L41 | +| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L79 | +| GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L53 | +| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L64 | +| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L93 | +| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L118 | +| GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L36 | +| POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L44 | +| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L107 | +| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L81 | +| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L92 | +| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L121 | +| GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L58 | +| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L70 | +| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L135 | +| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L530 | +| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L545 | | GET | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L77 | | POST | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L84 | | DELETE | /api/rag/chroma/collections/:name | `server/src/api/rag-chroma.api.ts` | L96 | @@ -118,6 +123,7 @@ Sources: | GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L50 | | PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L54 | | GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L68 | +| USE | /api/settings | `server/src/api/settings.api.ts` | L7 | | GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L124 | | POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L129 | | GET | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L105 | @@ -135,19 +141,19 @@ Sources: | DELETE | /api/user-persons/:id | `server/src/api/user-persons.core.api.ts` | L93 | | GET | /api/user-persons/:id | `server/src/api/user-persons.core.api.ts` | L39 | | PUT | /api/user-persons/:id | `server/src/api/user-persons.core.api.ts` | L72 | -| GET | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L343 | -| PUT | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L357 | -| GET | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L167 | -| POST | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L177 | -| DELETE | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L226 | -| GET | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L188 | -| PUT | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L199 | -| POST | /api/world-info/books/:id/duplicate | `server/src/api/world-info.core.api.ts` | L237 | -| GET | /api/world-info/books/:id/export | `server/src/api/world-info.core.api.ts` | L303 | -| POST | /api/world-info/books/import | `server/src/api/world-info.core.api.ts` | L254 | -| POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L382 | -| GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L322 | -| PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L332 | +| GET | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L341 | +| PUT | /api/world-info/bindings | `server/src/api/world-info.core.api.ts` | L355 | +| GET | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L165 | +| POST | /api/world-info/books | `server/src/api/world-info.core.api.ts` | L175 | +| DELETE | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L224 | +| GET | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L186 | +| PUT | /api/world-info/books/:id | `server/src/api/world-info.core.api.ts` | L197 | +| POST | /api/world-info/books/:id/duplicate | `server/src/api/world-info.core.api.ts` | L235 | +| GET | /api/world-info/books/:id/export | `server/src/api/world-info.core.api.ts` | L301 | +| POST | /api/world-info/books/import | `server/src/api/world-info.core.api.ts` | L252 | +| POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L372 | +| GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L320 | +| PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L330 | | USE | /media | `server/src/api/static.api.ts` | L21 | ## Notes diff --git a/knowledge-base/Artifacts v1.0.md b/knowledge-base/Artifacts v1.0.md new file mode 100644 index 00000000..e6ff9bc5 --- /dev/null +++ b/knowledge-base/Artifacts v1.0.md @@ -0,0 +1,715 @@ + # Artifacts v1.0 — единая система эффектов (Prompt + UI + Canonicalization) + +## 0. Цели и границы + +### Цель + +Система **Artifacts** — единый механизм, который: + +1. хранит данные (значения и историю), +2. детерминированно применяет их к **Prompt** и/или **UI**, +3. умеет **переписывать** текст текущего user/assistant turn (canonicalization) без отдельного типа эффектов, +4. убирает необходимость в двух отдельных контрактах: + +* `Prompt-time effects` +* `Turn canonicalization effects` + +### Нормы этого документа + +* Используются слова **MUST / MUST NOT / SHOULD** в смысле RFC. +* Система должна быть **детерминированной**: при одинаковом входе результат одинаковый. + +--- + +## 1. Термины + +### Artifact + +**Artifact** — именованная сущность данных, результат работы операций. +Идентификатор: **tag** (уникален в профиле). + +### Artifact event (Write / Emit) + +**Artifact event** — факт записи значения в артефакт (в рамках запуска/turn). +Операции **не меняют prompt напрямую**, они **эмитят события**. + +### View (представление) + +**View** — правило, как события артефакта материализуются: + +* в **prompt** (инъекции/переписывания/виртуальные сообщения), +* в **UI** (inline-парты в сообщениях), +* в **UI panel** (отдельный интерфейс/панель с лентой). + +### Part + +**Part** — атомарный фрагмент сообщения (Entry Variant), который: + +* отображается в UI (renderer), +* и/или сериализуется в prompt (serializer), +* имеет порядок, видимость, lifespan/TTL и т.д. + + +### Turn + +**Turn** — один шаг диалога (обычно один user entry → один assistant entry). +Система использует `turnIndex` (целое число, монотонно растёт). + +--- + +## 2. Инварианты и гарантии + +### 2.1. Уникальность tag + +* `Artifact.tag` MUST быть уникален **в рамках OperationProfile**. +* Формат tag MUST быть dot-path: + + * `segment.segment.segment` + * каждый segment: `^[a-z][a-z0-9_]*$` + * общая длина ≤ 128 +* Зарезервированный префикс: `__sys.` (для внутренних артефактов/служебных данных). + +### 2.2. Один контракт эффектов + +* Операции MUST эмитить **только** `artifact.emit`. +* Операции MUST NOT эмитить отдельные `prompt_time` или `turn_canonicalization`. + +### 2.3. Детерминизм и отсутствие гонок записи + +* Все записи MUST применяться **последовательно** в одном детерминированном порядке (см. раздел 6). +* Конкурентное выполнение операций разрешено, но **коммит всегда последовательный**. + +--- + +## 3. Модель данных + +Ниже — “логическая” модель. Реальная БД может отличаться, но API и смысл — такие. + +### 3.1. ArtifactDefinition (описание артефакта) + +Определяется один раз (в профиле) и используется всеми операциями. + +```ts +type ArtifactUsage = "prompt_only" | "ui_only" | "prompt+ui" | "internal"; +type ArtifactPersistence = "run_only" | "persisted"; +type ArtifactSemantics = "state" | "log/feed" | "lore/memory" | "intermediate" | (string & {}); + +type ArtifactDefinition = { + tag: string; // уникальный id + title: string; + description?: string; + + usage: ArtifactUsage; // строго валидируется против наличия views + persistence: ArtifactPersistence; + semantics: ArtifactSemantics; + + valueFormat: "text" | "markdown" | "json"; // “канонический” формат значения + schemaId?: string; // для json (опционально) + + history: { + enabled: boolean; // если false — история не хранится + maxItems: number; // жесткий лимит, напр. 0..1000 + }; + + // Главная часть: как это проецируется в prompt/ui + views: ArtifactView[]; +}; +``` + +### 3.2. ArtifactRecord (состояние артефакта) + +```ts +type ArtifactValueEnvelope = { + format: "text" | "markdown" | "json"; + value: string | object | number | boolean | null; + + schemaId?: string; +}; + +type ArtifactHistoryItem = { + eventId: string; + turnIndex: number; + hook: "before_main_llm" | "after_main_llm"; + opId: string; + createdAt: number; // epoch ms + + value: ArtifactValueEnvelope; +}; + +type ArtifactRecord = { + tag: string; + persistence: "run_only" | "persisted"; + semantics: string; + + current: ArtifactValueEnvelope | null; + history: ArtifactHistoryItem[]; // ограничена history.maxItems +}; +``` + +--- + +## 4. Views: Prompt / Inline UI / Panel UI + +### 4.1. Типы Views + +```ts +type ArtifactView = + | PromptPartView + | TurnRewriteView + | VirtualEntryView + | InlineMessagePartView + | PanelView; +``` + +--- + +## 5. Render: dumb vs smart + +Это универсальная схема, используемая в prompt и UI. + +### 5.1. RenderSpec + +```ts +type HistoryWindow = + | { kind: "none" } + | { kind: "last_n"; n: number }; + +type RenderInput = + | { kind: "event_value" } // значение текущего artifact event + | { kind: "artifact_current" } // текущее значение артефакта (после применения event) + | { kind: "artifact_feed" }; // feed = [current + historyWindow] + +type RenderSpec = + | { + kind: "dumb"; + input: RenderInput; + historyWindow?: HistoryWindow; // для artifact_feed + outputFormat: "text" | "markdown" | "json"; + } + | { + kind: "liquid"; + input: RenderInput; + historyWindow?: HistoryWindow; + template: string; // liquid-шаблон + outputFormat: "text" | "markdown" | "json"; + strictVariables?: boolean; + } + | { + kind: "smart"; + input: RenderInput; + historyWindow?: HistoryWindow; + rendererId: string; // UI-only: renderer + props?: Record; + }; +``` + +### 5.2. Контекст Liquid + +Если `RenderSpec.kind="liquid"`, движок MUST предоставить: + +* `this.tag` — tag текущего артефакта +* `this.event` — `{eventId, turnIndex, hook, opId, value}` +* `this.value` — выбранный input (event_value/current) +* `this.feed` — массив history items (если input=artifact_feed) +* `art` — объект всех артефактов (текущие состояния), доступ по dot-path: + + * `art.world.state.current.value` +* `now` — ISO timestamp + +Для **rewrite**-view дополнительно: + +* `target.text` — текущий текст цели (до применения текущего rewrite шага) + +--- + +## 6. Детерминированный механизм “записи по очереди” + +### 6.1. Порядок операций: Commit Order + +Для каждого hook (`before_main_llm`, `after_main_llm`) вычисляется **единственный детерминированный список операций**: + +1. Строим граф зависимостей `dependsOn`. +2. Делаем topological sort. +3. Внутри одной “прослойки” topo-sort сортируем по: + + 1. `config.order` по возрастанию + 2. `opId` лексикографически (как стабильный tie-break) + +**Commit Order MUST быть одинаковым** при одинаковой конфигурации. + +### 6.2. Конкурентное выполнение vs последовательный коммит + +* Операции MAY выполняться конкурентно (LLM вызовы параллельно). +* Но результаты MUST быть **применены (committed)** строго по **Commit Order**. +* Никакие “кто раньше завершился” не влияют на финальное состояние. + +### 6.3. Порядок событий внутри операции + +Если операция эмитит несколько событий (см. 7.2), то: + +* они MUST применяться в порядке массива `outputs[]`. + +--- + +## 7. Выход операции: только artifact.emit + +### 7.1. Operation output + +```ts +type ArtifactWriteMode = "set" | "append"; + +type ArtifactEmit = { + type: "artifact.emit"; + tag: string; + + writeMode?: ArtifactWriteMode; // default определяется semantics: + // state => set, log/feed => append, остальное => set + + value: ArtifactValueEnvelope; // text/markdown/json +}; +``` + +### 7.2. Множественный output + +Операция MAY эмитить несколько артефактов: + +```ts +type OperationOutput = { + type: "artifact.emit_many"; + outputs: ArtifactEmit[]; +}; +``` + +--- + +## 8. Материализация Views: как артефакты попадают в Prompt и UI + +Ключевое правило: + +> Каждый `artifact.emit` после применения к store **триггерит materialization** всех `views` этого артефакта (в commit-порядке событий). + +### 8.1. Write → обновление store + +При применении event: + +* `set`: `current = value`, историю обновляем по правилам `history.enabled/maxItems` +* `append`: + + * `current = value` + * и в историю добавляем item как отдельное событие (даже если current перезаписался) + +История MUST соблюдать `maxItems` (обрезаем с начала, оставляем последние). + +--- + +# 9. Prompt Views + +## 9.1. PromptPartView (вставка Part в prompt) + +Используется для “Prompt Only” и части “Prompt+UI”. + +```ts +type PromptTarget = + | { kind: "system" } + | { kind: "current_user_entry" } // entry пользователя текущего turn + | { kind: "assistant_output_entry" } // entry ассистента текущего run + | { kind: "prompt_entry_at_depth"; role: "user"|"assistant"; depthFromEnd: number }; + +type UpsertMode = "upsert" | "append"; + +type PromptPartView = { + kind: "prompt_part"; + id: string; // уникально внутри артефакта + + target: PromptTarget; + materialize: UpsertMode; + + part: { + channel: "main" | "aux" | "trace"; + order: number; + + visibility: { ui: "never" | "debug" | "always"; prompt: true }; + + // lifespan влияет ТОЛЬКО на prompt-включение (UI не режем lifespan’ом) + promptLifespan: "infinite" | { turns: number }; + + // как получить payload + render: RenderSpec; + + // как сериализовать в prompt (если payload json/markdown) + promptSerializer: { serializerId: string; props?: Record }; + + // ui-рендер для inline НЕ используется здесь (это prompt view) + ui?: never; + }; +}; +``` + +### Правила + +* `PromptPartView.part.visibility.prompt` MUST быть `true`. +* `PromptPartView` MUST NOT отображаться в UI (ui visibility = never или debug допускается только если usage не prompt_only, см. 12). + +### Upsert semantics + +* `materialize="upsert"`: в target создаётся/обновляется **один** Part-слот с тегами: + + * `artifact:` + * `view:` +* `materialize="append"`: каждый event создаёт новый Part (с доп. тегом `event:`). + +--- + +## 9.2. TurnRewriteView (каноникализация / override без отдельного эффекта) + +Это **прямой заменитель** Turn canonicalization effects. + +```ts +type RewriteTarget = + | { kind: "current_user_main" } // только в before_main_llm + | { kind: "assistant_output_main" }; // только в after_main_llm + +type TextCompose = "replace" | "prepend" | "append"; + +type TurnRewriteView = { + kind: "turn_rewrite"; + id: string; + + target: RewriteTarget; + compose: TextCompose; + + // откуда берём текст (как event_value/current/etc) + render: RenderSpec; + + // сохранять ли результат как part в entry_parts (да, всегда для user/assistant) + persist: true; +}; +``` + +### Правила + +* `target.kind="current_user_main"` разрешён **только** в `before_main_llm`. +* `target.kind="assistant_output_main"` разрешён **только** в `after_main_llm`. +* Применение rewrite MUST быть последовательным: если несколько rewrite-view срабатывают, они применяются **в commit order**, и каждый следующий rewrite работает с `target.text`, уже изменённым предыдущими rewrite’ами. + +### Реализация через Parts (строгое поведение) + +Когда применяется rewrite: + +1. движок берёт **текущий эффективный main text** цели, +2. вычисляет `newText` по `compose`, +3. создаёт новый Part, который: + + * копирует channel/order/visibility/prompt/ui базового main-part’а цели, + * ставит `replacesPartId` равным текущему main-partId, + * добавляет теги: `artifact:`, `view:`, `rewrite`, `event:`, +4. обновляет “effective main part pointer” на этот новый part. + +--- + +## 9.3. VirtualEntryView (создать новое виртуальное сообщение только для prompt) + +Это **заменитель** Prompt-time insertion эффектов. + +```ts +type VirtualInsert = + | { kind: "after_last_user" } + | { kind: "at_depth_from_end"; depthFromEnd: number }; // как prompt.insert_at_depth + +type VirtualEntryView = { + kind: "virtual_entry"; + id: string; + + role: "system" | "user" | "assistant"; + insert: VirtualInsert; + + render: RenderSpec; // генерит содержимое виртуального сообщения +}; +``` + +### Правила + +* VirtualEntryView **не пишет в БД** и **не виден** другим view как target. +* Виртуальное сообщение живёт только в рамках **текущего prompt build**. + +--- + +# 10. UI Views + +## 10.1. InlineMessagePartView (Part внутри сообщения) + +Используется для `ui_only` и `prompt+ui` (inline отображение). + +```ts +type UiTarget = + | { kind: "current_user_entry" } + | { kind: "assistant_output_entry" } + | { kind: "entry_at_depth"; role: "user"|"assistant"; depthFromEnd: number }; + +type InlineMessagePartView = { + kind: "inline_part"; + id: string; + + target: UiTarget; + materialize: "upsert" | "append"; + + part: { + channel: "aux" | "trace"; // inline части НЕ являются main + order: number; + + visibility: { ui: "always" | "debug"; prompt: boolean }; + + // promptLifespan влияет только на prompt (UI сохраняется) + promptLifespan: "infinite" | { turns: number }; + + render: RenderSpec; + + uiRenderer: { rendererId: string; props?: Record }; + promptSerializer?: { serializerId: string; props?: Record }; + }; +}; +``` + +### Правила + +* Inline part MUST быть `channel != main`. +* Если `visibility.prompt=true`, тогда part участвует в prompt наравне с остальными частями entry (см. 11). + +--- + +## 10.2. PanelView (отдельный интерфейс/панель артефакта) + +Это второй UI-режим (вне сообщений), с лентой. + +```ts +type PanelFeed = + | { kind: "current_only" } + | { kind: "current_plus_last_n"; n: number } + | { kind: "history_only_last_n"; n: number }; + +type PanelView = { + kind: "panel"; + id: string; + + panelId: string; // например: "right_sidebar" | "tab:artifacts" | "custom:" + feed: PanelFeed; + + // Dumb: показываем как есть (текст/markdown/json) + // Smart: rendererId (пользовательская верстка/шаблон/компонент) + render: RenderSpec; +}; +``` + +### Правила + +* PanelView MUST получать данные только из ArtifactRecord (current+history). +* PanelView MUST поддерживать “ленту”: текущий элемент + N предыдущих (feed). + +--- + +# 11. Сборка Prompt из Parts + +## 11.1. Системное сообщение + +Prompt builder MUST формировать **ровно одно** system message. + +System content строится так: + +1. Собираем все system parts (base + materialized prompt_part/virtual_entry(role=system)). +2. Фильтруем те, у кого `promptLifespan` активен. +3. Сортируем по `order`. +4. Конкатенируем через разделитель `\n\n`. + +## 11.2. User/Assistant сообщения + +Для каждого entry: + +1. берём active variant, +2. берём parts: + + * `softDeleted != true` + * `visibility.prompt == true` + * `promptLifespan` активен +3. сортируем по `order`, +4. сериализуем каждый part `promptSerializer`, +5. склеиваем (по правилам сериализатора/канала; default: `\n\n`). + +--- + +# 12. Валидация (жесткие ошибки) + +## 12.1. usage ↔ views + +`ArtifactDefinition.usage` MUST соответствовать наличию views: + +* `prompt_only`: + + * MUST иметь ≥ 1 view типа `prompt_part`/`turn_rewrite`/`virtual_entry` + * MUST NOT иметь `inline_part` и `panel` + +* `ui_only`: + + * MUST иметь ≥ 1 view типа `inline_part` и/или `panel` + * MUST NOT иметь prompt-only views + +* `prompt+ui`: + + * MUST иметь ≥ 1 prompt-view + * MUST иметь ≥ 1 ui-view + +* `internal`: + + * MUST NOT иметь views вообще + +## 12.2. Запрещенные комбинации + +* `TurnRewriteView.target=current_user_main` в `after_main_llm` → ERROR +* `TurnRewriteView.target=assistant_output_main` в `before_main_llm` → ERROR +* `InlineMessagePartView.part.channel="main"` → ERROR +* `tag` не соответствует формату → ERROR + +--- + +# 13. Примеры (минимально необходимые) + +## 13.1. “World State” как system instruction (Prompt Only) + +* артефакт хранит markdown +* в prompt вставляется как system part с order=10 +* UI нет + +```json +{ + "tag": "world.state", + "title": "World State", + "usage": "prompt_only", + "persistence": "persisted", + "semantics": "state", + "valueFormat": "markdown", + "history": { "enabled": true, "maxItems": 50 }, + "views": [ + { + "kind": "prompt_part", + "id": "sys_instruction", + "target": { "kind": "system" }, + "materialize": "upsert", + "part": { + "channel": "aux", + "order": 10, + "visibility": { "ui": "never", "prompt": true }, + "promptLifespan": "infinite", + "render": { "kind": "dumb", "input": { "kind": "artifact_current" }, "outputFormat": "markdown" }, + "promptSerializer": { "serializerId": "core.markdown_to_text" } + } + } + ] +} +``` + +## 13.2. Каноникализация user turn (Turn rewrite) + +```json +{ + "tag": "turn.user.canonical", + "title": "Canonical User Turn", + "usage": "prompt+ui", + "persistence": "persisted", + "semantics": "intermediate", + "valueFormat": "text", + "history": { "enabled": true, "maxItems": 20 }, + "views": [ + { + "kind": "turn_rewrite", + "id": "rewrite_user", + "target": { "kind": "current_user_main" }, + "compose": "replace", + "render": { "kind": "dumb", "input": { "kind": "event_value" }, "outputFormat": "text" }, + "persist": true + } + ] +} +``` + +## 13.3. “Погода” — в prompt только текущая, в UI панель лента + +* prompt: только текущий turn (TTL=1) +* panel: last 20 + +```json +{ + "tag": "world.weather", + "title": "Weather", + "usage": "prompt+ui", + "persistence": "persisted", + "semantics": "log/feed", + "valueFormat": "json", + "schemaId": "weather.v1", + "history": { "enabled": true, "maxItems": 200 }, + "views": [ + { + "kind": "prompt_part", + "id": "weather_prompt", + "target": { "kind": "system" }, + "materialize": "upsert", + "part": { + "channel": "aux", + "order": 30, + "visibility": { "ui": "never", "prompt": true }, + "promptLifespan": { "turns": 1 }, + "render": { + "kind": "liquid", + "input": { "kind": "artifact_current" }, + "template": "Weather now: {{ this.value.value.temperature }}C, {{ this.value.value.summary }}", + "outputFormat": "text" + }, + "promptSerializer": { "serializerId": "core.text" } + } + }, + { + "kind": "panel", + "id": "weather_panel", + "panelId": "right_sidebar", + "feed": { "kind": "current_plus_last_n", "n": 20 }, + "render": { + "kind": "smart", + "input": { "kind": "artifact_feed" }, + "historyWindow": { "kind": "last_n", "n": 20 }, + "rendererId": "core.weather_timeline", + "props": { "showIcons": true } + } + } + ] +} +``` + +--- + +# 14. Что именно нужно поменять в текущем проекте (строго по контракту) + +Это НЕ “варианты”, а прямой список обязательных изменений для соответствия спеки: + +1. `OperationOutput`: + + * удалить `prompt_time` и `turn_canonicalization` + * оставить только `artifact.emit` / `artifact.emit_many` + +2. Runtime commit: + + * собирать результаты операций как сейчас можно, + * но **применять строго по Commit Order** (граф dependsOn → order → opId), + * каждый emit → update store → materialize views. + +3. Prompt builder: + + * гарантировать **один system message** + * собирать system из parts + +4. Canonicalization: + + * реализовать через `TurnRewriteView` (создание part с replacesPartId по “текущему эффективному main”) + +5. UI: + + * inline — через parts + * panel — через artifact records + panel view (feed). + diff --git a/knowledge-base/artifacts-concept-v2-draft-2026-03-07.md b/knowledge-base/artifacts-concept-v2-draft-2026-03-07.md new file mode 100644 index 00000000..cb939270 --- /dev/null +++ b/knowledge-base/artifacts-concept-v2-draft-2026-03-07.md @@ -0,0 +1,726 @@ +# Operations as Artifacts — Concept Draft v2 + +## 0. Зачем это нужно + +Сейчас у операций есть несколько типов эффектов: + +- `artifacts` +- `prompt_time` +- `turn_canonicalization` + +Концептуально они решают очень похожую задачу: результат работы операции должен где-то сохраниться и затем как-то повлиять на prompt, UI или текущий turn. + +Для конечного пользователя это выглядит как три разных механизма, которые частично пересекаются по смыслу. В итоге модель становится сложнее, чем сама задача. + +Цель нового дизайна: + +> любая операция по умолчанию производит артефакт со своим состоянием, а уже сам артефакт определяет, взаимодействует ли он с внешним миром и каким образом. + + +## 1. Главная идея + +Новая модель строится вокруг трех сущностей: + +1. `Operation` + Вычисляет результат. + +2. `Artifact` + Хранит результат операции как состояние. + +3. `Artifact Exposure` + Определяет, как состояние артефакта влияет на внешний мир: + - prompt + - UI + - rewrite текущего turn + +Формула системы: + +`operation produces state -> artifact stores state -> exposures apply state` + +Это означает: + +- операция не пишет напрямую в prompt +- операция не переписывает напрямую сообщения +- операция не создает напрямую UI-эффекты +- операция только обновляет состояние своего артефакта +- все внешнее поведение описывается через exposures артефакта + + +## 2. Принципы + +### 2.1. Операция всегда пишет в артефакт + +У каждой операции есть один основной артефакт. + +Это базовый и обязательный контракт. + +Операция не должна выбирать между разными output-механиками. Она всегда делает одно и то же: + +1. выполняется +2. получает результат +3. записывает результат в свой артефакт + + +### 2.2. Хранение данных и их публикация разделены + +Нужно явно различать: + +- что хранится +- как долго хранится +- как это показывается в prompt +- как это показывается в UI +- влияет ли это на rewrite turn + +Это разные оси поведения. Их нельзя смешивать в один флаг вроде `usage`. + + +### 2.3. Артефакт может быть полностью внутренним + +Артефакт не обязан никак отображаться наружу. + +Если у него нет exposures, он остается внутренним состоянием, доступным только другим операциям и шаблонам. + + +### 2.4. Rewrite turn — это не отдельный output операции + +Переписывание user/assistant turn остается допустимым поведением, но описывается не как отдельный вид output операции, а как один из видов exposure артефакта. + +То есть: + +- операция вычисляет канонизированный текст +- артефакт хранит этот текст +- exposure `turn_rewrite` применяет его к целевой части turn + + +### 2.5. Система должна быть детерминированной + +При одинаковой конфигурации и одинаковых входных данных результат должен быть одинаковым: + +- одинаковый artifact state +- одинаковый prompt +- одинаковый UI/resulting rewrite + + +## 3. Что не является целью первой версии + +Этот концепт сознательно не пытается сразу решить все будущие задачи. + +Не цель v1: + +- сложный universal DSL на все случаи жизни +- multi-writer artifacts +- fan-out одной операции в много артефактов +- отдельный мощный panel framework +- продвинутая система renderer marketplace +- попытка закрыть все будущие сценарии одним документом + +Сначала нужна простая, понятная и устойчивая модель. + + +## 4. Термины + +### Operation + +Конфигурируемая единица вычисления, которая запускается на определенном hook и производит новое состояние своего артефакта. + + +### Artifact + +Состояние, связанное с одной операцией. + +Артефакт: + +- имеет `tag` +- хранит текущее значение +- опционально хранит историю +- может иметь exposures + + +### Exposure + +Правило публикации состояния артефакта во внешний мир. + +Exposure не меняет смысл самого артефакта. Exposure только определяет, как это состояние используется. + + +### Artifact State + +Текущее значение артефакта после последнего успешного применения операции. + + +### Artifact History + +История прошлых значений артефакта. + +Это именно история состояния артефакта, а не история его отображения в prompt или UI. + + +## 5. Базовые инварианты + +### 5.1. Один operation -> один primary artifact + +В базовой версии каждая операция имеет ровно один основной артефакт. + +Это сознательное ограничение ради простоты модели. + + +### 5.2. Operation не имеет prompt/ui output типов + +У операции нет отдельных output-типов вроде: + +- `prompt_time` +- `turn_canonicalization` + +Операция имеет только artifact output. + + +### 5.3. Внешний эффект возможен только через exposures + +Если состояние артефакта должно попасть: + +- в prompt +- в UI +- в rewrite текущего turn + +это всегда задается через exposure. + + +### 5.4. Persistence принадлежит артефакту + +`run_only` или `persisted` — это свойство самого артефакта. + +Не exposure. + + +### 5.5. Lifetime включения в prompt/UI принадлежит exposure + +Сколько проекция живет в prompt или где именно она отображается — это свойство exposure. + +Не artifact history. + + +### 5.6. История артефакта и prompt lifetime — разные вещи + +Нельзя смешивать: + +- "хранить прошлые значения" +- "показывать значение в prompt только 3 хода" + +Первое относится к artifact state/history. +Второе относится к exposure. + + +## 6. Предлагаемая модель данных + +Ниже логическая модель, не обязательная как точный runtime shape. + +```ts +type ArtifactFormat = "text" | "markdown" | "json"; +type ArtifactPersistence = "run_only" | "persisted"; +type ArtifactWriteMode = "replace" | "append"; + +type OperationArtifactConfig = { + artifactId: string; // internal stable id + tag: string; // human-readable unique alias for templates and references + title: string; + description?: string; + + format: ArtifactFormat; + persistence: ArtifactPersistence; + writeMode: ArtifactWriteMode; + + history: { + enabled: boolean; + maxItems: number; + }; + + exposures: ArtifactExposure[]; +}; +``` + +Шаблонный доступ к другим артефактам должен идти через `tag`, например `{{art.story_summary.value}}`. +`artifactId` остается внутренним ключом хранения и не должен становиться основным пользовательским API. + +```ts +type ArtifactRecord = { + tag: string; + format: "text" | "markdown" | "json"; + persistence: "run_only" | "persisted"; + + current: unknown | null; + history: ArtifactHistoryItem[]; +}; + +type ArtifactHistoryItem = { + turnIndex: number; + hook: "before_main_llm" | "after_main_llm"; + opId: string; + createdAt: number; + value: unknown; +}; +``` + + +## 7. Exposure-модель + +Exposure описывает только использование состояния артефакта. + +### 7.1. Prompt Part Exposure + +Вставляет состояние артефакта как part в существующее сообщение или system area. + +Примеры: + +- world state в system +- временный weather block +- скрытый aux block для модели + + +### 7.2. Prompt Message Exposure + +Создает отдельное виртуальное сообщение для prompt. + +Это замена сценариев, где раньше был prompt-time insertion как отдельный эффект. + +Важно: + +- это prompt-only проекция +- она не обязана становиться частью persisted entry parts + + +### 7.3. Turn Rewrite Exposure + +Применяет состояние артефакта как rewrite к: + +- текущему user main +- assistant output main + +Это замена отдельного canonicalization output. + +Семантически это не "prompt insertion", а именно "mutation through artifact state". + + +### 7.4. UI Inline Exposure + +Показывает состояние артефакта как inline part внутри сообщения. + + +### 7.5. UI Panel Exposure + +Показывает состояние артефакта вне сообщений, в отдельной панели или секции интерфейса. + +Это полезный режим, но он не должен усложнять первую рабочую версию. + + +## 8. Минимальный набор exposure-типов для первой версии + +Для первой реальной версии достаточно поддержать: + +1. `prompt_part` +2. `prompt_message` +3. `turn_rewrite` +4. `ui_inline` + +`ui_panel` можно оставить как future-ready extension, но не делать обязательным центром дизайна. + + +## 9. Как должна работать операция + +### 9.1. Execution contract + +Операция на выходе должна вернуть новое значение для своего артефакта. + +Примерно так: + +```ts +type OperationResult = { + artifact: { + value: unknown; + }; +}; +``` + +В расширенной версии можно добавить: + +- `format` +- `schemaId` +- `debugMeta` + +Но это не меняет главную идею: операция возвращает новое состояние артефакта, а не набор разнотипных эффектов. + + +### 9.2. Commit contract + +При commit: + +1. определяется детерминированный порядок операций +2. состояние артефакта обновляется +3. после обновления состояния применяются exposures этого артефакта + + +## 10. Запись состояния артефакта + +### 10.1. Replace mode + +`replace` означает: + +- `current` заменяется новым значением +- history при необходимости пополняется новым snapshot + + +### 10.2. Append mode + +`append` нужен для feed/log-подобных сценариев. + +Он означает, что новое значение логически добавляется как новый элемент истории, а `current` отражает последнее состояние. + +Это полезно для: + +- погоды +- событий +- журналов +- изменений по персонажу + + +## 11. Что принадлежит artifact, а что exposure + +### Artifact-level + +- `tag` +- `format` +- `persistence` +- `writeMode` +- `history.enabled` +- `history.maxItems` + + +### Exposure-level + +- target +- способ вставки/переписывания +- prompt/UI visibility +- order +- serializer/renderer +- lifetime участия в prompt + + +Это ключевое разделение модели. + + +## 12. Отношение к существующей parts-модели проекта + +В проекте уже существует сильная модель `entry parts`. + +Это хорошо. + +Новый концепт не должен дублировать parts как отдельную параллельную сущность. Наоборот: + +- artifacts отвечают за данные и состояние +- parts остаются основным substrate для prompt/UI materialization там, где это уместно + +Следствие: + +- `prompt_part` +- `ui_inline` +- `turn_rewrite` + +должны по возможности опираться на уже существующую parts-модель, а не создавать вторую независимую систему. + + +## 13. Что должно исчезнуть из пользовательской модели + +Пользователь не должен думать в терминах: + +- "моя операция делает artifacts" +- "моя операция делает prompt_time" +- "моя операция делает canonicalization" + +Пользователь должен думать так: + +1. операция вычисляет состояние +2. это состояние хранится в артефакте +3. артефакт публикует себя через exposures + + +## 14. Примеры + +### 14.1. World State + +Операция обновляет `world.state`. + +Артефакт: + +- `persistence = persisted` +- `format = markdown` + +Exposure: + +- `prompt_part` в system + +UI exposure не нужен. + + +### 14.2. Weather Tracker + +Операция обновляет `world.weather`. + +Артефакт: + +- `persistence = persisted` +- `writeMode = append` +- хранит историю значений + +Exposures: + +- `prompt_part` только на короткий prompt lifetime +- позже можно добавить `ui_panel` + + +### 14.3. User Turn Canonicalizer + +Операция вычисляет канонизированную версию user message. + +Артефакт: + +- хранит результат нормализации как state + +Exposure: + +- `turn_rewrite(target = current_user_main, mode = replace)` + +Это тот же артефакт, а не отдельный special effect type. + + +### 14.4. Internal Stat Tracker + +Операция считает внутренний показатель. + +Артефакт: + +- persisted или run_only +- без exposures + +Он никак не виден пользователю, но доступен другим операциям. + + +## 15. Осознанные ограничения первой версии + +Чтобы система осталась понятной, первая версия должна быть ограниченной: + +- одна операция -> один primary artifact +- без multi-emit +- без shared writers +- без сложной inheritance-модели артефактов +- без обязательной panel-системы + +Если позже эти ограничения станут мешать, их можно расширить отдельной версией концепта. + + +## 16. Итоговый тезис + +Новая модель должна быть не "еще одной сложной универсальной системой эффектов", а нормализацией уже существующей идеи: + +- операция всегда производит состояние +- это состояние всегда живет в артефакте +- все взаимодействие с внешним миром описывается только через exposures артефакта + +Именно это и должно стать основой новой системы операций. + + +## 17. Вопросы на следующее обсуждение + +После принятия этого концепта отдельно нужно решить: + +1. `tag` должен быть user-facing и уникальным в пределах compiled profile. +2. `artifactId` должен оставаться internal stable id; ссылки из шаблонов идут по `tag`, а не по `artifactId` и не по `opId`. +3. Должен ли `turn_rewrite` всегда персиститься в parts. +4. Нужен ли `append` как write mode в первой версии, или пока достаточно только `replace`. +5. Нужен ли `ui_panel` уже в первой версии, или его лучше отложить. +6. Насколько exposure-конфиг должен быть low-level, а насколько через пресеты. + + +## 18. Engineering Notes Against Current Codebase + +Ниже зафиксированы выводы после сопоставления этого концепта с текущей реализацией в коде. + +### 18.1. Общее направление концепта считается правильным + +Текущий runtime уже подтверждает исходную проблему модели: + +- у операции сейчас есть три разных output-механизма: + - `artifacts` + - `prompt_time` + - `turn_canonicalization` +- эти три механизма проходят через разные типы, валидаторы, UI-формы и commit-ветки +- при этом по смыслу все они являются вариантами одного и того же сценария: + - операция вычисляет результат + - результат влияет на state, prompt, UI или turn + +Следствие: + +- переход к модели `operation -> artifact state -> exposures` уменьшает концептуальную сложность +- это не искусственное усложнение, а нормализация уже существующей логики + + +### 18.2. Главное достоинство v2: разделение state и publication + +Это сильнейшее улучшение по сравнению с ранней моделью. + +В текущем коде `usage` хранится у артефакта, но не является полноценным исполняемым механизмом публикации. + +Фактически сейчас: + +- artifact store знает про `usage` +- artifact store знает про `semantics` +- но prompt/UI не строятся автоматически из artifact config +- prompt-time и canonicalization живут как отдельные ветки runtime + +Следствие: + +- `usage` в текущем виде смешивает intent и storage metadata +- exposures действительно лучше выражают поведение, чем единый флаг `usage` + + +### 18.3. Концепт хорошо ложится на существующую parts-модель + +Это важное наблюдение: проект уже имеет сильную substrate-модель через `entry parts`. + +Особенно хорошо это видно на current canonicalization flow: + +- rewrite user turn уже materialize-ится через новый part +- этот part ссылается на прошлый через `replacesPartId` +- UI projection уже умеет replacement chains +- у parts уже есть: + - `visibility` + - `lifespan` + - `prompt/ui` projection metadata + +Следствие: + +- `turn_rewrite` должен опираться на parts, а не создавать отдельную независимую механику +- `ui_inline` и часть prompt materialization по возможности тоже должны использовать parts +- тезис концепта "не строить вторую параллельную систему поверх parts" считается верным + + +### 18.4. Что обязательно нужно уточнить перед реализацией + +#### A. Artifact value не должен остаться только строкой + +В текущем runtime artifact value фактически строковый: + +- `value: string` +- `history: string[]` + +Для новой модели этого недостаточно, потому что концепт уже предполагает: + +- `text` +- `markdown` +- `json` + +Поэтому для v2 базовый контракт артефакта должен сразу учитывать: + +- `format` +- `value: unknown` +- optional `schemaId` или другой способ типизации structured payload + +Это не стоит откладывать в "потом", иначе первая реализация снова будет слишком тексто-центричной. + + +#### B. Single-writer инвариант надо валидировать на уровне compiled profile + +Сейчас duplicate artifact tag проверяется только внутри одного блока операций. + +Но профиль собирается из нескольких block refs. + +Следствие: + +- два разных блока уже сейчас могут теоретически писать в один и тот же `tag` +- при переходе на новую artifact-centric модель это станет более опасным, чем сейчас + +Поэтому инвариант: + +- один primary artifact writer на `tag` + +нужно проверять не только внутри блока, но и после сборки всего profile runtime graph. + + +#### C. Hook restrictions не должны исчезнуть + +Текущий runtime содержит важные policy-ограничения: + +- prompt effects допустимы только в `before_main_llm` +- assistant rewrite допустим только в `after_main_llm` + +В новой модели это не должно потеряться. + +Следствие: + +- ограничения должны переехать в exposure policy +- нельзя полагаться только на "гибкость" новой схемы +- exposure type должен валидироваться относительно hook, target и lifecycle + + +### 18.5. Что лучше не делать в первой миграции + +#### Не переписывать весь runtime за один шаг + +Практически безопаснее сделать двухслойную миграцию: + +1. Пользовательская модель и конфиг переходят на `artifact + exposures` +2. Внутренний runtime временно компилирует exposures в уже существующие internal runtime effects + +Например: + +- `prompt_part` -> текущие prompt effect handlers +- `turn_rewrite` -> текущие turn effect handlers +- artifact state commit -> текущие artifact stores + +Такой путь дает: + +- меньший риск регрессий +- более предсказуемую миграцию UI и validator слоя +- возможность сохранить текущие commit/tests/debug flows на переходный период + + +#### Не делать `ui_panel` центром первой версии + +`ui_panel` полезен, но сейчас он не должен тянуть архитектуру первой версии. + +Для v1 достаточно считать минимальным набором: + +1. `prompt_part` +2. `prompt_message` +3. `turn_rewrite` +4. `ui_inline` + +`ui_panel` лучше оставить future-ready extension. + + +#### Не переоценивать необходимость `append` как отдельного write mode в первой версии + +Сейчас stores уже ведут history append-подобным способом. + +Поэтому перед добавлением отдельного artifact write mode нужно отдельно доказать, что простой модели: + +- `current` +- `history` +- history snapshots on commit + +недостаточно для ближайших сценариев. + +Иначе это добавит сложность раньше времени. + + +### 18.6. Практический итог + +Этот концепт рекомендуется принять как целевую модель. + +Но внедрять его лучше при следующих условиях: + +1. artifact state не ограничен строкой +2. single-writer validation поднимается до уровня compiled profile +3. hook/exposure policy фиксируется явно +4. первая миграция идет через compilation в существующие internal runtime effects +5. `ui_panel` и, возможно, `append` остаются за пределами первой рабочей версии + +В таком виде концепт выглядит не только теоретически чище, но и реалистично внедряемым в текущую архитектуру проекта. diff --git a/knowledge-base/artifacts-v1-migration-plan-2026-02-21.md b/knowledge-base/artifacts-v1-migration-plan-2026-02-21.md new file mode 100644 index 00000000..49e4677b --- /dev/null +++ b/knowledge-base/artifacts-v1-migration-plan-2026-02-21.md @@ -0,0 +1,238 @@ +# Artifacts v1.0 — Migration Plan (2026-02-21) + +## 1. Цель + +Перевести текущую систему операций на контракт из `knowledge-base/Artifacts v1.0.md` без big-bang переписывания: + +1. единый output: только `artifact.emit` / `artifact.emit_many`; +2. детерминированный commit и materialization через views; +3. canonicalization через `TurnRewriteView`; +4. единая prompt-сборка с ровно одним `system` сообщением; +5. поддержка UI-view (`inline_part`, `panel`) из artifact store. + +## 2. Ключевые разрывы (что закрываем) + +1. В коде живут legacy-выходы `prompt_time` и `turn_canonicalization`. +2. `turn.assistant.replace_text` не персистится в entry parts после after-фазы. +3. Artifact value/history не поддерживает envelope/event-метаданные и writeMode. +4. Формат tag не совместим со spec dot-path. +5. Нет view-движка materialization (`prompt_part`, `turn_rewrite`, `virtual_entry`, `inline_part`, `panel`). +6. Prompt runtime допускает несколько `system` сообщений. +7. Уникальность artifact tag валидируется только внутри блока, не во всем профиле. + +## 3. План по этапам (PR-by-PR) + +## PR1 — Контракты v1.0 (без включения в runtime) + +Изменения: + +1. Ввести новые типы в `shared/types/operation-profiles.ts`: + - `ArtifactDefinition`, `ArtifactView`, `RenderSpec`, `ArtifactEmit`, `OperationOutputV1`. +2. В `server/src/services/chat-generation-v3/contracts.ts` добавить runtime-типы: + - `artifact.emit`, `artifact.emit_many`, `ArtifactValueEnvelope`, `ArtifactHistoryItem`. +3. Оставить legacy-типы временно как deprecated (для чтения старых профилей). + +Критерий готовности: + +1. Типы компилируются. +2. Нет breaking API для чтения старых профилей. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn typecheck:web` + +## PR2 — Валидация профиля и tags + +Изменения: + +1. В `server/src/services/operations/operation-block-validator.ts`: + - ввести dot-path валидацию tag; + - добавить profile-level проверку уникальности writer tag (между всеми блоками профиля). +2. Запретить создание новых `prompt_time`/`turn_canonicalization` в API. +3. Оставить импорт legacy c явной нормализацией в новый контракт. + +Критерий готовности: + +1. Новые профили сохраняются только с artifacts output. +2. Дубликат tag между блоками не проходит. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn --cwd server test -- operation-block-validator` + +## PR3 — Artifact commit engine (ядро) + +Изменения: + +1. В `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts`: + - операции возвращают только `artifact.emit(_many)`. +2. В `server/src/services/chat-generation-v3/operations/commit-effects-phase.ts`: + - commit только через artifact events; + - порядок: dependsOn topo -> order -> opId; + - каждый emit: `store update -> materialization`. +3. Удалить применение `prompt.*` и `turn.*` эффектов напрямую. + +Критерий готовности: + +1. Commit детерминирован и не зависит от фактического времени завершения операций. +2. Legacy prompt/turn эффекты не используются в runtime. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn --cwd server test -- commit-effects-phase` +3. `yarn --cwd server test -- execute-operations-phase` + +## PR4 — Artifact store v1.0 data model + +Изменения: + +1. Обновить `RunArtifactStore` и `ProfileSessionArtifactStore`: + - `current: ArtifactValueEnvelope | null`; + - `history: ArtifactHistoryItem[]`; + - поддержка `writeMode: set|append`; + - `history.maxItems` на уровне definition. +2. Поддержать `format: text|markdown|json`. +3. Сохранить `schemaId` в envelope для json. + +Критерий готовности: + +1. Store хранит canonical envelope/history в формате спецификации. +2. `append` и `set` работают по правилам spec. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn --cwd server test -- artifact-effects` +3. `yarn --cwd server test -- commit-effects-phase` + +## PR5 — Materialization engine (Prompt + Rewrite + Virtual + UI) + +Изменения: + +1. Добавить модуль materializer в `server/src/services/chat-generation-v3/operations/`: + - `prompt_part` + - `turn_rewrite` + - `virtual_entry` + - `inline_part` + - `panel` +2. `turn_rewrite` реализовать через новый part с `replacesPartId` от текущего effective main part. +3. Исправить текущий баг с assistant canonicalization: rewrite assistant должен персиститься в part. + +Критерий готовности: + +1. Любой artifact emit может материализовать prompt/UI по views. +2. User/assistant rewrite последовательно применяются в commit order. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn --cwd server test -- operations-flow.integration` +3. Добавить новые integration tests на rewrite chain и virtual entries. + +## PR6 — Prompt builder: ровно одно system message + +Изменения: + +1. Перенести prompt-сборку на parts-подход из spec: + - единый system content = concat system parts; + - promptLifespan фильтрация; + - deterministic sort by order. +2. Убрать множественные system-сообщения из runtime. + +Критерий готовности: + +1. На финальном `llmMessages` всегда один `system`. +2. Prompt snapshot соответствует новому builder. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn --cwd server test -- build-base-prompt` +3. `yarn --cwd server test -- run-chat-generation-v3` + +## PR7 — Web editor migration (только новый output/view) + +Изменения: + +1. В `web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/output-section.tsx`: + - удалить UI выбора `prompt_time`/`turn_canonicalization`; + - добавить конфиг emit/views (минимально: artifact + view presets). +2. Обновить маппинг формы и i18n (`web/src/features/.../operation-profile-form-mapping.ts`, `web/src/i18n/resources/*/operationProfiles.ts`). + +Критерий готовности: + +1. UI не дает создать legacy output. +2. RU/EN ключи синхронизированы. + +Проверки: + +1. `yarn typecheck:web` +2. `yarn build:web` + +## PR8 — Совместимость и миграция данных + +Изменения: + +1. Legacy profile reader: + - `prompt_time` -> `artifact.emit + virtual_entry/prompt_part`; + - `turn_canonicalization` -> `artifact.emit + turn_rewrite`. +2. Скрипт миграции профилей/блоков (dry-run + apply). +3. Логировать авто-конверсии и ошибки несовместимых кейсов. + +Критерий готовности: + +1. Старые профили исполняются через новый runtime. +2. Есть безопасный rollback-путь. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn --cwd server test` +3. Smoke e2e на старом и новом профиле. + +## PR9 — Наблюдаемость и финальная зачистка + +Изменения: + +1. Удалить legacy effect коды из debug и runtime (`prompt.*`, `turn.*`). +2. Обновить debug payload/events под artifact materialization. +3. Обновить документацию `knowledge-base` и API docs. + +Критерий готовности: + +1. В коде нет runtime-зависимости от legacy effect types. +2. Доки соответствуют фактическому поведению. + +Проверки: + +1. `yarn typecheck:server` +2. `yarn typecheck:web` +3. `yarn --cwd server test` +4. `yarn docs:check` (если обновлялись docs в `docs/**`) + +## 4. Порядок внедрения и риски + +Рекомендуемый порядок: `PR1 -> PR2 -> PR3 -> PR4 -> PR5 -> PR6 -> PR7 -> PR8 -> PR9`. + +Основные риски: + +1. Несовместимость старых профилей при жестком отключении legacy-типов. +2. Регрессии prompt-сборки из-за перехода на parts-only. +3. Рост сложности тестов и необходимость новых integration fixtures. + +Снижение рисков: + +1. Feature flag на новый commit/materialize engine. +2. Параллельный dual-read для legacy профилей до завершения PR8. +3. Golden tests на prompt hash/snapshot до и после миграции. + +## 5. Definition of Done (общая) + +1. Runtime использует только artifact emit контракт. +2. Turn canonicalization полностью реализована через `TurnRewriteView`. +3. Prompt builder выдает один system message. +4. UI поддерживает только новый способ настройки output/views. +5. Все обязательные проверки сервера и веба проходят. diff --git a/knowledge-base/chat-knowledge-store-spec-2026-03-21.md b/knowledge-base/chat-knowledge-store-spec-2026-03-21.md new file mode 100644 index 00000000..ec6f0a82 --- /dev/null +++ b/knowledge-base/chat-knowledge-store-spec-2026-03-21.md @@ -0,0 +1,821 @@ +# TaleSpinner Chat Knowledge Store Spec (v1 Blueprint) + +Этот документ фиксирует новую сущность knowledge store для чата и ветки чата. + +Документ описывает: +- назначение и границы ответственности +- модель данных v1 +- поиск и retrieval flow +- механизм доступа к закрытым записям +- разделение baseline/runtime данных +- import/export режимы +- план интеграции с SQLite FTS5 и Chroma + +Документ является internal blueprint. Это не user-facing docs и не публичный API-контракт. + +## 1. Зачем нужна новая сущность + +Текущие близкие механизмы решают другие задачи: + +- `world-info` активируется по ключам/регуляркам и может случайно тащить в prompt лишние записи +- `operation artifacts` являются runtime-результатами операций и всегда начинаются пустыми в новом чате +- `chat_runtime_state` хранит pipeline/runtime state, а не knowledge corpus +- Chroma в текущем проекте является vector/RAG слоем, а не source of truth для чатового знания + +Новая сущность нужна для сценариев, где: + +- данные должны быть доступны не автоматически, а только по explicit retrieval +- записи могут быть импортированы заранее до начала игры +- записи могут создаваться и обновляться по ходу игры +- записи могут быть скрыты до выполнения условий +- retrieval должен работать не только по тегам, но и по именам, алиасам и тексту +- одна операция ищет кандидатов, а другая операция отбирает из них только нужное для prompt + +Итоговый тезис: + +> Knowledge store это chat-scoped/branch-scoped structured knowledge layer with explicit retrieval, а не auto-injected lorebook. + +## 2. Основные принципы + +### 2.1. Explicit access only + +Knowledge store не должен автоматически попадать в prompt. + +Правильный flow: + +1. операция формирует retrieval request +2. backend делает search +3. backend возвращает candidate records +4. отдельная операция/LLM отбирает нужное +5. только отобранное попадает в prompt + +### 2.2. SQLite is source of truth + +Source of truth для knowledge store должен жить в основной SQLite БД. + +Причины: + +- нужны устойчивые id и целостность +- нужны chat/branch scope +- нужны import/export режимы +- нужны explicit links между записями +- нужны детерминированные unique constraints +- нужны baseline/runtime distinctions + +### 2.3. Chroma is not the primary store + +Chroma не должен быть основным хранилищем knowledge records. + +Chroma должен использоваться как вторичный индекс для vector search поверх тех же `recordId`. + +Итог: + +- одна доменная сущность +- SQLite как primary store +- FTS5 как text search v1 +- Chroma как optional vector index v2 + +### 2.4. Retrieval must be hybrid + +LLM не должна зависеть только от тегов. + +Поиск должен поддерживать: + +- exact match по `key` +- exact/prefix match по `title` +- aliases +- tags +- full-text search +- optional filters по type/scope/access +- ranking +- `limit` +- score threshold + +### 2.5. Closed records need deterministic gating + +LLM не решает самостоятельно, можно ли открыть запись. + +LLM может: + +- предложить найти запись +- предложить раскрыть запись + +Но backend должен: + +- проверить gate policy +- проверить runtime state +- только потом открыть доступ или раскрыть запись + +### 2.6. Baseline and runtime must stay separable + +Knowledge store должен различать: + +- исходные записи, существовавшие до начала игры +- записи, созданные или измененные во время игры + +Это обязательно для export modes и для чистого сценарного состояния. + +## 3. Термины + +- `Knowledge Collection` + - Логическая группа записей. + - Единица импорта/экспорта. + - Примеры: scenario pack, lore pack, faction pack, mystery pack. + +- `Knowledge Record` + - Одна atomic knowledge entry. + - Примеры: персонаж, место, событие, правило, предмет, факт, заметка, clue, state node. + +- `Knowledge Link` + - Явная связь между двумя knowledge records. + +- `Baseline Record` + - Запись, созданная до текущей игровой сессии или импортированная как исходный материал. + +- `Runtime Record` + - Запись, созданная в ходе игры пользователем, системой или LLM. + +- `Discoverability` + - Можно ли найти запись как candidate. + +- `Readability` + - Можно ли прочитать содержимое записи. + +- `Promptability` + - Можно ли использовать запись в prompt materialization. + +- `Reveal` + - Явная смена runtime state записи, после которой запись считается раскрытой в текущем чате/ветке. + +## 4. Scope model + +Knowledge store поддерживает два уровня scope: + +- `chat` + - запись общая для всего чата + +- `branch` + - запись или runtime state локальны для конкретной ветки + +Рекомендуемое правило v1: + +- `branchId = null` означает chat-scoped record/state +- `branchId != null` означает branch-scoped record/state + +На чтении активная ветка должна видеть: + +1. chat-scoped records +2. branch-scoped records текущей ветки + +Глубокую inheritance chain между ветками в v1 лучше не вводить. + +## 5. Что должно храниться в knowledge store + +Knowledge store должен уметь хранить: + +- сущности +- факты +- места +- фракции +- предметы +- правила мира +- события +- квестовые узлы +- скрытые данные +- runtime-заметки +- runtime-derived records +- связи между сущностями + +Knowledge store не должен быть заточен только под детективные сценарии. + +## 6. Предлагаемая логическая модель данных + +### 6.1. `knowledge_collections` + +Единица импорта/экспорта и группировки. + +Рекомендуемые поля: + +- `id` +- `ownerId` +- `chatId` +- `branchId | null` +- `scope` = `chat | branch` +- `name` +- `kind` +- `description` +- `status` = `active | archived | deleted` +- `origin` = `import | author | system_seed | user | llm` +- `layer` = `baseline | runtime` +- `metaJson` +- `createdAt` +- `updatedAt` + +Назначение: + +- объединяет записи в пакет +- дает контролируемую единицу export/import +- позволяет держать baseline pack отдельно от runtime pack + +### 6.2. `knowledge_records` + +Каноническая запись knowledge store. + +Рекомендуемые поля: + +- `id` +- `ownerId` +- `chatId` +- `branchId | null` +- `collectionId` +- `recordType` +- `key` +- `title` +- `aliasesJson` +- `tagsJson` +- `summary` +- `contentJson` +- `searchText` +- `accessMode` +- `origin` +- `layer` +- `derivedFromRecordId | null` +- `sourceMessageId | null` +- `sourceOperationId | null` +- `status` = `active | archived | deleted` +- `metaJson` +- `createdAt` +- `updatedAt` + +Рекомендуемые инварианты: + +- `key` должен быть stable identifier внутри `chatId + branchId + collectionId` +- `(chatId, branchId, collectionId, key)` должен быть unique +- `searchText` является нормализованным материалом для FTS + +Пояснения по полям: + +- `recordType` + - Примеры: `entity`, `location`, `event`, `rule`, `item`, `fact`, `note`, `state`. + +- `contentJson` + - Structured payload записи. + - Это не произвольный blob без смысла. + - Для разных `recordType` допускаются разные shape contracts. + +- `searchText` + - Flattened search material. + - Должен включать только разрешенный для индексации текст. + - Для закрытых записей сюда не должен безусловно попадать весь hidden payload. + +### 6.3. `knowledge_record_links` + +Явные связи между записями. + +Рекомендуемые поля: + +- `id` +- `ownerId` +- `chatId` +- `branchId | null` +- `fromRecordId` +- `relationType` +- `toRecordId` +- `metaJson` +- `createdAt` +- `updatedAt` + +Примеры `relationType`: + +- `located_in` +- `belongs_to` +- `knows_about` +- `depends_on` +- `reveals` +- `contradicts` +- `same_as` + +### 6.4. `knowledge_record_access_state` + +Runtime state доступа и раскрытия записи. + +Эту таблицу лучше держать отдельно от `knowledge_records`, чтобы: + +- не мутировать baseline content при обычном reveal +- хранить reveal state как runtime overlay +- управлять chat/branch-specific состоянием + +Рекомендуемые поля: + +- `id` +- `ownerId` +- `chatId` +- `branchId | null` +- `recordId` +- `discoverState` = `hidden | discoverable | visible` +- `readState` = `blocked | partial | full` +- `promptState` = `blocked | allowed` +- `revealState` = `hidden | revealed` +- `revealedAt | null` +- `revealedBy` = `system | user | llm | import | null` +- `revealReason | null` +- `flagsJson` +- `updatedAt` + +Рекомендуемый unique constraint: + +- `(chatId, branchId, recordId)` + +### 6.5. Search index + +Для v1 нужен SQLite FTS5 индекс поверх `knowledge_records`. + +Технически это должна быть отдельная FTS virtual table, но логически она является search projection, а не новой доменной сущностью. + +FTS index должен строиться по: + +- `title` +- `aliases` +- `tags` +- `summary` +- `searchText` + +Но только из разрешенного searchable material. + +## 7. Access model for closed records + +### 7.1. Почему нужен отдельный access model + +Нужно различать: + +- можно ли найти запись +- можно ли читать запись +- можно ли отправить запись в prompt + +Это не один флаг. + +### 7.2. `accessMode` + +Рекомендуемые режимы записи: + +- `public` + - можно искать, читать и использовать + +- `discoverable` + - можно найти как candidate, но полное содержимое доступно только после выполнения условий + +- `hidden` + - запись не участвует в обычном поиске и не показывается модели до unlock/reveal + +- `internal` + - служебная запись для system/runtime use + +### 7.3. Safe searchable surface + +Для закрытых записей нужно разделять: + +- `searchable metadata` +- `safe preview` +- `full content` + +Пример: + +- `title` доступен +- `summary` доступен в redacted-safe виде +- полный `contentJson` закрыт + +Это дает возможность: + +- найти релевантную запись +- не слить спойлер в prompt раньше времени + +### 7.4. Gate policy + +У записи должен быть `gatePolicyJson` в `metaJson` или отдельном поле. + +Рекомендуемая логика v1: + +- `all` +- `any` +- `not` + +И предикаты: + +- `flag_equals` +- `record_revealed` +- `record_state` +- `counter_gte` +- `manual_unlock` +- `branch_only` + +Пример: + +```json +{ + "discover": { "mode": "always" }, + "read": { + "all": [ + { "type": "flag_equals", "key": "quest.met_historian", "value": true }, + { "type": "record_revealed", "recordKey": "clue:tablet" } + ] + }, + "prompt": { + "all": [ + { "type": "record_revealed", "recordKey": "clue:tablet" } + ] + } +} +``` + +### 7.5. Reveal is a separate action + +Запись не должна открываться автоматически от самого факта поиска. + +Поиск и раскрытие должны быть разными действиями: + +1. search находит candidate +2. backend проверяет access +3. отдельный reveal action может открыть запись + +Правильный механизм: + +- `knowledge.search` не меняет state +- `knowledge.reveal` меняет state + +### 7.6. Allowed reveal triggers + +Рекомендуемые trigger modes v1: + +- `manual` + - явное действие user/system + +- `policy_only` + - backend сам может раскрыть запись после deterministic policy check + +- `llm_proposed` + - LLM предлагает reveal, backend валидирует + +- `record_revealed` + - reveal по раскрытию другой записи + +- `flag` + - reveal по runtime progress flag + +- `counter` + - reveal по накопленному счетчику + +### 7.7. Runtime truth + +Правда о том, раскрыта запись или нет, должна храниться в `knowledge_record_access_state`, а не вычисляться только на лету. + +Это нужно для: + +- реплея ветки +- branch-specific state +- export modes +- понятного UI/debug + +## 8. Provenance and baseline/runtime separation + +### 8.1. Почему origin недостаточно + +Одного поля `createdDuringGame` недостаточно. + +Нужно различать: + +- откуда запись появилась +- к какому слою относится +- является ли она производной + +### 8.2. Recommended provenance fields + +Для `knowledge_records`: + +- `origin` + - `import | author | system_seed | user | llm` + +- `layer` + - `baseline | runtime` + +- `derivedFromRecordId | null` + - если runtime record появился на основе существующей записи + +- `sourceMessageId | null` + - из какого сообщения появилась запись + +- `sourceOperationId | null` + - какая операция ее создала + +### 8.3. Recommended rules + +- baseline records не должны бездумно перезаписываться runtime логикой +- reveal state baseline records не должен мутировать сам baseline content +- runtime-created records должны быть явно отличимы от imported/seed records + +### 8.4. Mutations + +Для v1 рекомендуется: + +- baseline content хранить как канонический слой +- reveal/progress state держать отдельно +- runtime-добавления хранить как runtime records + +Полную copy-on-write overlay систему можно отложить. + +## 9. Import/export model + +### 9.1. Export unit + +Основная единица export/import должна быть `knowledge_collection`. + +### 9.2. Required export modes + +Knowledge store должен поддерживать минимум такие режимы: + +- `baseline_only` + - только исходные baseline records и links + +- `runtime_only` + - только runtime-created records и runtime access state + +- `baseline_plus_runtime` + - полный snapshot + +- `baseline_with_reveals` + - baseline records + runtime reveal/access state, но без runtime-created records + +### 9.3. Why this matters + +Это позволяет: + +- экспортировать чистый сценарный пакет без игрового мусора +- экспортировать только прогресс игры +- восстанавливать hidden/revealed state отдельно от контента + +## 10. Search model + +### 10.1. Search types + +Search должен быть гибридным: + +- exact by `key` +- exact/prefix by `title` +- aliases match +- tags overlap +- FTS5 full-text match +- optional relations-aware narrowing + +### 10.2. Search request + +Операции не должны слать произвольный SQL-подобный DSL. + +Рекомендуемый безопасный API: + +```json +{ + "textQuery": "лес древняя магия проклятие", + "keys": ["dark_forest"], + "titles": ["Темный лес"], + "aliases": ["черный лес"], + "tags": ["forest", "curse"], + "recordTypes": ["location", "fact"], + "collectionIds": ["mystery-pack"], + "scope": "active_branch_visible", + "includeHiddenCandidates": false, + "limit": 10, + "minScore": 0.25, + "minimumShouldMatch": 2 +} +``` + +### 10.3. Search response + +Search должен возвращать: + +- `recordId` +- `score` +- `matchReasons` +- `visibility` +- `preview` +- `record` only when allowed + +Примерно так: + +```json +{ + "hits": [ + { + "recordId": "loc_dark_forest", + "score": 0.91, + "matchReasons": ["title_exact", "fts"], + "visibility": "full", + "preview": { + "title": "Темный лес", + "summary": "Древний лес на северной границе" + }, + "record": { + "recordType": "location", + "tags": ["forest", "north", "curse"] + } + } + ] +} +``` + +### 10.4. Ranking + +V1 ranking может быть эвристическим: + +- exact key > exact title > alias > tags > FTS +- FTS relevance учитывает BM25-like score +- `minimumShouldMatch` и `minScore` режут слабые совпадения + +Точное математическое ранжирование не является ключевой целью v1. + +### 10.5. Hidden content indexing rule + +Полный hidden content не должен безусловно индексироваться в обычный searchable surface. + +Рекомендуемое правило: + +- `public` записи индексируются полностью +- `discoverable` записи индексируются по title/aliases/tags/safe preview +- `hidden` записи не участвуют в обычном search index + +## 11. Retrieval flow inside operations + +Правильный пайплайн: + +1. `planner` operation/LLM читает текущий turn и возвращает retrieval request +2. backend выполняет search +3. backend фильтрует результаты по access policy +4. `curator` operation/LLM получает candidate set +5. curator выбирает только реально нужные записи +6. выбранный knowledge material попадает в prompt + +Допустимы дополнительные операции: + +- `knowledge.reveal` +- `knowledge.upsert` +- `knowledge.link` + +Но они не должны смешиваться с `knowledge.search`. + +## 12. How Chroma fits later + +### 12.1. V1 + +В v1 Chroma не нужен для основной логики knowledge store. + +V1 должен работать на: + +- SQLite tables +- SQLite FTS5 + +### 12.2. V2 + +После появления vector retrieval: + +- те же `knowledge_records` остаются source of truth +- в Chroma индексируются documents по тем же `recordId` +- metadata в Chroma содержит минимум: + - `recordId` + - `chatId` + - `branchId` + - `collectionId` + - `recordType` + - `accessMode` + - `layer` + +### 12.3. Hybrid retrieval in v2 + +Будущий hybrid retrieval: + +1. exact/tag/FTS hits из SQLite +2. vector hits из Chroma +3. merge by `recordId` +4. rerank +5. pass to curator + +Итог: + +- не создается новая доменная сущность +- добавляется только еще один индексный слой + +## 13. Recommended backend API surface + +### 13.1. Repository-level operations + +Нужны repository/service методы: + +- `createKnowledgeCollection` +- `listKnowledgeCollections` +- `exportKnowledgeCollection` +- `importKnowledgeCollection` +- `upsertKnowledgeRecord` +- `listKnowledgeRecords` +- `searchKnowledgeRecords` +- `createKnowledgeLink` +- `listKnowledgeLinks` +- `revealKnowledgeRecord` +- `setKnowledgeAccessState` + +### 13.2. Operation-facing actions + +Для operation pipeline рекомендуется explicit actions: + +- `knowledge.search` +- `knowledge.reveal` +- `knowledge.upsert` +- `knowledge.link` + +`knowledge.search` должен быть read-only. + +`knowledge.reveal` должен быть guarded. + +## 14. V1 implementation blueprint + +### Phase 1. Storage foundation + +Сделать новые schema modules: + +- `server/src/db/schema/chat-knowledge.ts` + +Добавить таблицы: + +- `knowledge_collections` +- `knowledge_records` +- `knowledge_record_links` +- `knowledge_record_access_state` + +Добавить FTS migration для `knowledge_records`. + +### Phase 2. Repository layer + +Добавить `server/src/services/chat-knowledge/**`: + +- collections repository +- records repository +- links repository +- access-state repository +- search service + +### Phase 3. Search + +Сделать search service с: + +- exact lookup +- alias/tag lookup +- FTS lookup +- merge + ranking +- score thresholding + +### Phase 4. Operation integration + +Добавить operation-facing use cases: + +- planner -> retrieval request +- search -> candidate set +- curator -> prompt-ready reduction +- reveal action + +### Phase 5. Import/export + +Добавить collection import/export contracts. + +Минимальные режимы: + +- baseline only +- runtime only +- full snapshot + +### Phase 6. Chroma sync + +После стабилизации SQLite knowledge model: + +- добавить optional Chroma sync/indexing +- не менять доменные ids и storage truth + +## 15. Non-goals for v1 + +В v1 не нужно: + +- делать universal query DSL +- делать глубокую branch inheritance chain +- делать сложную graph query language +- делать full semantic retrieval mandatory +- давать LLM прямой доступ к raw hidden payload +- завязывать truth целиком на Chroma + +## 16. Final decisions captured by this spec + +1. Knowledge store это отдельная доменная сущность, не `world-info`, не artifacts и не runtime state. +2. Записи должны быть chat-scoped и branch-scoped. +3. Source of truth должен быть в SQLite. +4. Обычный поиск v1 должен быть гибридным и включать FTS5. +5. Chroma должен использоваться позже как вторичный vector index поверх тех же записей. +6. Search не должен автоматически раскрывать записи. +7. Для закрытых записей нужен отдельный access/reveal model. +8. Baseline и runtime данные должны быть явно разделены. +9. Import/export должен работать на уровне collections и поддерживать разные режимы snapshot. +10. В prompt попадает только результат explicit retrieval + curation, а не вся knowledge база. diff --git a/knowledge-base/guard-operation-implementation-spec-2026-03-15.md b/knowledge-base/guard-operation-implementation-spec-2026-03-15.md new file mode 100644 index 00000000..31e0858a --- /dev/null +++ b/knowledge-base/guard-operation-implementation-spec-2026-03-15.md @@ -0,0 +1,656 @@ +# Spec: План Внедрения Guard Operation + +_Дата: 2026-03-15_ + +## 1) Цель + +Цель этого документа: зафиксировать полный рабочий план внедрения `kind="guard"` в текущую архитектуру TaleSpinner. + +Feature goal: + +- дать пользователю возможность вычислять структурированные boolean-флаги по контексту чата; +- использовать эти флаги для ветвления исполнения других операций; +- отобразить ветви guard в node-editor; +- сохранить совместимость с текущей моделью `Operation -> artifact -> effects -> orchestrator`. + +## 2) Ключевое решение + +Guard не должен становиться отдельной магической подсистемой. + +Guard должен быть: + +- обычной операцией в профиле; +- с новым `kind="guard"`; +- с собственным typed output contract; +- с одним JSON-артефактом на выходе; +- с отдельным механизмом `runConditions` для downstream-ветвления. + +Важное архитектурное решение: + +- `dependsOn` отвечает за порядок исполнения; +- `runConditions` отвечает за логическое ветвление; +- `@core/operation-orchestrator` остаётся универсальным и не знает про guard-специфику; +- guard-специфика реализуется в operation runtime-слое. + +## 3) Scope + +Входит в scope: + +- shared contracts для guard и `runConditions`; +- save-time validation; +- runtime support в chat-generation-v3; +- поддержка `liquid` и `aux_llm` guard engine; +- node-editor representation; +- editor UX; +- тесты; +- внутренняя документация. + +Не входит в scope первого цикла: + +- nested guard outputs; +- сложные логические выражения (`AND/OR groups`) между несколькими условиями; +- arbitrary JSON-schema outputs вместо boolean map; +- условные связи между разными hook phases без явных зависимостей; +- отдельный DSL для правил. + +## 4) Target Contract + +### 4.1 Shared types + +Нужно расширить `shared/types/operation-profiles.ts`. + +Новые типы: + +```ts +export type GuardEngine = "liquid" | "aux_llm"; + +export type GuardRunOnError = "error" | "all_false"; + +export type GuardOutputDefinition = { + key: string; + title: string; + description?: string; +}; + +export type GuardOutputContract = GuardOutputDefinition[]; + +export type GuardLiquidParams = { + engine: "liquid"; + outputContract: GuardOutputContract; + template: string; + strictVariables?: boolean; + runOnError?: GuardRunOnError; +}; + +export type GuardAuxLlmParams = { + engine: "aux_llm"; + outputContract: GuardOutputContract; + system?: string; + prompt: string; + strictVariables?: boolean; + providerId: "openrouter" | "openai_compatible"; + credentialRef: string; + model?: string; + timeoutMs?: number; + retry?: LlmOperationRetry; + samplers?: LlmOperationSamplers; + runOnError?: GuardRunOnError; +}; + +export type GuardOperationParams = + | (GuardLiquidParams & { artifact: OperationArtifactConfig }) + | (GuardAuxLlmParams & { artifact: OperationArtifactConfig }); + +export type OperationRunCondition = + | { + type: "guard_output"; + sourceOpId: string; + outputKey: string; + operator: "is_true"; + } + | { + type: "guard_output"; + sourceOpId: string; + outputKey: string; + operator: "is_false"; + }; +``` + +Изменения существующих типов: + +```ts +export type OperationKind = + | "template" + | "llm" + | "guard" + | "rag" + | "tool" + | "compute" + | "transform" + | "legacy"; +``` + +```ts +export type OperationConfig = { + enabled: boolean; + required: boolean; + hooks: OperationHook[]; + triggers?: OperationTrigger[]; + activation?: OperationActivationConfig; + order: number; + dependsOn?: string[]; + runConditions?: OperationRunCondition[]; + params: TParams; +}; +``` + +Примечание: + +- guard лучше описывать отдельным специализированным params-типом, а не generic `paramsJson`; +- это упростит и backend validation, и frontend editor. + +### 4.2 Runtime result + +Runtime value guard-артефакта: + +```ts +export type GuardOutputValue = Record; +``` + +Новые runtime причины пропуска: + +```ts +export type OperationSkipReason = + | "activation_not_reached" + | "dependency_not_done" + | "dependency_missing" + | "guard_not_matched" + | "unsupported_kind" + | "orchestrator_aborted" + | "filtered_out" + | "disabled"; +``` + +Расширение `skipDetails`: + +```ts +export type OperationSkipDetails = { + activation?: { ... }; + blockedByOpIds?: string[]; + blockedByReason?: "activation_not_reached"; + guard?: { + sourceOpId: string; + outputKey: string; + operator: "is_true" | "is_false"; + actual: boolean | null; + }; +}; +``` + +## 5) Save-time Validation Plan + +Затрагиваемый код: + +- `server/src/services/operations/operation-block-validator.ts` +- возможно `server/src/services/operations/operation-profile-validator.ts` + +Что нужно добавить: + +1. Распознавание `kind="guard"` в Zod-схеме. +2. Новую Zod-схему для guard params. +3. Валидацию `outputContract`. +4. Валидацию `runConditions`. +5. Валидацию совместимости hooks/dependencies. +6. Валидацию artifact policy для guard. + +Правила: + +- `artifact.format === "json"` обязательно; +- `outputContract.length >= 1`; +- все `outputContract.key` уникальны; +- `runConditions[].sourceOpId` существует; +- `runConditions[].sourceOpId` ссылается на `kind="guard"`; +- `runConditions[].outputKey` существует в source guard; +- каждый `runCondition.sourceOpId` должен быть в `dependsOn`; +- hooks target operation должны быть подмножеством hooks source guard, как и для обычных dependency rules; +- для `engine="aux_llm"` нельзя разрешать не-json поведение; +- для `engine="liquid"` `template` должен компилироваться. + +Рекомендация: + +- не разрешать `runConditions` без явного `dependsOn`, даже если source guard идёт раньше по `order`. + +## 6) Runtime Execution Plan + +Затрагиваемый код: + +- `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts` +- новый helper-файл под guard runtime +- `server/src/services/chat-generation-v3/contracts.ts` + +### 6.1 Что не нужно делать + +Не нужно: + +- переписывать `@core/operation-orchestrator`; +- делать task ids вида `guard:isBattle`; +- учить оркестратор понимать branch outputs. + +Почему: + +- это бизнес-логика operation runtime, а не generic DAG runtime; +- core orchestrator сейчас правильно изолирован. + +### 6.2 Что нужно сделать + +Нужно расширить `executeOperationsPhase` так, чтобы: + +1. `kind="guard"` входил в executable operations. +2. Для guard выполнялся отдельный runtime executor. +3. После выполнения dependency chain, но до старта конкретной операции, проверялись `runConditions`. +4. Если `runConditions` не совпали, операция помечалась `skipped` с `guard_not_matched`. + +Рекомендуемое разбиение: + +- `execute-operations-phase.ts` оставляет orchestration flow; +- новый `guard-operation-executor.ts` выполняет guard; +- новый `guard-run-conditions.ts` оценивает условия перед стартом операции; +- новый helper для извлечения guard value из runtime artifacts. + +### 6.3 Порядок проверки + +Порядок в runtime: + +1. Отфильтровать операции по enabled/hook/trigger/activation. +2. Оркестратор ждёт выполнения `dependsOn`. +3. Когда конкретная операция становится runnable: + - собрать preview state от зависимостей; + - проверить `runConditions`; + - если хотя бы одно условие не совпало, не исполнять `run`, а вернуть `skipped`. + +Рекомендация: + +- condition evaluation делать внутри runtime execution path конкретной задачи, а не как статический pre-filter. + +Причина: + +- условие зависит от runtime values зависимостей, а не только от статической конфигурации. + +### 6.4 Guard executor + +`guard-operation-executor.ts` должен: + +- принимать `op`, `liquidContext`, `abortSignal`; +- возвращать: + - `renderedValue: Record` + - `debugSummary` + +#### `engine="liquid"` + +Алгоритм: + +1. Render Liquid template to string. +2. Parse JSON. +3. Validate against derived contract. +4. Return object. + +#### `engine="aux_llm"` + +Алгоритм: + +1. Render `system` and `prompt`. +2. Build derived schema from `outputContract`. +3. Invoke aux LLM in strict json mode. +4. Validate JSON. +5. Return object. + +### 6.5 Artifact write + +Guard effect: + +```ts +{ + type: "artifact.upsert", + opId: op.opId, + artifactId: artifactRuntimeKey, + format: "json", + persistence: artifact.persistence, + writeMode: "replace", + history: artifact.history, + semantics: artifact.semantics ?? "intermediate", + value: guardValue +} +``` + +### 6.6 Debug / observability + +Нужно логировать безопасно: + +- engine type; +- output keys; +- whether parsing/validation passed; +- which downstream operations were skipped by guard; +- condition mismatch details. + +Не нужно логировать без лимитов: + +- полный prompt aux guard; +- полный model output; +- большие payloads. + +## 7) Template Context Plan + +Затрагиваемый код: + +- `server/src/services/chat-core/prompt-template-context.ts` +- `server/src/services/chat-core/prompt-template-renderer.ts` + +Что уже достаточно: + +- `messages` +- `promptSystem` +- `art` +- `artByOpId` +- `chat` +- `user` +- `char` + +Что желательно добавить в рамках guard: + +- `worldInfo.activatedCount` +- `worldInfo.activatedEntries` +- `worldInfo.warnings` + +Рекомендация: + +- расширять `InstructionRenderContext`, потому что это полезно не только guard. + +Что ещё потребуется для `liquid` guard: + +- фильтры/хелперы для boolean-friendly вычислений; +- возможно `json` filter для безопасной сериализации boolean/string values; +- при необходимости regex helpers. + +Рекомендация для v1: + +- не делать слишком широкий DSL; +- добавить только минимальный набор helpers, который нужен для первых реальных сценариев. + +## 8) Web / Editor Plan + +Затрагиваемый код: + +- `web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts` +- `web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/params-section.tsx` +- новые guard-specific sections +- `web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/execution-section.tsx` +- `web/src/features/sidebars/operation-profiles/node-editor/flow/operation-flow-node.tsx` +- `web/src/features/sidebars/operation-profiles/node-editor/block-node-editor-modal.tsx` +- RU/EN i18n resources + +### 8.1 Form model + +Нужно добавить новый form-kind: + +- `kind="guard"` +- редактор `outputContract` +- переключатель engine +- textarea для liquid template +- LLM config subset для aux_llm +- отображение `artifact.format=json` как фиксированного ограничения + +### 8.2 Execution section + +Нужно добавить UI для `runConditions`. + +Вид: + +- `source guard` +- `output key` +- `operator (is true / is false)` + +UX rules: + +- список source guards берётся из операций того же блока; +- список `outputKey` зависит от выбранного source guard; +- если выбран source guard, которого больше нет, форма показывает ошибку; +- `source guard` автоматически добавляется в `dependsOn`, либо UI требует сделать это явно. + +Рекомендация: + +- добавлять dependency автоматически и показывать пользователю это явно. + +### 8.3 Node editor + +Нужно расширить node model: + +- guard node показывает multi-output handles; +- обычные edges остаются для `dependsOn`; +- guard-condition edges рисуются отдельно. + +Нужны структурированные данные для condition edges: + +```ts +type GuardConditionEdge = { + sourceOpId: string; + outputKey: string; + operator: "is_true" | "is_false"; + targetOpId: string; +}; +``` + +Хранение: + +- condition edges не должны вычисляться из строк; +- они должны строиться из `runConditions`. + +Рекомендованный rollout: + +1. Сначала form/editor support без сложного graph editing. +2. Потом node-editor render support. +3. Потом полноценное редактирование guard edges прямо на графе. + +Это уменьшит размер первого PR. + +## 9) i18n Plan + +Затрагиваемый код: + +- `web/src/i18n/resources/ru/operationProfiles.ts` +- `web/src/i18n/resources/en/operationProfiles.ts` + +Нужно добавить: + +- новый kind label `guard`; +- labels для engine; +- labels для outputs contract; +- labels для run conditions; +- help text о том, что guard возвращает JSON boolean map; +- help text про difference between `dependsOn` and `runConditions`. + +## 10) Persistence / Migration + +DB migration для нового kind не требуется, если `operations` уже сохраняются как JSON blob и validator просто начнёт +пропускать новый `kind`. + +Нужно проверить: + +- не существует ли в UI/server мест с жёстким перечислением `OperationKind`; +- import/export bundle contracts корректно пропускают новый kind; +- legacy import paths не ломаются. + +Backwards compatibility: + +- старые профили должны продолжать работать без изменений; +- новые guard-операции должны экспортироваться/импортироваться обычным путём; +- никакой миграции старых `dependsOn` строк не требуется, потому что guard routing вводится как новый контракт. + +## 11) Testing Plan + +По AGENTS backend/frontend изменения должны идти через TDD. + +### 11.1 Shared / validator tests + +Нужны тесты на: + +- валидный guard block; +- duplicate output keys; +- invalid artifact format; +- runCondition с неизвестным source op; +- runCondition с source op не guard; +- runCondition с неизвестным outputKey; +- runCondition без `dependsOn`. + +### 11.2 Runtime tests + +Нужны тесты на: + +- guard liquid returns valid JSON; +- guard liquid parse error; +- guard aux_llm returns valid JSON; +- guard aux_llm schema validation error; +- downstream op runs when guard output is true; +- downstream op skips with `guard_not_matched` when false; +- multiple outputs branch independently; +- guard artifact is visible through `art` and `artByOpId`. + +### 11.3 UI tests + +Нужны тесты на: + +- form mapping for guard params; +- runConditions form serialization/deserialization; +- node-editor meta/render for guard outputs; +- RU/EN label presence where practical. + +## 12) Recommended Delivery Plan + +### Phase 0: Docs + contract alignment + +Задачи: + +- утвердить spec и contracts; +- договориться о v1 scope. + +Результат: + +- согласованный дизайн без спорных string hacks. + +### Phase 1: Shared contracts + validator + +Задачи: + +- обновить shared types; +- обновить form mapping types; +- обновить server validators; +- написать unit tests. + +Результат: + +- профили с guard корректно сохраняются/валидируются. + +### Phase 2: Runtime support + +Задачи: + +- реализовать `guard-operation-executor`; +- добавить condition evaluation; +- обновить runtime result model; +- написать runtime/integration tests. + +Результат: + +- guard реально влияет на исполнение графа. + +### Phase 3: Editor support + +Задачи: + +- добавить guard form sections; +- добавить `runConditions` editor; +- добавить i18n; +- написать frontend tests. + +Результат: + +- feature можно настроить без ручного JSON. + +### Phase 4: Node-editor support + +Задачи: + +- multi-output guard node; +- read-only render guard branches; +- затем graph editing for guard branches. + +Результат: + +- ветвление видно на графе. + +### Phase 5: Hardening + +Задачи: + +- добавить полезные Liquid helpers; +- расширить context полями world-info runtime; +- улучшить diagnostics/debug. + +Результат: + +- guard применим к реальным продвинутым сценариям. + +## 13) Open Questions + +Нужно решить до реализации: + +1. Нужен ли `runOnError="all_false"` в первом релизе, или оставить только `error`. +2. Нужны ли nested outputs во v1. +3. Нужно ли разрешать несколько `runConditions` как implicit AND only, или сразу проектировать `AND/OR`. +4. Добавлять ли guard-ветки в node-editor сразу как редактируемые, или сначала только как отображаемые. +5. Должен ли `liquid` guard уметь использовать regex helpers из коробки, или это отдельный подэтап. + +Рекомендации: + +1. `runOnError`: можно оставить оба варианта, но дефолт `error`. +2. nested outputs: нет. +3. `runConditions`: только AND во v1. +4. node-editor editing: отложить на отдельный этап. +5. regex helpers: добавить только если первый реальный сценарий действительно требует их. + +## 14) Definition of Done + +Feature считается готовой, когда: + +- `kind="guard"` есть в shared contracts; +- validator принимает и отвергает корректные/некорректные конфиги по стабильным правилам; +- runtime умеет исполнять guard и пропускать downstream по `runConditions`; +- `guard_not_matched` виден в runtime results; +- editor умеет настраивать guard без raw JSON hacks; +- RU/EN локализация синхронизирована; +- тесты покрывают validator/runtime/UI-критические кейсы; +- обязательные проверки зелёные для фактически затронутых слоёв. + +## 15) Minimal First PR Recommendation + +Если резать работу на минимальный практичный инкремент, рекомендуемый первый PR: + +- shared types for `kind="guard"` and `runConditions`; +- validator support; +- runtime support only for `engine="aux_llm"`; +- form support without node-editor branch editing; +- tests for validator/runtime/form mapping. + +Почему именно так: + +- `aux_llm` быстрее даёт value для сложных семантических кейсов; +- `liquid` helpers можно спокойно добавить во втором PR; +- node-editor graph editing — самая дорогая часть UX, её лучше не смешивать с core runtime. + +Альтернативный первый PR: + +- `engine="liquid"` first, если приоритет — дешёвый deterministic guard без внешних вызовов. + +Выбор зависит от того, какой кейс для команды важнее: + +- быстрый semantic routing; +- или дешёвый local rule engine. diff --git a/knowledge-base/pipelines-and-processing-spec-v2/13-operation-kind-guard.md b/knowledge-base/pipelines-and-processing-spec-v2/13-operation-kind-guard.md new file mode 100644 index 00000000..6b4ffff8 --- /dev/null +++ b/knowledge-base/pipelines-and-processing-spec-v2/13-operation-kind-guard.md @@ -0,0 +1,524 @@ +# v2 — Operation / kind=guard (контракт guard-операции) + +Этот документ описывает контракт для операций с `OperationDefinition.kind="guard"`. + +Цель guard-операции: посмотреть на контекст текущего `Run`, выполнить набор проверок и вернуть +типизированный JSON-результат, по которому другие операции смогут ветвиться. + +> Ключевой принцип: `guard` не вводит отдельную магическую модель исполнения. +> Это обычная операция v2, которая пишет один JSON-артефакт и даёт другим операциям +> структурированные условия запуска. + +## 0) Что такое Guard + +`kind="guard"` — это операция-классификатор. + +Она: + +- читает `OperationContext`; +- вычисляет набор флагов; +- возвращает JSON-объект вида `Record`; +- пишет этот объект в один `artifact` с `format="json"`; +- не изменяет prompt/history сама по себе, если это явно не настроено через `artifact.exposures`. + +Типичные кейсы: + +- проверить последние 5 сообщений на боевую сцену; +- определить, что сейчас ночь; +- определить, что сцена стала NSFW; +- проверить, что активировался нужный world-info/outlet; +- разветвить дальнейший граф на несколько веток. + +## 1) Инварианты v1 + +Для первого рабочего релиза guard должен быть максимально узким и предсказуемым. + +Инварианты: + +- guard пишет ровно один артефакт; +- guard-результат в v1 — плоский JSON-объект с boolean-полями; +- каждый ключ результата должен быть заранее объявлен в контракте операции; +- downstream-операции ветвятся не по строке вида `opId:isBattle`, а по структурированным `runConditions`; +- `false` в guard-результате не является ошибкой; +- если guard завершился `done`, его артефакт считается валидным и может читаться другими операциями; +- если downstream-операция не прошла `runConditions`, она получает `status="skipped"` и причину + `guard_not_matched`. + +Ограничения v1: + +- только плоские boolean outputs; +- без вложенных путей (`combat.isBattle`) и без массивов; +- без multi-value routing; +- без implicit conditions через `dependsOn`. + +Это ограничение сделано специально, чтобы: + +- упростить UI node-editor; +- упростить валидацию; +- не превращать guard в ещё один mini-language. + +## 2) Контракт результата guard + +### 2.1 Shape + +Guard-операция обязана вернуть JSON, совместимый с заранее объявленным `outputContract`. + +Минимальная форма: + +```json +{ + "isBattle": true, + "isNight": false, + "isNSFW": false +} +``` + +### 2.2 OutputContract + +Предлагаемый контракт v1: + +```ts +export type GuardOutputDefinition = { + key: string; + title: string; + description?: string; +}; + +export type GuardOutputContract = GuardOutputDefinition[]; +``` + +Правила: + +- `key` уникален внутри операции; +- `key` должен соответствовать безопасному формату идентификатора, например `^[a-z][a-zA-Z0-9_]*$`; +- каждый `key` обязан присутствовать в runtime-результате; +- значение каждого `key` должно быть boolean; +- лишние поля в результате guard не допускаются в strict-режиме v1. + +Пример: + +```ts +outputContract: [ + { key: "isBattle", title: "Battle" }, + { key: "isNight", title: "Night time" }, + { key: "isNSFW", title: "NSFW" }, +] +``` + +### 2.3 Почему не `jsonSchema` общего вида + +Внутренне можно генерировать `jsonSchema` из `outputContract`, но внешний контракт guard лучше держать отдельным. + +Причины: + +- node-editor должен заранее знать, какие ветки рисовать; +- UI должен уметь показывать именованные выходы; +- валидация связи должна понимать не просто JSON, а ветку guard output; +- nested JSON сильно усложнит UX и runtime на первом этапе. + +Вывод: в v1 guard имеет собственный небольшой контракт outputs, а не произвольную JSON-схему. + +## 3) `params` для `kind="guard"` + +Предлагаемый минимальный контракт: + +```ts +export type GuardEngine = "liquid" | "aux_llm"; + +export type GuardRunOnError = "error" | "all_false"; + +export type GuardParams = + | { + engine: "liquid"; + outputContract: GuardOutputContract; + template: string; + strictVariables?: boolean; + runOnError?: GuardRunOnError; + artifact: OperationArtifactConfig; + } + | { + engine: "aux_llm"; + outputContract: GuardOutputContract; + system?: string; + prompt: string; + strictVariables?: boolean; + providerId: "openrouter" | "openai_compatible"; + credentialRef: string; + model?: string; + timeoutMs?: number; + retry?: LlmOperationRetry; + samplers?: LlmOperationSamplers; + runOnError?: GuardRunOnError; + artifact: OperationArtifactConfig; + }; +``` + +### 3.1 `engine="liquid"` + +Liquid-guard нужен для дешёвых, быстрых и детерминированных проверок. + +Идея v1: + +- `template` рендерится через LiquidJS; +- результатом рендера должна быть JSON-строка; +- эта JSON-строка парсится и валидируется по `outputContract`. + +Пример: + +```liquid +{ + "isBattle": {{ recentMessagesText(5) contains "attack" | json }}, + "isNight": {{ world.timeOfDay == "night" | json }}, + "isNSFW": {{ recentMessagesText(5) contains "kiss" | json }} +} +``` + +Примечание: для такого сценария потребуется расширить набор внутренних Liquid filters/helpers, потому что текущий +контекст и фильтры покрывают не все нужные проверки. + +### 3.2 `engine="aux_llm"` + +Aux-LLM guard нужен для нечётких семантических проверок, где regex/contains уже не хватает. + +Идея v1: + +- `system` и `prompt` рендерятся как Liquid-шаблоны; +- aux LLM вызывается в JSON-режиме; +- schema для ответа автоматически выводится из `outputContract`; +- результат строго валидируется; +- на выходе guard всё равно пишет тот же boolean JSON. + +Важный принцип: + +- оба engine должны приводиться к одному и тому же runtime-контракту; +- downstream-операции не должны знать, как именно guard вычислялся. + +### 3.3 `runOnError` + +Предлагаемый рабочий минимум: + +- `error` — ошибка вычисления завершает guard как `status="error"`; +- `all_false` — ошибка вычисления приводит к synthetic-результату, где все outputs=`false`, и guard + завершается `done`. + +Рекомендация для v1: + +- дефолт = `error`; +- `all_false` использовать только там, где пропуск ветки безопаснее, чем остановка сценария. + +## 4) Входы guard-операции + +Guard читает стандартный `OperationContext`. + +На практике для guard особенно важны: + +- `messages`; +- `promptSystem`; +- `art` и `artByOpId`; +- `chat`; +- `char`; +- `user`; +- `rag`; +- world-info, уже разрешённый в template context; +- future runtime meta для world-info activation/debug. + +Для реальных guard-кейсов v1 желательно дополнить context удобными полями: + +- `worldInfo.activatedCount`; +- `worldInfo.activatedEntries`; +- `worldInfo.warnings`; +- `chatRuntime` или аналогичный объект для derived runtime facts. + +## 5) Как downstream-операции ветвятся + +### 5.1 Почему недостаточно `dependsOn` + +`dependsOn` отвечает только на вопрос “когда можно стартовать”. + +Для guard нам нужен отдельный вопрос: + +- можно ли запускать эту операцию при текущем значении guard outputs. + +Поэтому v1 должен ввести второй механизм: `runConditions`. + +### 5.2 Предлагаемый контракт `runConditions` + +```ts +export type OperationRunCondition = + | { + type: "guard_output"; + sourceOpId: string; + outputKey: string; + operator: "is_true"; + } + | { + type: "guard_output"; + sourceOpId: string; + outputKey: string; + operator: "is_false"; + }; +``` + +Расширение `OperationConfig`: + +```ts +export type OperationConfig = { + enabled: boolean; + required: boolean; + hooks: OperationHook[]; + triggers?: OperationTrigger[]; + activation?: OperationActivationConfig; + order: number; + dependsOn?: string[]; + runConditions?: OperationRunCondition[]; + params: TParams; +}; +``` + +### 5.3 Обязательное правило согласованности + +Если операция ссылается на guard через `runConditions`, этот `sourceOpId` должен также присутствовать в `dependsOn`. + +Причины: + +- явный порядок исполнения; +- понятный граф зависимостей; +- предсказуемый preview state; +- простая валидация. + +Правило: + +- `runCondition.sourceOpId` обязательно входит в `dependsOn`; +- `sourceOpId` обязан ссылаться именно на `kind="guard"`; +- `outputKey` обязан существовать в `sourceOpId.config.params.outputContract`. + +### 5.4 Почему не `opId:isBattle` + +Строковый формат вида `combat_guard:isBattle` не рекомендуется и не должен становиться канонической моделью. + +Причины: + +- невозможно нормально типизировать; +- сложно валидировать на save-time; +- сложно мигрировать; +- плохо отображается в UI; +- смешивает граф выполнения и граф ветвления в одну непрозрачную строку. + +## 6) Runtime-семантика + +### 6.1 Guard сам по себе + +Если guard отработал успешно: + +- `status="done"`; +- в `effects` есть один `artifact.upsert` с JSON-объектом `Record`; +- downstream-операции могут читать этот артефакт через `art` и `artByOpId`. + +Если guard вернул валидный JSON, где часть флагов `false`: + +- это нормальный результат; +- guard не считается `skipped`; +- guard не считается `error`. + +### 6.2 Downstream-операция с guard-условием + +Алгоритм: + +1. Операция ждёт завершения всех `dependsOn` со статусом `done`. +2. Перед стартом runtime проверяет все `runConditions`. +3. Если все условия совпали, операция стартует как обычно. +4. Если хотя бы одно условие не совпало, операция не стартует и получает: + - `status="skipped"` + - `skipReason="guard_not_matched"` + - `skipDetails` с объяснением, какое условие не совпало. + +Предлагаемый `skipDetails`: + +```ts +skipDetails: { + guard?: { + sourceOpId: string; + outputKey: string; + operator: "is_true" | "is_false"; + actual: boolean | null; + }; +} +``` + +## 7) Node-editor модель + +Guard должен отображаться как узел с несколькими именованными выходами. + +v1 UI-контракт: + +- у guard-узла есть стандартный target handle для `dependsOn`; +- у guard-узла есть несколько source handles, по одному на каждый `outputContract.key`; +- edge guard-ветки хранит не строку, а структурированную ссылку: + - `sourceOpId` + - `outputKey` + - `operator` + - `targetOpId` + +Рекомендуемая визуальная модель: + +- обычные `dependsOn` edges: нейтральные; +- guard-edges: отдельный стиль, label=`isBattle=true` или `isBattle=false`. + +Важно: + +- граф зависимостей и граф условий логически разные; +- в UI их можно рисовать как два типа рёбер, но в модели данных они не должны смешиваться. + +## 8) Артефакт guard + +Guard остаётся совместим с существующей artifact-моделью. + +Рекомендации для `artifact`: + +- `format="json"` +- `writeMode="replace"` +- `persistence="run_only"` по умолчанию +- `semantics="intermediate"` или `semantics="state"` +- `history.enabled=true` допустимо, но не обязательно + +По умолчанию у guard не должно быть prompt/UI exposures. + +Причина: + +- guard прежде всего решает ветвление, а не инжектит текст. + +## 9) Валидация save-time + +Guard требует новых правил в валидаторе блока/профиля. + +Минимум: + +- `kind="guard"` распознаётся схемой; +- `artifact.format` для guard должен быть `json`; +- `outputContract` не пустой; +- `outputContract.key` уникальны; +- `runConditions[].sourceOpId` существует; +- `runConditions[].sourceOpId` ссылается на `kind="guard"`; +- `runConditions[].outputKey` существует в guard-контракте; +- `runConditions[].sourceOpId` входит в `dependsOn`; +- hooks downstream-операции должны быть совместимы с hooks guard-операции. + +Для `engine="aux_llm"`: + +- runtime JSON schema автоматически выводится из `outputContract`; +- `strictSchemaValidation=true` включается принудительно; +- `outputMode="json"` фиксируется принудительно. + +Для `engine="liquid"`: + +- `template` должен компилироваться как Liquid; +- runtime JSON парсинг и contract validation обязательны. + +## 10) Ошибки + +Рекомендуемые коды ошибок: + +- `GUARD_TEMPLATE_RENDER_ERROR` +- `GUARD_OUTPUT_PARSE_ERROR` +- `GUARD_OUTPUT_VALIDATION_ERROR` +- `GUARD_PROVIDER_ERROR` +- `GUARD_TIMEOUT` +- `GUARD_INVALID_PARAMS` + +Семантика: + +- ошибка вычисления guard = ошибка самой guard-операции; +- `false` в выходе guard = не ошибка; +- пропуск downstream по guard = `skipped`, не ошибка guard-а. + +## 11) Пример профиля + +Пример guard: + +```json +{ + "opId": "combat_guard", + "name": "Combat guard", + "kind": "guard", + "config": { + "enabled": true, + "required": false, + "hooks": ["before_main_llm"], + "order": 100, + "params": { + "engine": "aux_llm", + "outputContract": [ + { "key": "isBattle", "title": "Battle" }, + { "key": "isNSFW", "title": "NSFW" } + ], + "system": "Classify the scene.", + "prompt": "Look at the last 5 messages and return JSON only.", + "providerId": "openrouter", + "credentialRef": "cred-1", + "model": "openai/gpt-5-mini", + "artifact": { + "artifactId": "artifact:combat_guard", + "tag": "combat_guard_state", + "title": "Combat guard state", + "format": "json", + "persistence": "run_only", + "writeMode": "replace", + "history": { "enabled": true, "maxItems": 20 }, + "exposures": [] + } + } + } +} +``` + +Пример downstream: + +```json +{ + "opId": "combat_dice", + "name": "Combat dice", + "kind": "llm", + "config": { + "enabled": true, + "required": false, + "hooks": ["before_main_llm"], + "order": 200, + "dependsOn": ["combat_guard"], + "runConditions": [ + { + "type": "guard_output", + "sourceOpId": "combat_guard", + "outputKey": "isBattle", + "operator": "is_true" + } + ], + "params": {} + } +} +``` + +## 12) Рекомендация по реализации + +Guard лучше реализовывать как новый `kind`, но не делать core-orchestrator guard-aware. + +Рекомендованный слой реализации: + +- `shared/**`: типы `kind="guard"` и `runConditions`; +- `server/src/services/operations/**`: save-time validation; +- `server/src/services/chat-generation-v3/operations/**`: runtime execute + condition evaluation; +- `web/**`: editor + node-editor + i18n. + +Сам `@core/operation-orchestrator` должен остаться универсальным DAG-исполнителем, знающим только о задачах и +зависимостях. + +## 13) Итог + +Guard в v1 — это: + +- новый `OperationKind`; +- два движка: `liquid` и `aux_llm`; +- один JSON-артефакт; +- плоский boolean output contract; +- отдельные `runConditions` для ветвления; +- multi-output узел в node-editor; +- никакой строковой магии в `dependsOn`. diff --git a/knowledge-base/pipelines-and-processing-spec-v2/map.md b/knowledge-base/pipelines-and-processing-spec-v2/map.md index 36e74ae8..756755ee 100644 --- a/knowledge-base/pipelines-and-processing-spec-v2/map.md +++ b/knowledge-base/pipelines-and-processing-spec-v2/map.md @@ -11,6 +11,7 @@ - [16 — Effect Commit (как применяются эффекты)](./16-effect-commit.md) - [11 — Operation / kind=llm](./11-operation-kind-llm.md) - [12 — Operation / kind=template](./12-operation-kind-template.md) + - [13 — Operation / kind=guard](./13-operation-kind-guard.md) - [20 — OperationProfile (профиль операций)](./20-operation-profile.md) - [30 — Run (жизненный цикл запуска и граница main LLM)](./30-run.md) - [40 — Artifacts (артефакты)](./40-artifacts.md) diff --git a/knowledge-base/st-chat-completion-presets-implementation-notes.md b/knowledge-base/st-chat-completion-presets-implementation-notes.md new file mode 100644 index 00000000..74b1d86f --- /dev/null +++ b/knowledge-base/st-chat-completion-presets-implementation-notes.md @@ -0,0 +1,23 @@ +# ST-like Chat Completion Presets — implementation notes + +## Decisions +- Canonical ST preset storage uses existing `instructions` records with `tsInstruction.mode = st_advanced`. +- Active preset remains per-chat through `instructionId`. +- Existing provider-side `llm-presets` stay separate and are relabeled as connection presets. +- Global bind toggle is persisted in app settings as `bindChatCompletionPresetToConnection`. + +## Best-effort bind mapping +- `chat_completion_source = openrouter` -> `activeProviderId = openrouter` +- any other explicit `chat_completion_source` -> `activeProviderId = openai_compatible` +- `openai_model` -> `activeModel` +- sensitive/raw-only connection fields are preserved for round-trip export but are not auto-applied + +## Round-trip rules +- `rawPreset` remains the source for unknown ST keys +- edited prompt blocks, prompt order, and response config overwrite only supported fields during export +- unsupported connection fields stay raw-only and surface warnings instead of failing import/apply + +## Intentional gaps +- No new dedicated backend entity/API for ST presets +- No automatic migration from provider-side `llm-presets` +- No direct auto-apply for sensitive fields like `custom_url`, `reverse_proxy`, or token secrets diff --git a/knowledge-base/template-runtime-dsl-spec-2026-03-16.md b/knowledge-base/template-runtime-dsl-spec-2026-03-16.md new file mode 100644 index 00000000..55db46b2 --- /dev/null +++ b/knowledge-base/template-runtime-dsl-spec-2026-03-16.md @@ -0,0 +1,619 @@ +# Spec: Native Template Runtime DSL + +_Date: 2026-03-16_ + +## 1. Goal + +This document defines the target design for a new native template/runtime DSL in TaleSpinner. + +The goal is to replace LiquidJS as the primary template engine with a controlled runtime that: + +- separates text templating from deterministic data evaluation; +- supports a minimal Handlebars-like template model; +- preserves required TaleSpinner custom behavior; +- preserves required SillyTavern compatibility syntax through aliases; +- supports variables, scoped state, and reusable user-defined macros; +- remains deterministic, inspectable, and safe to execute. + +This is a product/runtime spec, not an implementation task list. + +## 2. Motivation + +Current LiquidJS usage solves text interpolation but is a poor fit for structured deterministic evaluation. + +Example of the current pain: + +```txt +{ + "uwu": bool, + "ara": bool +} + +uwu = true if the text below contains uwu in any form +ara = true if the text below contains ara in any form + +text: {{lastUserMessage}} +``` + +This currently pushes a deterministic parsing task into an LLM or into text-assembly tricks. + +Target behavior: + +- deterministic checks like `contains(lastUserMessage, "uwu", "i")` must be evaluated directly by the runtime; +- structured values like objects and arrays must be produced as typed runtime values first and serialized second; +- text templates must remain useful for prompt assembly and UI-oriented rendering. + +## 3. Core Decision + +The new system MUST have two language surfaces over one shared runtime: + +1. `template` language + - used for prompt text, world info text, greeting templates, and similar string output; + - returns `string`. + +2. `expr` language + - used for deterministic computation, guard outputs, structured objects, booleans, arrays, and JSON-ready values; + - returns typed runtime values. + +Both languages MUST use: + +- the same base context; +- the same helper registry; +- the same variable/state model; +- the same macro registry; +- the same runtime limits and safety rules. + +## 4. Non-Goals + +The first version MUST NOT try to do the following: + +- full LiquidJS syntax compatibility; +- full Handlebars syntax compatibility; +- arbitrary JavaScript execution; +- user-defined parser rules; +- unrestricted recursive macro expansion; +- unrestricted persistent mutable state; +- implicit side effects from ordinary expressions. + +## 5. Runtime Model + +### 5.1 Context + +Base runtime context MUST be built from the existing `InstructionRenderContext`. + +At minimum it includes: + +- `char` +- `user` +- `chat` +- `messages` +- `rag` +- `worldInfo` +- `art` +- `artByOpId` +- `now` +- `anchorBefore` +- `anchorAfter` +- `description` +- `scenario` +- `personality` +- `system` +- `promptSystem` +- `persona` +- `wiBefore` +- `wiAfter` +- `loreBefore` +- `loreAfter` +- `outlet` +- `outletEntries` +- `anTop` +- `anBottom` +- `emTop` +- `emBottom` +- `mesExamples` +- `mesExamplesRaw` +- `lastUserMessage` +- `lastAssistantMessage` + +These names SHOULD remain stable across template and expr runtimes. + +### 5.2 State + +Runtime state MUST be separated from base context. + +Context is input data. State is mutable execution state. + +Target model: + +```ts +type RuntimeState = { + scopes: { + local: Record; + render: Record; + turn: Record; + session?: Record; + }; + trim?: { + pending: boolean; + }; + rng: () => number; +}; +``` + +### 5.3 Scopes + +The runtime MUST support the following scopes: + +- `local` + - visible only inside the current block or current macro expansion. +- `render` + - visible during the full current render/evaluation call. +- `turn` + - visible during the current generation cycle. +- `session` + - optional for v1 runtime contract, but reserved for future chat- or branch-level state. + +Rules: + +- reads MUST search from nearest scope to widest scope; +- writes MUST require an explicit target scope if they are not `local`; +- v1 default write scope SHOULD be `render`; +- writes to `session` MUST be explicitly enabled by the caller and MUST NOT be implicit. + +## 6. Language Surfaces + +## 6.1 `expr` Language + +The `expr` language is the canonical language for deterministic evaluation. + +It MUST support: + +- literals + - `null`, `true`, `false`, numbers, strings +- path lookup + - `char.name` + - `art.note.value` +- arrays + - `["a", "b"]` +- objects + - `{ uwu: true, ara: false }` +- function calls + - `contains(lastUserMessage, "uwu", "i")` +- unary operators + - `not x` +- binary operators + - `and` + - `or` + - `==` + - `!=` + +Example: + +```txt +{ + uwu: contains(lastUserMessage, "uwu", "i"), + ara: contains(lastUserMessage, "ara", "i") +} +``` + +This MUST evaluate to a typed object, not to text. + +## 6.2 `template` Language + +The `template` language is a text-producing language with Handlebars-like block semantics. + +The v1 native syntax MUST support: + +- output nodes + - `{{ char.name }}` + - `{{ contains(lastUserMessage, "uwu", "i") }}` +- conditional blocks + - `{{#if contains(lastUserMessage, "uwu", "i")}}...{{else}}...{{/if}}` +- iteration blocks + - `{{#each messages as msg}}...{{/each}}` +- statement calls + - `{{ set("mood", "angry") }}` +- macro calls + - `{{ use("sceneHeader", { char: char, user: user }) }}` + +The `template` language MUST render a string. + +## 6.3 Relationship Between `template` and `expr` + +`template` expressions MUST embed the same expression model as `expr`. + +This means: + +- one helper registry; +- one lookup model; +- one truthiness model; +- one set of core value types. + +The runtime MUST NOT maintain different meanings for the same expression between `template` and `expr`. + +## 7. AST Model + +The runtime SHOULD normalize both languages into a small shared internal AST. + +Illustrative model: + +```ts +type ExprNode = + | { kind: "literal"; value: null | boolean | number | string } + | { kind: "path"; segments: string[] } + | { kind: "call"; name: string; args: ExprNode[] } + | { kind: "array"; items: ExprNode[] } + | { kind: "object"; entries: Array<{ key: string; value: ExprNode }> } + | { kind: "unary"; op: "not"; value: ExprNode } + | { + kind: "binary"; + op: "and" | "or" | "eq" | "neq"; + left: ExprNode; + right: ExprNode; + }; + +type TemplateNode = + | { kind: "text"; value: string } + | { kind: "output"; expr: ExprNode } + | { kind: "if"; condition: ExprNode; then: TemplateNode[]; else: TemplateNode[] } + | { kind: "each"; source: ExprNode; itemName: string; body: TemplateNode[] } + | { kind: "statement"; statement: StatementNode }; + +type StatementNode = + | { kind: "trim" } + | { kind: "set"; scope: "local" | "render" | "turn" | "session"; name: string; value: ExprNode } + | { kind: "useMacro"; name: string; args: Record }; +``` + +Exact AST shapes may change, but the architecture SHOULD preserve this separation: + +- expressions are values; +- statements are side effects; +- blocks control rendering; +- aliases lower into canonical nodes. + +## 8. Built-in Helpers + +The runtime MUST expose built-in helpers through a registry. + +Initial built-ins SHOULD include: + +- `contains(text, needle, flags?) -> boolean` +- `match(text, pattern, flags?) -> boolean` +- `lower(text) -> string` +- `upper(text) -> string` +- `trimText(text) -> string` +- `size(value) -> number` +- `empty(value) -> boolean` +- `coalesce(a, b, ...) -> unknown` +- `json(value) -> string` +- `outlet(key) -> string` +- `pickRandom(...values) -> unknown` +- `recentMessages(count) -> Array` +- `recentMessagesText(count) -> string` +- `recentMessagesByContextTokens(limit) -> Array` +- `recentMessagesByContextTokensText(limit) -> string` + +Rules: + +- helpers MUST be deterministic unless explicitly documented otherwise; +- `pickRandom` MUST use runtime RNG, not ambient `Math.random`; +- helper failures MUST surface as typed runtime errors; +- helpers MUST NOT mutate state directly. + +## 9. Built-in Statements + +Statements are side-effectful runtime actions and MUST be modeled separately from helpers. + +Initial built-ins SHOULD include: + +- `trim` + - whitespace/layout control node +- `set` + - set a variable in a target scope +- `unset` + - remove a variable from a target scope +- `capture` + - optional future node that renders nested template content into a variable + +Rules: + +- statements MUST be explicit; +- statements MUST NOT masquerade as pure expressions; +- statement effects MUST be visible in debug output. + +## 10. SillyTavern Compatibility Layer + +ST compatibility is required. + +The runtime MUST support ST aliases as a compatibility input layer without making them the canonical internal syntax. + +### 10.1 Design Rule + +ST syntax MUST parse into compatibility nodes or be lowered directly into canonical AST. + +Internal execution MUST operate on canonical runtime operations, not on raw ST syntax. + +### 10.2 Required Alias Categories + +The runtime MUST support three categories of ST aliases: + +1. value aliases +2. state aliases +3. formatting aliases + +### 10.3 Initial Required Aliases + +The following aliases are required in the first compatibility layer: + +- `{{outlet::default}}` +- `{{random::A::B}}` +- `{{trim}}` +- `{{setvar::name::value}}` + +Canonical lowering examples: + +- `{{outlet::default}}` + - lower to `outlet("default")` +- `{{random::A::B}}` + - lower to `pickRandom("A", "B")` +- `{{trim}}` + - lower to `trim` statement node +- `{{setvar::mood::angry}}` + - lower to `set(scope="render", name="mood", value="angry")` + +### 10.4 Future Alias Support + +Additional ST aliases MAY be added later through a whitelist-based compatibility registry. + +Unknown ST alias syntax MUST fail with a clear validation error. + +The runtime MUST NOT silently accept unknown alias forms. + +## 11. Macro System + +## 11.1 Goal + +Users MUST be able to define reusable macros in a dedicated UI and reuse them across templates. + +Macros are a first-class runtime feature, not a text-replace hack. + +## 11.2 Macro Kinds + +The system SHOULD support these macro kinds: + +- `value` + - returns a typed value from expressions +- `template` + - returns rendered template output + +The first release SHOULD NOT expose user-defined stateful statement macros. + +Built-in statement macros MAY exist internally, but user-defined macros SHOULD stay constrained. + +## 11.3 Macro Storage + +Macros SHOULD be stored as separate entities, not inline inside arbitrary templates. + +Illustrative shape: + +```ts +type MacroDefinition = { + id: string; + ownerId: string; + name: string; + kind: "value" | "template"; + description?: string; + engine: "native_v1"; + params: Array<{ + name: string; + required: boolean; + defaultExpr?: string; + }>; + body: string; + createdAt: string; + updatedAt: string; +}; +``` + +## 11.4 Macro Invocation + +Illustrative canonical forms: + +```txt +use("sceneHeader", { char: char, user: user }) +call("detectTone", { text: lastUserMessage }) +``` + +Template usage example: + +```txt +{{ use("sceneHeader", { char: char, user: user }) }} +``` + +Expression usage example: + +```txt +call("detectFlags", { text: lastUserMessage }) +``` + +## 11.5 Macro Semantics + +Rules: + +- macro invocation MUST create a fresh local scope; +- macro arguments MUST be bound by name; +- macro expansion MUST be debuggable; +- recursive macro calls MUST be limited; +- recursive cycles MUST be detected; +- macros MUST lower into AST or execute through the same runtime, not through raw string substitution. + +## 12. Validation and Error Model + +The runtime MUST provide explicit validation entry points: + +- `validateTemplate(source)` +- `validateExpr(source)` +- `validateMacro(definition)` + +The error model SHOULD distinguish: + +- parse errors +- unknown helper errors +- unknown alias errors +- unknown macro errors +- invalid path errors in strict mode +- runtime helper failures +- macro cycle errors +- scope access errors +- output limit errors + +The system MUST expose user-facing error messages and machine-readable error codes. + +## 13. Strictness + +The runtime MUST support strict and non-strict variable access. + +Suggested behavior: + +- strict mode + - missing path or missing variable is an error +- non-strict mode + - missing path resolves to `null` or empty string depending on output context + +The exact null/empty-string coercion rules MUST be consistent across the system and documented once in runtime docs. + +## 14. Whitespace and Layout + +Whitespace control MUST be explicit. + +`trim` is special because it affects surrounding rendered text, not just a returned value. + +Therefore: + +- `trim` MUST remain a statement/layout node; +- whitespace control MUST NOT be modeled only as a normal string helper; +- macro expansion and block rendering MUST preserve deterministic trim behavior. + +## 15. Determinism and Safety + +The runtime MUST be deterministic for the same input. + +The runtime MUST NOT allow: + +- arbitrary filesystem access; +- arbitrary network access; +- ambient mutable globals; +- arbitrary JS execution; +- unbounded recursion; +- unbounded AST growth; +- unbounded output growth. + +Required limits: + +- max macro expansion depth +- max AST node count after lowering +- max execution steps +- max output chars +- max object/array nesting + +## 16. Debuggability + +The runtime SHOULD expose debug artifacts for development and diagnostics: + +- parsed AST +- lowered AST after alias normalization +- expanded macro tree +- runtime variable scopes +- helper calls +- stop reason on limits or failures + +This is important because the system is more than a template engine. It is a small controlled interpreter. + +## 17. Integration Targets + +The new runtime is intended to replace LiquidJS as the primary engine in these areas: + +- instruction text rendering +- operation template rendering +- LLM prompt and system rendering +- deterministic guard evaluation +- world info text rendering +- greeting template rendering +- manual chat edit rendering + +Important architectural note: + +- deterministic guards SHOULD prefer `expr` over text templates whenever possible; +- building JSON by hand in text templates SHOULD NOT be the primary path for deterministic outputs. + +## 18. Rollout Strategy + +Recommended rollout: + +1. Implement shared runtime core + - context + - state + - helper registry + - expr parser and evaluator + +2. Implement minimal template runtime + - text + - output + - `if` + - `each` + - `trim` + - `set` + +3. Add ST compatibility aliases + - whitelist only + +4. Add macro registry + - storage + - validation + - invocation + - recursion guards + +5. Migrate product surfaces to native runtime + - start from deterministic guard/compute cases + - then prompt/instruction rendering + +## 19. Agreed Principles + +The implementation MUST follow these principles: + +- native syntax is canonical; +- ST syntax is compatibility-only; +- no LiquidJS compatibility target; +- text templating and deterministic evaluation are separate concerns; +- aliases normalize into canonical operations; +- user macros are data, not parser extensions; +- expressions are pure; +- statements are explicit; +- state scopes are controlled and bounded; +- debugability is a hard requirement, not optional polish. + +## 20. Open Decisions Requiring Agreement + +These items still need explicit agreement before implementation: + +1. Should `session` scope be in v1 runtime or reserved for v2 only? +2. Should `setvar::` default to `render` scope or `turn` scope? +3. Should user-defined macros be limited to `value` and `template` in v1, with no user-defined statement macros? +4. Should template invocation use only canonical `use("name", {...})` syntax in v1, with no extra shorthand? +5. What exact ST alias whitelist is required in the first compatibility pass beyond: + - `trim` + - `outlet::` + - `random::` + - `setvar::` + +## 21. Recommended Default Answers + +Unless product requirements change, this spec recommends: + +1. `session` scope is reserved but not enabled by default in v1. +2. `setvar::` defaults to `render` scope in v1. +3. User-defined macros are limited to `value` and `template` in v1. +4. Canonical macro invocation in v1 is only `use("name", {...})` and `call("name", {...})`. +5. ST alias support in v1 is whitelist-based and intentionally narrow. + diff --git a/package.json b/package.json index 16027676..7b08bef8 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,15 @@ "install:docs": "yarn --cwd docs install", "install:all": "run-s install:server install:web", "dev:server": "yarn --cwd server dev", - "dev:web": "yarn --cwd web dev", + "dev:web": "yarn --cwd web dev --host ", "dev": "run-p dev:server dev:web", + "test:server": "yarn --cwd server test", + "test:web": "yarn --cwd web test", + "test:e2e:smoke": "yarn --cwd server test:e2e:smoke", + "test:e2e:full": "yarn --cwd server test:e2e:full", + "test:e2e:stability": "yarn --cwd server test:e2e:stability", + "test:e2e:blackbox": "yarn --cwd server test:e2e:blackbox", + "test": "run-s test:server test:web test:e2e:smoke", "build:server": "yarn --cwd server build", "build:web": "yarn --cwd web build", "build": "run-s build:server build:web", diff --git a/server/drizzle/0028_ui_app_settings_bind_chat_completion.sql b/server/drizzle/0028_ui_app_settings_bind_chat_completion.sql new file mode 100644 index 00000000..91cba66d --- /dev/null +++ b/server/drizzle/0028_ui_app_settings_bind_chat_completion.sql @@ -0,0 +1,2 @@ +ALTER TABLE `ui_app_settings` +ADD COLUMN `bind_chat_completion_preset_to_connection` integer DEFAULT false NOT NULL; diff --git a/server/drizzle/0029_instruction_kinds.sql b/server/drizzle/0029_instruction_kinds.sql new file mode 100644 index 00000000..ad2fc772 --- /dev/null +++ b/server/drizzle/0029_instruction_kinds.sql @@ -0,0 +1,28 @@ +ALTER TABLE `instructions` ADD COLUMN `kind` text DEFAULT 'basic' NOT NULL; +--> statement-breakpoint + +ALTER TABLE `instructions` ADD COLUMN `st_base_json` text; +--> statement-breakpoint + +UPDATE `instructions` +SET `kind` = CASE + WHEN json_valid(`meta_json`) AND json_extract(`meta_json`, '$.tsInstruction.mode') = 'st_advanced' THEN 'st_base' + WHEN `kind` IS NULL OR trim(`kind`) = '' THEN 'basic' + ELSE `kind` +END; +--> statement-breakpoint + +UPDATE `instructions` +SET `st_base_json` = CASE + WHEN `st_base_json` IS NOT NULL THEN `st_base_json` + WHEN json_valid(`meta_json`) AND json_type(`meta_json`, '$.stBase') IS NOT NULL THEN json_extract(`meta_json`, '$.stBase') + WHEN json_valid(`meta_json`) AND json_type(`meta_json`, '$.tsInstruction.stAdvanced') IS NOT NULL THEN json_extract(`meta_json`, '$.tsInstruction.stAdvanced') + ELSE `st_base_json` +END; +--> statement-breakpoint + +UPDATE `instructions` +SET `meta_json` = CASE + WHEN json_valid(`meta_json`) AND json_type(`meta_json`, '$.tsInstruction') IS NOT NULL THEN json_remove(`meta_json`, '$.tsInstruction') + ELSE `meta_json` +END; diff --git a/server/drizzle/0030_app_backgrounds.sql b/server/drizzle/0030_app_backgrounds.sql new file mode 100644 index 00000000..0b03147d --- /dev/null +++ b/server/drizzle/0030_app_backgrounds.sql @@ -0,0 +1,10 @@ +CREATE TABLE `ui_app_backgrounds` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `file_name` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE `ui_app_settings` +ADD COLUMN `active_app_background_id` text; diff --git a/server/drizzle/0031_chat_knowledge_store.sql b/server/drizzle/0031_chat_knowledge_store.sql new file mode 100644 index 00000000..fc143f51 --- /dev/null +++ b/server/drizzle/0031_chat_knowledge_store.sql @@ -0,0 +1,150 @@ +CREATE TABLE `knowledge_collections` ( + `id` text PRIMARY KEY NOT NULL, + `owner_id` text DEFAULT 'global' NOT NULL, + `chat_id` text NOT NULL, + `branch_id` text, + `scope` text NOT NULL, + `name` text NOT NULL, + `kind` text, + `description` text, + `status` text DEFAULT 'active' NOT NULL, + `origin` text DEFAULT 'author' NOT NULL, + `layer` text DEFAULT 'baseline' NOT NULL, + `meta_json` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`branch_id`) REFERENCES `chat_branches`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `knowledge_collections_owner_chat_updated_at_idx` ON `knowledge_collections` (`owner_id`,`chat_id`,`updated_at`); +--> statement-breakpoint +CREATE UNIQUE INDEX `knowledge_collections_owner_chat_branch_name_uq` ON `knowledge_collections` (`owner_id`,`chat_id`,`branch_id`,`name`); +--> statement-breakpoint +CREATE TABLE `knowledge_records` ( + `id` text PRIMARY KEY NOT NULL, + `owner_id` text DEFAULT 'global' NOT NULL, + `chat_id` text NOT NULL, + `branch_id` text, + `collection_id` text NOT NULL, + `record_type` text NOT NULL, + `key` text NOT NULL, + `title` text NOT NULL, + `aliases_json` text DEFAULT '[]' NOT NULL, + `tags_json` text DEFAULT '[]' NOT NULL, + `summary` text, + `content_json` text DEFAULT 'null' NOT NULL, + `search_text` text DEFAULT '' NOT NULL, + `access_mode` text DEFAULT 'public' NOT NULL, + `origin` text DEFAULT 'author' NOT NULL, + `layer` text DEFAULT 'baseline' NOT NULL, + `derived_from_record_id` text, + `source_message_id` text, + `source_operation_id` text, + `status` text DEFAULT 'active' NOT NULL, + `gate_policy_json` text, + `meta_json` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`branch_id`) REFERENCES `chat_branches`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`collection_id`) REFERENCES `knowledge_collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `knowledge_records_owner_chat_branch_updated_at_idx` ON `knowledge_records` (`owner_id`,`chat_id`,`branch_id`,`updated_at`); +--> statement-breakpoint +CREATE INDEX `knowledge_records_collection_record_type_idx` ON `knowledge_records` (`collection_id`,`record_type`); +--> statement-breakpoint +CREATE UNIQUE INDEX `knowledge_records_scope_collection_key_uq` ON `knowledge_records` (`chat_id`,`branch_id`,`collection_id`,`key`); +--> statement-breakpoint +CREATE UNIQUE INDEX `knowledge_records_active_scope_collection_key_uq` ON `knowledge_records` (`chat_id`,`branch_id`,`collection_id`,`key`) WHERE `status` = 'active'; +--> statement-breakpoint +CREATE TABLE `knowledge_record_links` ( + `id` text PRIMARY KEY NOT NULL, + `owner_id` text DEFAULT 'global' NOT NULL, + `chat_id` text NOT NULL, + `branch_id` text, + `from_record_id` text NOT NULL, + `relation_type` text NOT NULL, + `to_record_id` text NOT NULL, + `meta_json` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`branch_id`) REFERENCES `chat_branches`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`from_record_id`) REFERENCES `knowledge_records`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`to_record_id`) REFERENCES `knowledge_records`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `knowledge_record_links_owner_chat_branch_relation_idx` ON `knowledge_record_links` (`owner_id`,`chat_id`,`branch_id`,`relation_type`); +--> statement-breakpoint +CREATE UNIQUE INDEX `knowledge_record_links_from_relation_to_uq` ON `knowledge_record_links` (`from_record_id`,`relation_type`,`to_record_id`); +--> statement-breakpoint +CREATE TABLE `knowledge_record_access_state` ( + `id` text PRIMARY KEY NOT NULL, + `owner_id` text DEFAULT 'global' NOT NULL, + `chat_id` text NOT NULL, + `branch_id` text, + `record_id` text NOT NULL, + `discover_state` text DEFAULT 'hidden' NOT NULL, + `read_state` text DEFAULT 'blocked' NOT NULL, + `prompt_state` text DEFAULT 'blocked' NOT NULL, + `reveal_state` text DEFAULT 'hidden' NOT NULL, + `revealed_at` integer, + `revealed_by` text, + `reveal_reason` text, + `flags_json` text DEFAULT '{}' NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`branch_id`) REFERENCES `chat_branches`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`record_id`) REFERENCES `knowledge_records`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `knowledge_record_access_state_chat_branch_reveal_idx` ON `knowledge_record_access_state` (`chat_id`,`branch_id`,`reveal_state`,`updated_at`); +--> statement-breakpoint +CREATE UNIQUE INDEX `knowledge_record_access_state_scope_record_uq` ON `knowledge_record_access_state` (`chat_id`,`branch_id`,`record_id`); +--> statement-breakpoint +CREATE VIRTUAL TABLE `knowledge_records_fts` USING fts5( + `record_id` UNINDEXED, + `title`, + `aliases`, + `tags`, + `summary`, + `search_text` +); +--> statement-breakpoint +CREATE TRIGGER `knowledge_records_ai_fts` +AFTER INSERT ON `knowledge_records` +WHEN NEW.`status` = 'active' AND NEW.`access_mode` IN ('public', 'discoverable') +BEGIN + INSERT INTO `knowledge_records_fts` (`record_id`, `title`, `aliases`, `tags`, `summary`, `search_text`) + VALUES ( + NEW.`id`, + NEW.`title`, + COALESCE(NEW.`aliases_json`, '[]'), + COALESCE(NEW.`tags_json`, '[]'), + COALESCE(NEW.`summary`, ''), + COALESCE(NEW.`search_text`, '') + ); +END; +--> statement-breakpoint +CREATE TRIGGER `knowledge_records_ad_fts` +AFTER DELETE ON `knowledge_records` +BEGIN + DELETE FROM `knowledge_records_fts` WHERE `record_id` = OLD.`id`; +END; +--> statement-breakpoint +CREATE TRIGGER `knowledge_records_au_fts` +AFTER UPDATE ON `knowledge_records` +BEGIN + DELETE FROM `knowledge_records_fts` WHERE `record_id` = OLD.`id`; + INSERT INTO `knowledge_records_fts` (`record_id`, `title`, `aliases`, `tags`, `summary`, `search_text`) + SELECT + NEW.`id`, + NEW.`title`, + COALESCE(NEW.`aliases_json`, '[]'), + COALESCE(NEW.`tags_json`, '[]'), + COALESCE(NEW.`summary`, ''), + COALESCE(NEW.`search_text`, '') + WHERE NEW.`status` = 'active' AND NEW.`access_mode` IN ('public', 'discoverable'); +END; diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index e49a6a36..3cde38e9 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -169,6 +169,34 @@ "when": 1772790000000, "tag": "0027_generation_runtime_control", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1773012000000, + "tag": "0028_ui_app_settings_bind_chat_completion", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1773100000000, + "tag": "0029_instruction_kinds", + "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1773200000000, + "tag": "0030_app_backgrounds", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1774060000000, + "tag": "0031_chat_knowledge_store", + "breakpoints": true } ] } diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 917cbe7b..eaedd4a1 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -4,7 +4,7 @@ import importPlugin from "eslint-plugin-import"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist", "data", "public", "src/legacy/**"] }, + { ignores: ["dist", "data", "public"] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/server/package.json b/server/package.json index 1a027fd6..a20c1494 100644 --- a/server/package.json +++ b/server/package.json @@ -32,12 +32,12 @@ "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "build:watch": "tsc -p tsconfig.json -w", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e:smoke": "vitest run -c vitest.e2e.config.ts src/e2e/smoke/backend.smoke.e2e.spec.ts src/e2e/smoke/blackbox.smoke.e2e.spec.ts", - "test:e2e:full": "vitest run -c vitest.e2e.config.ts src/e2e/full-matrix.e2e.spec.ts src/e2e/full-matrix-extra.e2e.spec.ts", - "test:e2e:stability": "vitest run -c vitest.e2e.config.ts src/e2e/stability/smoke-repeat.e2e.spec.ts", - "test:e2e:blackbox": "vitest run -c vitest.e2e.config.ts src/e2e/smoke/blackbox.smoke.e2e.spec.ts", + "test": "node --no-warnings ./node_modules/vitest/vitest.mjs run --silent=passed-only --reporter=dot", + "test:watch": "node --no-warnings ./node_modules/vitest/vitest.mjs", + "test:e2e:smoke": "node --no-warnings ./node_modules/vitest/vitest.mjs run --silent=passed-only --reporter=dot -c vitest.e2e.config.ts src/e2e/smoke/backend.smoke.e2e.spec.ts", + "test:e2e:full": "node --no-warnings ./node_modules/vitest/vitest.mjs run --silent=passed-only --reporter=dot -c vitest.e2e.config.ts src/e2e/full-matrix.e2e.spec.ts src/e2e/full-matrix-extra.e2e.spec.ts", + "test:e2e:stability": "node --no-warnings ./node_modules/vitest/vitest.mjs run --silent=passed-only --reporter=dot -c vitest.e2e.config.ts src/e2e/stability/smoke-repeat.e2e.spec.ts", + "test:e2e:blackbox": "node --no-warnings ./node_modules/vitest/vitest.mjs run --silent=passed-only --reporter=dot -c vitest.e2e.config.ts src/e2e/smoke/blackbox.smoke.e2e.spec.ts", "seed:chat:large": "ts-node -r tsconfig-paths/register src/scripts/seed-large-chat.ts", "seed:chat:extreme": "ts-node -r tsconfig-paths/register src/scripts/seed-large-chat.ts --extreme", "lint": "eslint . --config eslint.config.mjs", @@ -73,6 +73,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "globals": "^17.0.0", + "peggy": "^5.1.0", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsc-alias": "^1.8.16", diff --git a/server/src/api/_routes_.ts b/server/src/api/_routes_.ts index 74dbfb59..6ac243f7 100644 --- a/server/src/api/_routes_.ts +++ b/server/src/api/_routes_.ts @@ -1,5 +1,8 @@ +import appBackgroundsRoutes from "./app-backgrounds.core.api"; import appSettingsRoutes from "./app-settings.api"; +import bundlesCoreRoutes from "./bundles.core.api"; import chatEntriesRoutes from "./chat-entries.api"; +import chatKnowledgeCoreRoutes from "./chat-knowledge.core.api"; import chatCoreRoutes from "./chats.core.api"; import entityProfilesCoreRoutes from "./entity-profiles.core.api"; import entityProfilesImportRoutes from "./entity-profiles.import.api"; @@ -22,9 +25,11 @@ import userPersonsCoreRoutes from "./user-persons.core.api"; import worldInfoCoreRoutes from "./world-info.core.api"; export const routes = [ + bundlesCoreRoutes, entityProfilesCoreRoutes, entityProfilesImportRoutes, chatCoreRoutes, + chatKnowledgeCoreRoutes, chatEntriesRoutes, generationsCoreRoutes, operationProfilesCoreRoutes, @@ -35,6 +40,7 @@ export const routes = [ modelsRoutes, settingsRoutes, appSettingsRoutes, + appBackgroundsRoutes, generateRoutes, sidebarsRoutes, ragRoutes, diff --git a/server/src/api/app-backgrounds.core.api.test.ts b/server/src/api/app-backgrounds.core.api.test.ts new file mode 100644 index 00000000..954416c3 --- /dev/null +++ b/server/src/api/app-backgrounds.core.api.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; + +import { appBackgroundActiveBodySchema } from "./app-backgrounds.core.api"; + +describe("app background route schemas", () => { + test("accepts active background id patch", () => { + const parsed = appBackgroundActiveBodySchema.safeParse({ + activeBackgroundId: "builtin:mist", + }); + + expect(parsed.success).toBe(true); + }); + + test("accepts clearing active background id", () => { + const parsed = appBackgroundActiveBodySchema.safeParse({ + activeBackgroundId: null, + }); + + expect(parsed.success).toBe(true); + }); + + test("rejects unknown patch keys", () => { + const parsed = appBackgroundActiveBodySchema.safeParse({ + activeBackgroundId: "builtin:mist", + injected: true, + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/server/src/api/app-backgrounds.core.api.ts b/server/src/api/app-backgrounds.core.api.ts new file mode 100644 index 00000000..699772c1 --- /dev/null +++ b/server/src/api/app-backgrounds.core.api.ts @@ -0,0 +1,112 @@ +import path from "node:path"; + +import express, { type Request } from "express"; +import multer from "multer"; +import { z } from "zod"; + +import { asyncHandler } from "@core/middleware/async-handler"; +import { HttpError } from "@core/middleware/error-handler"; +import { validate } from "@core/middleware/validate"; + +import { + deleteAppBackground, + getAppBackgroundCatalog, + importAppBackground, + setAppBackgroundActive, +} from "../services/app-backgrounds/app-backgrounds-repository"; + +const router = express.Router(); + +const allowedMimesByExtension: Record = { + ".png": ["image/png"], + ".jpg": ["image/jpeg"], + ".jpeg": ["image/jpeg"], + ".gif": ["image/gif"], + ".webp": ["image/webp"], + ".svg": ["image/svg+xml"], +}; + +function normalizeMimeType(mimeType: string): string { + return String(mimeType ?? "") + .toLowerCase() + .split(";")[0] + .trim(); +} + +function isAllowedImageType(params: { originalName: string; mimeType: string }): boolean { + const extension = path.extname(params.originalName).toLowerCase(); + const allowedMimes = allowedMimesByExtension[extension]; + if (!allowedMimes) return false; + return allowedMimes.includes(normalizeMimeType(params.mimeType)); +} + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, + }, + fileFilter: (_req, file, cb) => { + if (isAllowedImageType({ originalName: file.originalname, mimeType: file.mimetype })) { + return cb(null, true); + } + cb(new Error("Unsupported file type")); + }, +}); + +const idParamsSchema = z + .object({ + id: z.string().min(1), + }) + .strict(); + +export const appBackgroundActiveBodySchema = z + .object({ + activeBackgroundId: z.string().min(1).nullable(), + }) + .strict(); + +router.get( + "/app-backgrounds", + asyncHandler(async () => { + const data = await getAppBackgroundCatalog(); + return { data }; + }) +); + +router.post( + "/app-backgrounds/import", + upload.single("image"), + asyncHandler(async (req: Request) => { + if (!req.file) { + throw new HttpError(400, "Image is required", "VALIDATION_ERROR"); + } + + const data = await importAppBackground({ + fileBuffer: req.file.buffer, + originalName: req.file.originalname, + }); + return { data }; + }) +); + +router.put( + "/app-backgrounds/active", + validate({ body: appBackgroundActiveBodySchema }), + asyncHandler(async (req: Request) => { + const body = appBackgroundActiveBodySchema.parse(req.body); + const data = await setAppBackgroundActive(body); + return { data }; + }) +); + +router.delete( + "/app-backgrounds/:id", + validate({ params: idParamsSchema }), + asyncHandler(async (req: Request) => { + const params = idParamsSchema.parse(req.params); + const data = await deleteAppBackground({ id: params.id }); + return { data }; + }) +); + +export default router; diff --git a/server/src/api/bundles.core.api.ts b/server/src/api/bundles.core.api.ts new file mode 100644 index 00000000..b7ec4134 --- /dev/null +++ b/server/src/api/bundles.core.api.ts @@ -0,0 +1,86 @@ +import express, { type Request } from "express"; +import multer from "multer"; +import { z } from "zod"; + +import { asyncHandler } from "@core/middleware/async-handler"; +import { HttpError } from "@core/middleware/error-handler"; +import { validate } from "@core/middleware/validate"; + +import { ownerIdSchema } from "../chat-core/schemas"; +import { getRequestOwnerId } from "../core/request-context/request-context"; +import { exportBundleSelection } from "../services/bundles/export-bundle-selection"; +import { importBundleFile } from "../services/bundles/import-bundle-file"; + +const router = express.Router(); +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } }); + +const resourceKindSchema = z.enum([ + "instruction", + "operation_block", + "operation_profile", + "world_info_book", + "entity_profile", + "ui_theme_preset", + "sampler_preset", +]); + +const exportBodySchema = z + .object({ + ownerId: ownerIdSchema.optional(), + source: z.object({ + kind: resourceKindSchema, + id: z.string().min(1), + }), + selections: z + .array( + z.object({ + kind: resourceKindSchema, + id: z.string().min(1), + }) + ) + .min(1), + format: z.enum(["json", "archive", "auto"]).optional().default("auto"), + }) + .strict(); + +router.post( + "/bundles/export", + validate({ body: exportBodySchema }), + asyncHandler(async (req: Request) => { + const body = exportBodySchema.parse(req.body); + const exported = await exportBundleSelection({ + ownerId: getRequestOwnerId(req, body.ownerId), + source: body.source, + selections: body.selections, + format: body.format, + }); + + return { + data: exported.buffer, + headers: { + "Content-Type": exported.contentType, + "Content-Disposition": `attachment; filename="${exported.fileName}"`, + }, + raw: true, + }; + }) +); + +router.post( + "/bundles/import", + upload.single("file"), + asyncHandler(async (req: Request) => { + if (!req.file) { + throw new HttpError(400, "file is required", "VALIDATION_ERROR"); + } + return { + data: await importBundleFile({ + ownerId: getRequestOwnerId(req), + fileName: req.file.originalname, + buffer: req.file.buffer, + }), + }; + }) +); + +export default router; diff --git a/server/src/api/chat-entries.api.ts b/server/src/api/chat-entries.api.ts index 2b85dab8..de1a9527 100644 --- a/server/src/api/chat-entries.api.ts +++ b/server/src/api/chat-entries.api.ts @@ -6,8 +6,6 @@ import { HttpError } from "@core/middleware/error-handler"; import { validate } from "@core/middleware/validate"; import { initSse, type SseWriter } from "@core/sse/sse"; -import type { BatchUpdateEntryPartsBody as ChatRuntimeBatchUpdateEntryPartsBody } from "../application/chat-runtime/chat-entry-helpers"; -import type { ChatGenerationSession } from "../application/chat-runtime/contracts"; import { batchUpdateEntryParts } from "../application/chat-runtime/use-cases/batch-update-entry-parts"; import { continueGeneration } from "../application/chat-runtime/use-cases/continue-generation"; import { createEntryAndStartGeneration } from "../application/chat-runtime/use-cases/create-entry-and-start-generation"; @@ -31,6 +29,8 @@ import { } from "../services/chat-entry-parts/entries-repository"; import { softDeletePart } from "../services/chat-entry-parts/parts-repository"; +import type { BatchUpdateEntryPartsBody as ChatRuntimeBatchUpdateEntryPartsBody } from "../application/chat-runtime/chat-entry-helpers"; +import type { ChatGenerationSession } from "../application/chat-runtime/contracts"; import type { RunEvent } from "../services/chat-generation-v3/contracts"; const router = express.Router(); @@ -402,13 +402,34 @@ const manualEditBodySchema = z.object({ }); const batchUpdateEntryPartSchema = z.object({ - partId: z.string().min(1), - deleted: z.boolean(), + partId: z.string().min(1).optional(), + clientPartId: z.string().min(1).optional(), + deleted: z.boolean().optional().default(false), + channel: z.enum(["main", "reasoning", "aux", "trace"]).optional(), + payloadFormat: z.enum(["text", "markdown", "json"]).optional(), + label: z.string().min(1).optional(), visibility: z.object({ ui: z.enum(["always", "never"]), prompt: z.boolean(), }), payload: z.unknown(), +}).superRefine((value, ctx) => { + const hasPartId = typeof value.partId === "string"; + const hasClientPartId = typeof value.clientPartId === "string"; + if (hasPartId === hasClientPartId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Either partId or clientPartId is required", + path: ["partId"], + }); + } + if (hasClientPartId && value.deleted) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "New parts cannot be deleted", + path: ["deleted"], + }); + } }); const batchUpdateEntryPartsBodySchema = z.object({ diff --git a/server/src/api/chat-entries.meta.test.ts b/server/src/api/chat-entries.meta.test.ts index 7bac4af9..89c01b17 100644 --- a/server/src/api/chat-entries.meta.test.ts +++ b/server/src/api/chat-entries.meta.test.ts @@ -887,7 +887,6 @@ describe("batch update entry parts helpers", () => { const plan = buildBatchUpdatePartPlan({ variantParts: parts, - nowMs: 100, body: { variantId: "variant-1", mainPartId: "part-alt", @@ -923,14 +922,14 @@ describe("batch update entry parts helpers", () => { expect.objectContaining({ partId: "part-alt", channel: "main", - order: 0, + order: 10, replacesPartId: null, payload: "alt edited", }), expect.objectContaining({ partId: "part-main", channel: "aux", - order: 10, + order: 0, payload: "main edited", }), expect.objectContaining({ @@ -944,6 +943,115 @@ describe("batch update entry parts helpers", () => { ); }); + test("buildBatchUpdatePartPlan preserves explicit order when main changes", () => { + const parts: Part[] = [ + makePart({ partId: "first", channel: "main", order: 0, payload: "first" }), + makePart({ partId: "second", channel: "aux", order: 10, payload: "second" }), + makePart({ partId: "third", channel: "aux", order: 20, payload: "third" }), + ]; + + const plan = buildBatchUpdatePartPlan({ + variantParts: parts, + body: { + variantId: "variant-1", + mainPartId: "second", + orderedPartIds: ["first", "second", "third"], + parts: parts.map((part) => ({ + partId: part.partId, + deleted: false, + visibility: { ui: "always", prompt: true }, + payload: part.payload, + })), + }, + }); + + expect(plan.patches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ partId: "first", channel: "aux", order: 0 }), + expect.objectContaining({ partId: "second", channel: "main", order: 10 }), + expect.objectContaining({ partId: "third", channel: "aux", order: 20 }), + ]) + ); + }); + + test("buildBatchUpdatePartPlan separates hard deletes from soft-delete patches", () => { + const parts: Part[] = [ + makePart({ partId: "keep", channel: "main", order: 0, payload: "keep" }), + makePart({ partId: "remove", channel: "aux", order: 10, payload: "remove" }), + ]; + + const plan = buildBatchUpdatePartPlan({ + variantParts: parts, + body: { + variantId: "variant-1", + mainPartId: "keep", + orderedPartIds: ["keep"], + parts: [ + { + partId: "keep", + deleted: false, + visibility: { ui: "always", prompt: true }, + payload: "keep", + }, + { + partId: "remove", + deleted: true, + visibility: { ui: "always", prompt: true }, + payload: "remove", + }, + ], + }, + }); + + expect(plan.deletedPartIds).toEqual(["remove"]); + expect(plan.patches.map((item) => item.partId)).toEqual(["keep"]); + expect(plan.patches).not.toContainEqual(expect.objectContaining({ partId: "remove", softDeleted: true })); + }); + + test("buildBatchUpdatePartPlan creates new user blocks", () => { + const parts: Part[] = [ + makePart({ partId: "existing-main", channel: "main", order: 0, payload: "main" }), + ]; + + const plan = buildBatchUpdatePartPlan({ + variantParts: parts, + body: { + variantId: "variant-1", + mainPartId: "new-block", + orderedPartIds: ["existing-main", "new-block"], + parts: [ + { + partId: "existing-main", + deleted: false, + visibility: { ui: "always", prompt: true }, + payload: "main", + }, + { + clientPartId: "new-block", + channel: "aux", + payloadFormat: "markdown", + visibility: { ui: "always", prompt: true }, + payload: "new text", + }, + ], + }, + }); + + expect(plan.creates).toEqual([ + expect.objectContaining({ + clientPartId: "new-block", + channel: "main", + order: 10, + payload: "new text", + payloadFormat: "markdown", + source: "user", + }), + ]); + expect(plan.patches).toEqual([ + expect.objectContaining({ partId: "existing-main", channel: "aux", order: 0 }), + ]); + }); + test("buildBatchUpdatePartPlan rejects non-text main", () => { const parts: Part[] = [ makePart({ diff --git a/server/src/api/chat-knowledge.core.api.test.ts b/server/src/api/chat-knowledge.core.api.test.ts new file mode 100644 index 00000000..f37a72c3 --- /dev/null +++ b/server/src/api/chat-knowledge.core.api.test.ts @@ -0,0 +1,217 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { bootstrapApp, createApp } from "../app"; +import { initDb, resetDbForTests } from "../db/client"; +import { chatBranches, chats, entityProfiles } from "../db/schema"; + +import type { Server } from "node:http"; + +async function startServer() { + resetDbForTests(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-knowledge-api-")); + const dbPath = path.join(tempDir, "db.sqlite"); + await bootstrapApp({ dbPath }); + const app = createApp(); + const server = await new Promise((resolve) => { + const started = app.listen(0, () => resolve(started)); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + tempDir, + server, + }; +} + +async function stopServer(server: Server, tempDir: string) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); +} + +async function seedChatScope(params: { chatId: string; branchId: string }) { + const db = await initDb(); + const now = new Date(); + const entityProfileId = `entity:${params.chatId}`; + + await db.insert(entityProfiles).values({ + id: entityProfileId, + ownerId: "global", + name: `Entity ${params.chatId}`, + kind: "CharSpec", + specJson: "{}", + metaJson: null, + isFavorite: false, + createdAt: now, + updatedAt: now, + avatarAssetId: null, + }); + + await db.insert(chats).values({ + id: params.chatId, + ownerId: "global", + entityProfileId, + title: `Chat ${params.chatId}`, + activeBranchId: params.branchId, + instructionId: null, + status: "active", + createdAt: now, + updatedAt: now, + lastMessageAt: null, + lastMessagePreview: null, + version: 0, + metaJson: null, + originChatId: null, + originBranchId: null, + originMessageId: null, + }); + + await db.insert(chatBranches).values({ + id: params.branchId, + ownerId: "global", + chatId: params.chatId, + title: params.branchId, + createdAt: now, + updatedAt: now, + parentBranchId: null, + forkedFromMessageId: null, + forkedFromVariantId: null, + metaJson: null, + currentTurn: 0, + }); +} + +describe("chat knowledge api", () => { + afterEach(() => { + resetDbForTests(); + }); + + test("creates records, searches previews, and reveals through HTTP routes", async () => { + const started = await startServer(); + try { + await seedChatScope({ chatId: "chat-api", branchId: "branch-api" }); + + const collectionResponse = await fetch(`${started.baseUrl}/api/chat-knowledge/collections`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + chatId: "chat-api", + branchId: null, + scope: "chat", + name: "API Pack", + kind: "scenario", + origin: "author", + layer: "baseline", + }), + }); + expect(collectionResponse.status).toBe(200); + const collectionBody = (await collectionResponse.json()) as { data: { id: string } }; + + const recordResponse = await fetch(`${started.baseUrl}/api/chat-knowledge/records`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + chatId: "chat-api", + branchId: null, + collectionId: collectionBody.data.id, + recordType: "fact", + key: "sealed_room", + title: "Sealed Room", + aliases: [], + tags: ["secret"], + summary: "A hidden room under the manor", + content: { text: "The sealed room contains the evidence." }, + accessMode: "discoverable", + origin: "author", + layer: "baseline", + gatePolicy: { + read: { + all: [{ type: "manual_unlock" }], + }, + }, + }), + }); + expect(recordResponse.status).toBe(200); + const recordBody = (await recordResponse.json()) as { data: { id: string } }; + + const searchBefore = await fetch(`${started.baseUrl}/api/chat-knowledge/records/search`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + chatId: "chat-api", + branchId: "branch-api", + request: { + textQuery: "sealed room", + limit: 5, + }, + }), + }); + expect(searchBefore.status).toBe(200); + const searchBeforeBody = (await searchBefore.json()) as { + data: { hits: Array<{ visibility: string; record: unknown }> }; + }; + expect(searchBeforeBody.data.hits[0]?.visibility).toBe("preview"); + expect(searchBeforeBody.data.hits[0]?.record).toBeNull(); + + const revealResponse = await fetch(`${started.baseUrl}/api/chat-knowledge/reveal`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + chatId: "chat-api", + branchId: "branch-api", + request: { + recordIds: [recordBody.data.id], + revealedBy: "system", + reason: "manual unlock", + context: { + manualUnlock: true, + }, + }, + }), + }); + expect(revealResponse.status).toBe(200); + + const searchAfter = await fetch(`${started.baseUrl}/api/chat-knowledge/records/search`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + chatId: "chat-api", + branchId: "branch-api", + request: { + textQuery: "sealed room", + limit: 5, + }, + }), + }); + const searchAfterBody = (await searchAfter.json()) as { + data: { hits: Array<{ visibility: string; record: { key: string } | null }> }; + }; + expect(searchAfter.status).toBe(200); + expect(searchAfterBody.data.hits[0]?.visibility).toBe("full"); + expect(searchAfterBody.data.hits[0]?.record?.key).toBe("sealed_room"); + } finally { + await stopServer(started.server, started.tempDir); + } + }); +}); diff --git a/server/src/api/chat-knowledge.core.api.ts b/server/src/api/chat-knowledge.core.api.ts new file mode 100644 index 00000000..5d96347d --- /dev/null +++ b/server/src/api/chat-knowledge.core.api.ts @@ -0,0 +1,379 @@ +import { + knowledgeAccessModes, + knowledgeExportModes, + knowledgeLayers, + knowledgeOrigins, + knowledgeRecordStatuses, + knowledgeScopes, + type KnowledgeCollectionExportPayload, + type KnowledgeGatePolicy, +} from "@shared/types/chat-knowledge"; +import express, { type Request } from "express"; +import { z } from "zod"; + +import { asyncHandler } from "@core/middleware/async-handler"; +import { HttpError } from "@core/middleware/error-handler"; +import { validate } from "@core/middleware/validate"; + +import { idSchema, ownerIdSchema } from "../chat-core/schemas"; +import { getRequestOwnerId } from "../core/request-context/request-context"; +import { + createKnowledgeCollection, + exportKnowledgeCollection, + getKnowledgeCollectionById, + importKnowledgeCollection, + listKnowledgeCollections, +} from "../services/chat-knowledge/knowledge-collections-repository"; +import { + createKnowledgeRecordLinksBulk, + listKnowledgeRecordLinks, +} from "../services/chat-knowledge/knowledge-links-repository"; +import { + getKnowledgeRecordById, + listKnowledgeRecords, + upsertKnowledgeRecord, +} from "../services/chat-knowledge/knowledge-records-repository"; +import { revealKnowledgeRecords } from "../services/chat-knowledge/knowledge-reveal-service"; +import { searchKnowledgeRecords } from "../services/chat-knowledge/knowledge-search-service"; + +const router = express.Router(); + +const branchIdSchema = idSchema.nullable().optional(); +const knowledgeScopeSchema = z.enum(knowledgeScopes); +const knowledgeOriginSchema = z.enum(knowledgeOrigins); +const knowledgeLayerSchema = z.enum(knowledgeLayers); +const knowledgeAccessModeSchema = z.enum(knowledgeAccessModes); +const knowledgeStatusSchema = z.enum(knowledgeRecordStatuses); +const knowledgeExportModeSchema = z.enum(knowledgeExportModes); + +const collectionCreateSchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + scope: knowledgeScopeSchema, + name: z.string().min(1), + kind: z.string().min(1).nullable().optional(), + description: z.string().nullable().optional(), + status: z.enum(["active", "archived", "deleted"]).optional(), + origin: knowledgeOriginSchema, + layer: knowledgeLayerSchema, + meta: z.unknown().optional(), +}); + +const collectionListQuerySchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, +}); + +const collectionExportQuerySchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + mode: knowledgeExportModeSchema, +}); + +const collectionImportSchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + payload: z.unknown(), +}); + +const recordUpsertSchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + collectionId: idSchema, + recordType: z.string().min(1), + key: z.string().min(1), + title: z.string().min(1), + aliases: z.array(z.string()).default([]), + tags: z.array(z.string()).default([]), + summary: z.string().nullable().optional(), + content: z.unknown(), + accessMode: knowledgeAccessModeSchema, + origin: knowledgeOriginSchema, + layer: knowledgeLayerSchema, + derivedFromRecordId: idSchema.nullable().optional(), + sourceMessageId: idSchema.nullable().optional(), + sourceOperationId: z.string().nullable().optional(), + status: knowledgeStatusSchema.optional(), + gatePolicy: z.unknown().nullable().optional(), + meta: z.unknown().optional(), +}); + +const recordListQuerySchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + collectionId: idSchema.optional(), + includeArchived: z.coerce.boolean().optional(), +}); + +const searchSchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + request: z.object({ + textQuery: z.string().optional(), + keys: z.array(z.string()).optional(), + titles: z.array(z.string()).optional(), + aliases: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + recordTypes: z.array(z.string()).optional(), + collectionIds: z.array(idSchema).optional(), + includeHiddenCandidates: z.boolean().optional(), + limit: z.number().int().min(1).max(200).optional(), + minScore: z.number().finite().optional(), + minimumShouldMatch: z.number().int().min(0).optional(), + context: z + .object({ + flags: z.record(z.string(), z.unknown()).optional(), + counters: z.record(z.string(), z.number()).optional(), + manualUnlock: z.boolean().optional(), + }) + .optional(), + }), +}); + +const linksCreateSchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + items: z + .array( + z.object({ + fromRecordId: idSchema, + relationType: z.string().min(1), + toRecordId: idSchema, + meta: z.unknown().optional(), + }) + ) + .min(1) + .max(500), +}); + +const linksListQuerySchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, +}); + +const revealSchema = z.object({ + ownerId: ownerIdSchema.optional(), + chatId: idSchema, + branchId: branchIdSchema, + request: z.object({ + recordIds: z.array(idSchema).optional(), + recordKeys: z.array(z.string()).optional(), + reason: z.string().optional(), + revealedBy: z.enum(["system", "user", "llm", "import"]).optional(), + context: z + .object({ + flags: z.record(z.string(), z.unknown()).optional(), + counters: z.record(z.string(), z.number()).optional(), + manualUnlock: z.boolean().optional(), + }) + .optional(), + }), +}); + +router.get( + "/chat-knowledge/collections", + validate({ query: collectionListQuerySchema }), + asyncHandler(async (req: Request) => { + const query = collectionListQuerySchema.parse(req.query); + return { + data: await listKnowledgeCollections({ + ...query, + branchId: query.branchId ?? null, + }), + }; + }) +); + +router.post( + "/chat-knowledge/collections", + validate({ body: collectionCreateSchema }), + asyncHandler(async (req: Request) => { + const body = collectionCreateSchema.parse(req.body); + return { + data: await createKnowledgeCollection({ + ...body, + ownerId: getRequestOwnerId(req, body.ownerId), + branchId: body.branchId ?? null, + }), + }; + }) +); + +router.get( + "/chat-knowledge/collections/:id", + validate({ params: z.object({ id: idSchema }) }), + asyncHandler(async (req: Request) => { + const collection = await getKnowledgeCollectionById(String(req.params.id)); + if (!collection) throw new HttpError(404, "Knowledge collection not found", "NOT_FOUND"); + return { data: collection }; + }) +); + +router.get( + "/chat-knowledge/collections/:id/export", + validate({ + params: z.object({ id: idSchema }), + query: collectionExportQuerySchema, + }), + asyncHandler(async (req: Request) => { + const query = collectionExportQuerySchema.parse(req.query); + return { + data: await exportKnowledgeCollection({ + ownerId: getRequestOwnerId(req, query.ownerId), + chatId: query.chatId, + branchId: query.branchId ?? null, + collectionId: String(req.params.id), + mode: query.mode, + }), + }; + }) +); + +router.post( + "/chat-knowledge/collections/import", + validate({ body: collectionImportSchema }), + asyncHandler(async (req: Request) => { + const body = collectionImportSchema.parse(req.body); + return { + data: await importKnowledgeCollection({ + ownerId: getRequestOwnerId(req, body.ownerId), + chatId: body.chatId, + branchId: body.branchId ?? null, + payload: body.payload as KnowledgeCollectionExportPayload, + }), + }; + }) +); + +router.get( + "/chat-knowledge/records", + validate({ query: recordListQuerySchema }), + asyncHandler(async (req: Request) => { + const query = recordListQuerySchema.parse(req.query); + return { + data: await listKnowledgeRecords({ + ownerId: getRequestOwnerId(req, query.ownerId), + chatId: query.chatId, + branchId: query.branchId ?? null, + collectionId: query.collectionId, + includeArchived: query.includeArchived, + }), + }; + }) +); + +router.post( + "/chat-knowledge/records", + validate({ body: recordUpsertSchema }), + asyncHandler(async (req: Request) => { + const body = recordUpsertSchema.parse(req.body); + return { + data: await upsertKnowledgeRecord({ + ownerId: getRequestOwnerId(req, body.ownerId), + chatId: body.chatId, + branchId: body.branchId ?? null, + collectionId: body.collectionId, + recordType: body.recordType, + key: body.key, + title: body.title, + aliases: body.aliases, + tags: body.tags, + summary: body.summary, + content: body.content, + accessMode: body.accessMode, + origin: body.origin, + layer: body.layer, + derivedFromRecordId: body.derivedFromRecordId, + sourceMessageId: body.sourceMessageId, + sourceOperationId: body.sourceOperationId, + status: body.status, + gatePolicy: body.gatePolicy as KnowledgeGatePolicy | null | undefined, + meta: body.meta, + }), + }; + }) +); + +router.get( + "/chat-knowledge/records/:id", + validate({ params: z.object({ id: idSchema }) }), + asyncHandler(async (req: Request) => { + const record = await getKnowledgeRecordById(String(req.params.id)); + if (!record) throw new HttpError(404, "Knowledge record not found", "NOT_FOUND"); + return { data: record }; + }) +); + +router.post( + "/chat-knowledge/records/search", + validate({ body: searchSchema }), + asyncHandler(async (req: Request) => { + const body = searchSchema.parse(req.body); + return { + data: await searchKnowledgeRecords({ + ownerId: getRequestOwnerId(req, body.ownerId), + chatId: body.chatId, + branchId: body.branchId ?? null, + request: body.request, + }), + }; + }) +); + +router.get( + "/chat-knowledge/links", + validate({ query: linksListQuerySchema }), + asyncHandler(async (req: Request) => { + const query = linksListQuerySchema.parse(req.query); + return { + data: await listKnowledgeRecordLinks({ + ownerId: getRequestOwnerId(req, query.ownerId), + chatId: query.chatId, + branchId: query.branchId ?? null, + }), + }; + }) +); + +router.post( + "/chat-knowledge/links", + validate({ body: linksCreateSchema }), + asyncHandler(async (req: Request) => { + const body = linksCreateSchema.parse(req.body); + return { + data: await createKnowledgeRecordLinksBulk({ + ownerId: getRequestOwnerId(req, body.ownerId), + chatId: body.chatId, + branchId: body.branchId ?? null, + items: body.items, + }), + }; + }) +); + +router.post( + "/chat-knowledge/reveal", + validate({ body: revealSchema }), + asyncHandler(async (req: Request) => { + const body = revealSchema.parse(req.body); + return { + data: await revealKnowledgeRecords({ + ownerId: getRequestOwnerId(req, body.ownerId), + chatId: body.chatId, + branchId: body.branchId ?? null, + request: body.request, + }), + }; + }) +); + +export default router; diff --git a/server/src/api/files/controllers.ts b/server/src/api/files/controllers.ts index 7f3280d7..b1c3d4e4 100644 --- a/server/src/api/files/controllers.ts +++ b/server/src/api/files/controllers.ts @@ -1,8 +1,8 @@ import fs from "fs/promises"; +import { randomUUID as uuidv4 } from "node:crypto"; import path from "path"; import sharp from "sharp"; -import { randomUUID as uuidv4 } from "node:crypto"; import { resolveSafePath } from "@core/files/safe-path"; import { type AsyncRequestHandler } from "@core/middleware/async-handler"; diff --git a/server/src/api/instructions.core.api.ts b/server/src/api/instructions.core.api.ts index 43fe4f0f..74651525 100644 --- a/server/src/api/instructions.core.api.ts +++ b/server/src/api/instructions.core.api.ts @@ -12,6 +12,7 @@ import { updateInstructionBodySchema, } from "../chat-core/schemas"; import { getRequestOwnerId } from "../core/request-context/request-context"; +import { loadBuiltInSillyTavernPreset } from "../services/chat-core/instruction-st-base"; import { createInstruction, deleteInstruction, @@ -52,6 +53,14 @@ router.get( }) ); +router.get( + "/instructions/default-st-preset", + asyncHandler(async () => { + const preset = await loadBuiltInSillyTavernPreset(); + return { data: preset }; + }) +); + router.post( "/instructions/prerender", validate({ body: prerenderBodySchema }), @@ -97,23 +106,39 @@ router.post( "/instructions", validate({ body: createInstructionBodySchema }), asyncHandler(async (req: Request) => { - try { - validateLiquidTemplate(req.body.templateText); - } catch (error) { - throw new HttpError( - 400, - `Instruction не компилируется: ${error instanceof Error ? error.message : String(error)}`, - "VALIDATION_ERROR" - ); + const body = createInstructionBodySchema.parse(req.body); + + if (body.kind === "basic") { + try { + validateLiquidTemplate(body.templateText); + } catch (error) { + throw new HttpError( + 400, + `Instruction не компилируется: ${error instanceof Error ? error.message : String(error)}`, + "VALIDATION_ERROR" + ); + } } - const created = await createInstruction({ - ownerId: getRequestOwnerId(req, req.body.ownerId), - name: req.body.name, - engine: req.body.engine, - templateText: req.body.templateText, - meta: req.body.meta, - }); + const ownerId = getRequestOwnerId(req, body.ownerId); + const created = + body.kind === "basic" + ? await createInstruction({ + ownerId, + name: body.name, + kind: "basic", + engine: body.engine, + templateText: body.templateText, + meta: body.meta, + }) + : await createInstruction({ + ownerId, + name: body.name, + kind: "st_base", + engine: body.engine, + stBase: body.stBase, + meta: body.meta, + }); return { data: created }; }) ); @@ -123,9 +148,10 @@ router.put( validate({ params: idParamsSchema, body: updateInstructionBodySchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - if (typeof req.body.templateText === "string") { + const body = updateInstructionBodySchema.parse(req.body); + if (body.kind === "basic" && typeof body.templateText === "string") { try { - validateLiquidTemplate(req.body.templateText); + validateLiquidTemplate(body.templateText); } catch (error) { throw new HttpError( 400, @@ -135,13 +161,24 @@ router.put( } } - const updated = await updateInstruction({ - id: params.id, - name: req.body.name, - engine: req.body.engine, - templateText: req.body.templateText, - meta: typeof req.body.meta === "undefined" ? undefined : req.body.meta, - }); + let updated; + try { + updated = await updateInstruction({ + id: params.id, + kind: body.kind, + name: body.name, + engine: body.engine, + templateText: body.kind === "basic" ? body.templateText : undefined, + stBase: body.kind === "st_base" ? body.stBase : undefined, + meta: typeof body.meta === "undefined" ? undefined : body.meta, + }); + } catch (error) { + throw new HttpError( + 400, + error instanceof Error ? error.message : String(error), + "VALIDATION_ERROR" + ); + } if (!updated) throw new HttpError(404, "Instruction не найден", "NOT_FOUND"); return { data: updated }; diff --git a/server/src/api/legacy-route-wrappers.test.ts b/server/src/api/legacy-route-wrappers.test.ts index 4141750e..cc8bdbbb 100644 --- a/server/src/api/legacy-route-wrappers.test.ts +++ b/server/src/api/legacy-route-wrappers.test.ts @@ -1,10 +1,21 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import * as path from "node:path"; + import { afterEach, describe, expect, test } from "vitest"; -import { createApp } from "../app"; +import { bootstrapApp, createApp } from "../app"; +import { resetDbForTests } from "../db/client"; import type { Server } from "node:http"; -async function requestJson(path: string): Promise { +async function requestJson(requestPath: string): Promise { + resetDbForTests(); + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), "talespinner-legacy-routes-") + ); + const dbPath = path.join(tempDir, "db.sqlite"); + await bootstrapApp({ dbPath }); const app = createApp(); const server = await new Promise((resolve) => { const started = app.listen(0, () => resolve(started)); @@ -15,11 +26,13 @@ async function requestJson(path: string): Promise { if (!address || typeof address === "string") { throw new Error("Failed to resolve test server address"); } - return await fetch(`http://127.0.0.1:${address.port}${path}`); + return await fetch(`http://127.0.0.1:${address.port}${requestPath}`); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); } } diff --git a/server/src/api/llm.api.ts b/server/src/api/llm.api.ts index 176e25a0..9ccf77b2 100644 --- a/server/src/api/llm.api.ts +++ b/server/src/api/llm.api.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { asyncHandler } from "@core/middleware/async-handler"; import { HttpError } from "@core/middleware/error-handler"; import { validate } from "@core/middleware/validate"; -import { updateLlmRuntime } from "../application/llm/use-cases/update-llm-runtime"; import { llmProviderDefinitions, openAiCompatibleConfigSchema, @@ -16,16 +15,14 @@ import { deleteToken, getProviderConfig, getRuntime, - getRuntimeProviderState, listProviders, listTokens, upsertProviderConfig, - upsertRuntimeProviderState, - upsertRuntime, updateToken, } from "@services/llm/llm-repository"; -import { getModels } from "@services/llm/llm-service"; -import { checkProviderConnection } from "@services/llm/llm-service"; +import { checkProviderConnection, getModels } from "@services/llm/llm-service"; + +import { updateLlmRuntime } from "../application/llm/use-cases/update-llm-runtime"; const router = express.Router(); diff --git a/server/src/api/operation-blocks.core.api.ts b/server/src/api/operation-blocks.core.api.ts index 3647e89a..8cfc6394 100644 --- a/server/src/api/operation-blocks.core.api.ts +++ b/server/src/api/operation-blocks.core.api.ts @@ -9,16 +9,12 @@ import { deleteOperationBlockWithValidation } from "../application/operations/us import { importOperationBlocks } from "../application/operations/use-cases/import-operation-blocks"; import { idSchema, jsonValueSchema, ownerIdSchema } from "../chat-core/schemas"; import { getRequestOwnerId } from "../core/request-context/request-context"; -import { validateOperationBlockImport } from "../services/operations/operation-block-validator"; import { createOperationBlock, - deleteOperationBlock, getOperationBlockById, listOperationBlocks, - resolveImportedOperationBlockName, updateOperationBlock, } from "../services/operations/operation-blocks-repository"; -import { listOperationProfiles } from "../services/operations/operation-profiles-repository"; const router = express.Router(); diff --git a/server/src/api/operation-profiles.core.api.ts b/server/src/api/operation-profiles.core.api.ts index b83cdf22..4eaffcab 100644 --- a/server/src/api/operation-profiles.core.api.ts +++ b/server/src/api/operation-profiles.core.api.ts @@ -10,17 +10,7 @@ import { importOperationProfiles } from "../application/operations/use-cases/imp import { setActiveOperationProfileWithValidation } from "../application/operations/use-cases/set-active-operation-profile"; import { idSchema, jsonValueSchema, ownerIdSchema } from "../chat-core/schemas"; import { getRequestOwnerId } from "../core/request-context/request-context"; -import { - createOperationBlock, - getOperationBlockById, - listOperationBlocks, - resolveImportedOperationBlockName, -} from "../services/operations/operation-blocks-repository"; -import { - getOperationProfileSettings, - setActiveOperationProfile, -} from "../services/operations/operation-profile-settings-repository"; -import { validateOperationProfileImport } from "../services/operations/operation-profile-validator"; +import { getOperationProfileSettings } from "../services/operations/operation-profile-settings-repository"; import { createOperationProfile, deleteOperationProfile, @@ -43,16 +33,6 @@ const updateBodySchema = z.object({ patch: jsonValueSchema, }); -function resolveImportedProfileName(input: string, existingNames: string[]): string { - const base = input.trim() || "Imported profile"; - if (!existingNames.includes(base)) return base; - for (let idx = 2; idx <= 9999; idx += 1) { - const candidate = `${base} (imported ${idx})`; - if (!existingNames.includes(candidate)) return candidate; - } - return `${base} (imported ${Date.now()})`; -} - router.get( "/operation-profiles", asyncHandler(async (req: Request) => { diff --git a/server/src/api/static.api.ts b/server/src/api/static.api.ts index 11aff365..49b7dc70 100644 --- a/server/src/api/static.api.ts +++ b/server/src/api/static.api.ts @@ -2,6 +2,7 @@ import path from "path"; import express from "express"; +import { resolveMonorepoRoot } from "../config/path-resolver"; import { createDataPath } from "../utils"; const router = express.Router(); @@ -32,4 +33,18 @@ router.use( }) ); +router.use( + "/defaults/backgrounds", + express.static(path.join(resolveMonorepoRoot(), "default", "backgrounds"), { + maxAge: "1d", + setHeaders: (res, filePath) => { + const ext = path.extname(filePath).toLowerCase(); + const mimeType = mimeTypes[ext]; + if (mimeType) { + res.setHeader("Content-Type", mimeType); + } + }, + }) +); + export default router; diff --git a/server/src/api/world-info.core.api.ts b/server/src/api/world-info.core.api.ts index 503a01b6..b88eea6e 100644 --- a/server/src/api/world-info.core.api.ts +++ b/server/src/api/world-info.core.api.ts @@ -10,8 +10,6 @@ import { replaceWorldInfoBindingsWithValidation } from "../application/world-inf import { resolveWorldInfoForChat } from "../application/world-info/use-cases/resolve-world-info-for-chat"; import { idSchema, ownerIdSchema } from "../chat-core/schemas"; import { getRequestOwnerId } from "../core/request-context/request-context"; -import { getChatById } from "../services/chat-core/chats-repository"; -import { listProjectedPromptMessages } from "../services/chat-entry-parts/prompt-history"; import { convertWorldInfoImport, exportWorldInfoBookToStNative, @@ -27,16 +25,13 @@ import { createWorldInfoBook, duplicateWorldInfoBook, getWorldInfoBookById, - getWorldInfoBooksByIds, getWorldInfoSettings, listWorldInfoBindings, listWorldInfoBooks, patchWorldInfoSettings, - replaceWorldInfoBindings, softDeleteWorldInfoBook, updateWorldInfoBook, } from "../services/world-info/world-info-repositories"; -import { resolveWorldInfoRuntimeForChat } from "../services/world-info/world-info-runtime"; import { worldInfoBindingRoles, worldInfoScopes } from "../services/world-info/world-info-types"; const router = express.Router(); diff --git a/server/src/app.ts b/server/src/app.ts index 02c4bbab..bdb01f9f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -7,9 +7,9 @@ import morgan from "morgan"; import { routes } from "./api/_routes_"; import staticRouter from "./api/static.api"; import { runBackendBootstrap } from "./core/bootstrap/bootstrap-coordinator"; +import { structuredLogger } from "./core/logging/structured-logger"; import { errorHandler } from "./core/middleware/error-handler"; import { requestLifecycleLogger } from "./core/middleware/request-lifecycle-logger"; -import { structuredLogger } from "./core/logging/structured-logger"; import { requestContextMiddleware } from "./core/request-context/request-context"; export type BootstrapAppOptions = { @@ -20,14 +20,22 @@ export async function bootstrapApp(options: BootstrapAppOptions = {}): Promise 0 ? trimmed : null; +} + +function assertUniquePartIds(ids: string[], label: string): void { + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) { + throw new HttpError(400, `Duplicate ${label} item`, "VALIDATION_ERROR", { + partId: id, + }); + } + seen.add(id); + } +} + +function normalizePayloadByFormat(params: { + payloadFormat: PartPayloadFormat; + payload: unknown; + partId: string; +}): BatchUpdateEntryPartPayload { + if (params.payloadFormat === "text" || params.payloadFormat === "markdown") { + if (typeof params.payload !== "string") { + throw new HttpError(400, "Text/markdown part payload must be a string", "VALIDATION_ERROR", { + partId: params.partId, + }); + } + return params.payload; + } + + if (params.payloadFormat === "json") { + if ( + params.payload === null || + typeof params.payload === "string" || + typeof params.payload === "number" || + typeof params.payload === "boolean" || + typeof params.payload === "object" + ) { + return params.payload as BatchUpdateEntryPartPayload; + } + } + + throw new HttpError(400, "Unsupported payload type for part format", "VALIDATION_ERROR", { + partId: params.partId, + payloadFormat: params.payloadFormat, + }); +} + +function isPartDependentOnTarget(params: { + refId: string; + targetRefId: string; + byRefId: Map; +}): boolean { + const visited = new Set(); + let cursor = params.byRefId.get(params.refId)?.replacesPartId ?? null; + while (cursor) { + if (cursor === params.targetRefId) return true; + if (visited.has(cursor)) break; + visited.add(cursor); + cursor = params.byRefId.get(cursor)?.replacesPartId ?? null; + } + return false; +} + +function isStringMainPayloadFormat(format: PartPayloadFormat): boolean { + return format === "text" || format === "markdown"; +} + +function patchRefId(patch: BatchUpdateEntryPartPatch): string { + if ("partId" in patch && typeof patch.partId === "string") return patch.partId; + return patch.clientPartId; +} + +function isExistingPatch( + patch: BatchUpdateEntryPartPatch +): patch is ExistingBatchUpdateEntryPartPatch { + return "partId" in patch && typeof patch.partId === "string"; +} + +function normalizeNewPartFormat(format: PartPayloadFormat | undefined): PartPayloadFormat { + return format ?? "markdown"; +} + +function normalizeNewPartChannel(channel: PartChannel | undefined): PartChannel { + return channel ?? "aux"; +} + +function defaultUiForFormat(format: PartPayloadFormat): Part["ui"] { + return { rendererId: format === "json" ? "json" : format }; +} + +function defaultPromptForFormat(format: PartPayloadFormat): Part["prompt"] { + if (format === "json") return { serializerId: "asJson" }; + if (format === "markdown") return { serializerId: "asMarkdown" }; + return { serializerId: "asText" }; +} + +export function assertBatchUpdateVariantIsActive(params: { + entry: Entry; + requestedVariantId: string; +}): void { + if (params.entry.activeVariantId === params.requestedVariantId) return; + throw new HttpError(409, "Variant mismatch: active variant has changed", "CONFLICT", { + activeVariantId: params.entry.activeVariantId, + requestedVariantId: params.requestedVariantId, + }); +} + +export function buildBatchUpdatePartPlan(params: { + variantParts: Part[]; + body: BatchUpdateEntryPartsBody; +}): BatchUpdatePartPlan { + const activeParts = (params.variantParts ?? []).filter((part) => !part.softDeleted); + if (activeParts.length === 0) { + throw new HttpError(400, "No active parts in variant", "VALIDATION_ERROR"); + } + + const activeById = new Map(activeParts.map((part) => [part.partId, part] as const)); + const requestedRefIds = params.body.parts.map(patchRefId); + assertUniquePartIds(requestedRefIds, "parts"); + + const existingPatches = params.body.parts.filter(isExistingPatch); + const requestedExistingIds = existingPatches.map((item) => item.partId); + for (const partId of requestedExistingIds) { + if (!activeById.has(partId)) { + throw new HttpError(404, "Part не найден", "NOT_FOUND", { partId }); + } + } + if (requestedExistingIds.length !== activeParts.length) { + throw new HttpError(400, "Request must include all active variant parts", "VALIDATION_ERROR"); + } + + const requestPatchByRefId = new Map(params.body.parts.map((item) => [patchRefId(item), item] as const)); + const plannedByRefId = new Map(); + const createdTurn = activeParts.reduce((max, part) => Math.max(max, part.createdTurn), 0); + + for (const activePart of activeParts) { + const patch = requestPatchByRefId.get(activePart.partId); + if (!patch || !isExistingPatch(patch)) { + throw new HttpError(400, "Missing part in batch update request", "VALIDATION_ERROR", { + partId: activePart.partId, + }); + } + + plannedByRefId.set(activePart.partId, { + refId: activePart.partId, + partId: activePart.partId, + clientPartId: null, + channel: activePart.channel, + order: activePart.order, + payload: normalizePayloadByFormat({ + payloadFormat: activePart.payloadFormat, + payload: patch.payload, + partId: activePart.partId, + }), + payloadFormat: activePart.payloadFormat, + schemaId: activePart.schemaId, + label: activePart.label, + visibility: { + ui: patch.visibility.ui, + prompt: patch.visibility.prompt, + }, + replacesPartId: normalizeReplacesPartId(activePart.replacesPartId), + deleted: patch.deleted, + createdTurn: activePart.createdTurn, + }); + } + + for (const patch of params.body.parts) { + if (isExistingPatch(patch)) continue; + const format = normalizeNewPartFormat(patch.payloadFormat); + plannedByRefId.set(patch.clientPartId, { + refId: patch.clientPartId, + partId: null, + clientPartId: patch.clientPartId, + channel: normalizeNewPartChannel(patch.channel), + order: 0, + payload: normalizePayloadByFormat({ + payloadFormat: format, + payload: patch.payload, + partId: patch.clientPartId, + }), + payloadFormat: format, + label: patch.label, + visibility: { + ui: patch.visibility.ui, + prompt: patch.visibility.prompt, + }, + replacesPartId: null, + deleted: false, + createdTurn, + }); + } + + const nonDeletedPartIds = Array.from(plannedByRefId.values()) + .filter((part) => !part.deleted) + .map((part) => part.refId); + if (nonDeletedPartIds.length === 0) { + throw new HttpError(400, "At least one part must remain active", "VALIDATION_ERROR"); + } + + const mainPart = plannedByRefId.get(params.body.mainPartId); + if (!mainPart || mainPart.deleted) { + throw new HttpError(400, "mainPartId must reference an active part", "VALIDATION_ERROR", { + mainPartId: params.body.mainPartId, + }); + } + if (!isStringMainPayloadFormat(mainPart.payloadFormat) || typeof mainPart.payload !== "string") { + throw new HttpError(400, "Main part must be text/markdown with string payload", "VALIDATION_ERROR", { + mainPartId: params.body.mainPartId, + }); + } + + assertUniquePartIds(params.body.orderedPartIds, "orderedPartIds"); + const orderedSet = new Set(params.body.orderedPartIds); + const nonDeletedSet = new Set(nonDeletedPartIds); + if (orderedSet.size !== nonDeletedSet.size) { + throw new HttpError( + 400, + "orderedPartIds must match the set of non-deleted parts", + "VALIDATION_ERROR" + ); + } + for (const partId of orderedSet) { + if (!nonDeletedSet.has(partId)) { + throw new HttpError( + 400, + "orderedPartIds must match the set of non-deleted parts", + "VALIDATION_ERROR", + { partId } + ); + } + } + + mainPart.channel = "main"; + mainPart.replacesPartId = null; + + const deletedRefIds = new Set( + Array.from(plannedByRefId.values()) + .filter((part) => part.deleted) + .map((part) => part.refId) + ); + + for (const plannedPart of plannedByRefId.values()) { + if (plannedPart.deleted) continue; + if (plannedPart.refId !== params.body.mainPartId && plannedPart.channel === "main") { + plannedPart.channel = "aux"; + } + if (plannedPart.replacesPartId && deletedRefIds.has(plannedPart.replacesPartId)) { + plannedPart.replacesPartId = null; + } + } + + for (const plannedPart of plannedByRefId.values()) { + if (plannedPart.deleted) continue; + if (plannedPart.refId === params.body.mainPartId) continue; + if ( + isPartDependentOnTarget({ + refId: plannedPart.refId, + targetRefId: params.body.mainPartId, + byRefId: plannedByRefId, + }) + ) { + plannedPart.replacesPartId = null; + } + } + + let nextOrder = 0; + for (const partId of params.body.orderedPartIds) { + const plannedPart = plannedByRefId.get(partId); + if (!plannedPart || plannedPart.deleted) continue; + plannedPart.order = nextOrder; + nextOrder += 10; + } + + const activePlans = Array.from(plannedByRefId.values()).filter((part) => !part.deleted); + const patches: BatchUpdatePartPatchPlan[] = activePlans + .filter((plannedPart) => plannedPart.partId) + .map((plannedPart) => ({ + partId: plannedPart.partId as string, + channel: plannedPart.channel, + order: plannedPart.order, + payload: plannedPart.payload, + payloadFormat: plannedPart.payloadFormat, + schemaId: plannedPart.schemaId, + label: plannedPart.label, + visibility: plannedPart.visibility, + replacesPartId: plannedPart.replacesPartId, + softDeleted: false, + softDeletedAt: null, + softDeletedBy: null, + })); + + const creates: BatchUpdatePartCreatePlan[] = activePlans + .filter((plannedPart) => plannedPart.clientPartId) + .map((plannedPart) => ({ + clientPartId: plannedPart.clientPartId as string, + channel: plannedPart.channel, + order: plannedPart.order, + payload: plannedPart.payload, + payloadFormat: plannedPart.payloadFormat, + schemaId: plannedPart.schemaId, + label: plannedPart.label, + visibility: plannedPart.visibility, + ui: defaultUiForFormat(plannedPart.payloadFormat), + prompt: defaultPromptForFormat(plannedPart.payloadFormat), + lifespan: "infinite", + createdTurn: plannedPart.createdTurn, + source: "user", + replacesPartId: plannedPart.replacesPartId, + })); + + const deletedPartIds = Array.from(plannedByRefId.values()) + .filter((item) => item.deleted && item.partId) + .map((item) => item.partId as string); + + return { + mainPartId: params.body.mainPartId, + updatedPartIds: patches.map((item) => item.partId), + deletedPartIds, + patches, + creates, + }; +} diff --git a/server/src/application/chat-runtime/batch-update-entry-parts-types.ts b/server/src/application/chat-runtime/batch-update-entry-parts-types.ts new file mode 100644 index 00000000..905a3dd4 --- /dev/null +++ b/server/src/application/chat-runtime/batch-update-entry-parts-types.ts @@ -0,0 +1,86 @@ +import type { + Part, + PartChannel, + PartLifespan, + PartPayloadFormat, + PartSource, + PartVisibility, +} from "@shared/types/chat-entry-parts"; + +export type BatchUpdateEntryPartPayload = string | object | number | boolean | null; + +export type ExistingBatchUpdateEntryPartPatch = { + partId: string; + clientPartId?: never; + deleted: boolean; + visibility: { + ui: "always" | "never"; + prompt: boolean; + }; + payload: BatchUpdateEntryPartPayload; +}; + +export type NewBatchUpdateEntryPartPatch = { + partId?: never; + clientPartId: string; + deleted?: false; + channel?: PartChannel; + payloadFormat?: PartPayloadFormat; + label?: string; + visibility: { + ui: "always" | "never"; + prompt: boolean; + }; + payload: BatchUpdateEntryPartPayload; +}; + +export type BatchUpdateEntryPartPatch = + | ExistingBatchUpdateEntryPartPatch + | NewBatchUpdateEntryPartPatch; + +export type BatchUpdateEntryPartsBody = { + variantId: string; + mainPartId: string; + orderedPartIds: string[]; + parts: BatchUpdateEntryPartPatch[]; +}; + +export type BatchUpdatePartPatchPlan = { + partId: string; + channel: PartChannel; + order: number; + payload: BatchUpdateEntryPartPayload; + payloadFormat: PartPayloadFormat; + schemaId?: string; + label?: string; + visibility: PartVisibility; + replacesPartId: string | null; + softDeleted: false; + softDeletedAt: null; + softDeletedBy: null; +}; + +export type BatchUpdatePartCreatePlan = { + clientPartId: string; + channel: PartChannel; + order: number; + payload: BatchUpdateEntryPartPayload; + payloadFormat: PartPayloadFormat; + schemaId?: string; + label?: string; + visibility: PartVisibility; + ui: Part["ui"]; + prompt: Part["prompt"]; + lifespan: PartLifespan; + createdTurn: number; + source: PartSource; + replacesPartId: string | null; +}; + +export type BatchUpdatePartPlan = { + mainPartId: string; + updatedPartIds: string[]; + deletedPartIds: string[]; + patches: BatchUpdatePartPatchPlan[]; + creates: BatchUpdatePartCreatePlan[]; +}; diff --git a/server/src/application/chat-runtime/chat-entry-helpers.ts b/server/src/application/chat-runtime/chat-entry-helpers.ts index 26da5d8d..166c49a4 100644 --- a/server/src/application/chat-runtime/chat-entry-helpers.ts +++ b/server/src/application/chat-runtime/chat-entry-helpers.ts @@ -1,19 +1,23 @@ import { HttpError } from "@core/middleware/error-handler"; +import { renderLiquidTemplate } from "../../services/chat-core/prompt-template-renderer"; import { getUiProjection } from "../../services/chat-entry-parts/projection"; import { normalizePromptDiagnosticsDebugJson } from "../../services/chat-generation-v3/prompt/generation-debug-payload"; -import { renderLiquidTemplate } from "../../services/chat-core/prompt-template-renderer"; + +export { + assertBatchUpdateVariantIsActive, + buildBatchUpdatePartPlan, +} from "./batch-update-entry-parts-plan"; +export type { + BatchUpdateEntryPartPatch, + BatchUpdateEntryPartPayload, + BatchUpdateEntryPartsBody, + BatchUpdatePartPlan, +} from "./batch-update-entry-parts-types"; import type { GenerationWithDebugDto } from "../../services/chat-core/generations-repository"; import type { InstructionRenderContext } from "../../services/chat-core/prompt-template-renderer"; -import type { - Entry, - Part, - PartChannel, - PartPayloadFormat, - PartVisibility, - Variant, -} from "@shared/types/chat-entry-parts"; +import type { Entry, Part, Variant } from "@shared/types/chat-entry-parts"; export type UserPersonaSnapshot = { id: string; @@ -109,58 +113,6 @@ export type LatestWorldInfoActivationsResponse = { }>; }; -export type BatchUpdateEntryPartPayload = string | object | number | boolean | null; - -export type BatchUpdateEntryPartPatch = { - partId: string; - deleted: boolean; - visibility: { - ui: "always" | "never"; - prompt: boolean; - }; - payload: BatchUpdateEntryPartPayload; -}; - -export type BatchUpdateEntryPartsBody = { - variantId: string; - mainPartId: string; - orderedPartIds: string[]; - parts: BatchUpdateEntryPartPatch[]; -}; - -type PlannedBatchPartState = { - partId: string; - channel: PartChannel; - order: number; - payload: BatchUpdateEntryPartPayload; - payloadFormat: PartPayloadFormat; - schemaId?: string; - label?: string; - visibility: PartVisibility; - replacesPartId: string | null; - deleted: boolean; -}; - -type BatchUpdatePartPlan = { - mainPartId: string; - updatedPartIds: string[]; - deletedPartIds: string[]; - patches: Array<{ - partId: string; - channel: PartChannel; - order: number; - payload: BatchUpdateEntryPartPayload; - payloadFormat: PartPayloadFormat; - schemaId?: string; - label?: string; - visibility: PartVisibility; - replacesPartId: string | null; - softDeleted: boolean; - softDeletedAt: number | null; - softDeletedBy: "user" | "agent" | null; - }>; -}; - export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -698,245 +650,6 @@ export function pickPreviousUserEntries(params: { return result; } -function normalizeReplacesPartId(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function assertUniquePartIds(ids: string[], label: string): void { - const seen = new Set(); - for (const id of ids) { - if (seen.has(id)) { - throw new HttpError(400, `Duplicate ${label} item`, "VALIDATION_ERROR", { - partId: id, - }); - } - seen.add(id); - } -} - -function normalizePayloadByFormat(params: { - payloadFormat: PartPayloadFormat; - payload: unknown; - partId: string; -}): BatchUpdateEntryPartPayload { - if (params.payloadFormat === "text" || params.payloadFormat === "markdown") { - if (typeof params.payload !== "string") { - throw new HttpError(400, "Text/markdown part payload must be a string", "VALIDATION_ERROR", { - partId: params.partId, - }); - } - return params.payload; - } - - if (params.payloadFormat === "json") { - if ( - params.payload === null || - typeof params.payload === "string" || - typeof params.payload === "number" || - typeof params.payload === "boolean" || - typeof params.payload === "object" - ) { - return params.payload as BatchUpdateEntryPartPayload; - } - } - - throw new HttpError(400, "Unsupported payload type for part format", "VALIDATION_ERROR", { - partId: params.partId, - payloadFormat: params.payloadFormat, - }); -} - -function isPartDependentOnTarget(params: { - partId: string; - targetPartId: string; - byId: Map; -}): boolean { - const visited = new Set(); - let cursor = params.byId.get(params.partId)?.replacesPartId ?? null; - while (cursor) { - if (cursor === params.targetPartId) return true; - if (visited.has(cursor)) break; - visited.add(cursor); - cursor = params.byId.get(cursor)?.replacesPartId ?? null; - } - return false; -} - -function isStringMainPayloadFormat(format: PartPayloadFormat): boolean { - return format === "text" || format === "markdown"; -} - -export function assertBatchUpdateVariantIsActive(params: { - entry: Entry; - requestedVariantId: string; -}): void { - if (params.entry.activeVariantId === params.requestedVariantId) return; - throw new HttpError(409, "Variant mismatch: active variant has changed", "CONFLICT", { - activeVariantId: params.entry.activeVariantId, - requestedVariantId: params.requestedVariantId, - }); -} - -export function buildBatchUpdatePartPlan(params: { - variantParts: Part[]; - body: BatchUpdateEntryPartsBody; - nowMs?: number; -}): BatchUpdatePartPlan { - const activeParts = (params.variantParts ?? []).filter((part) => !part.softDeleted); - if (activeParts.length === 0) { - throw new HttpError(400, "No active parts in variant", "VALIDATION_ERROR"); - } - - const activeById = new Map(activeParts.map((part) => [part.partId, part] as const)); - const requestedPartIds = params.body.parts.map((item) => item.partId); - assertUniquePartIds(requestedPartIds, "parts"); - - for (const partId of requestedPartIds) { - if (!activeById.has(partId)) { - throw new HttpError(404, "Part не найден", "NOT_FOUND", { partId }); - } - } - if (requestedPartIds.length !== activeParts.length) { - throw new HttpError(400, "Request must include all active variant parts", "VALIDATION_ERROR"); - } - - const requestPatchById = new Map(params.body.parts.map((item) => [item.partId, item] as const)); - const plannedById = new Map(); - - for (const activePart of activeParts) { - const patch = requestPatchById.get(activePart.partId); - if (!patch) { - throw new HttpError(400, "Missing part in batch update request", "VALIDATION_ERROR", { - partId: activePart.partId, - }); - } - - plannedById.set(activePart.partId, { - partId: activePart.partId, - channel: activePart.channel, - order: activePart.order, - payload: normalizePayloadByFormat({ - payloadFormat: activePart.payloadFormat, - payload: patch.payload, - partId: activePart.partId, - }), - payloadFormat: activePart.payloadFormat, - schemaId: activePart.schemaId, - label: activePart.label, - visibility: { - ui: patch.visibility.ui, - prompt: patch.visibility.prompt, - }, - replacesPartId: normalizeReplacesPartId(activePart.replacesPartId), - deleted: patch.deleted, - }); - } - - const nonDeletedPartIds = Array.from(plannedById.values()) - .filter((part) => !part.deleted) - .map((part) => part.partId); - if (nonDeletedPartIds.length === 0) { - throw new HttpError(400, "At least one part must remain active", "VALIDATION_ERROR"); - } - - const mainPart = plannedById.get(params.body.mainPartId); - if (!mainPart || mainPart.deleted) { - throw new HttpError(400, "mainPartId must reference an active part", "VALIDATION_ERROR", { - mainPartId: params.body.mainPartId, - }); - } - if (!isStringMainPayloadFormat(mainPart.payloadFormat) || typeof mainPart.payload !== "string") { - throw new HttpError(400, "Main part must be text/markdown with string payload", "VALIDATION_ERROR", { - mainPartId: params.body.mainPartId, - }); - } - - assertUniquePartIds(params.body.orderedPartIds, "orderedPartIds"); - const orderedSet = new Set(params.body.orderedPartIds); - const nonDeletedSet = new Set(nonDeletedPartIds); - if (orderedSet.size !== nonDeletedSet.size) { - throw new HttpError( - 400, - "orderedPartIds must match the set of non-deleted parts", - "VALIDATION_ERROR" - ); - } - for (const partId of orderedSet) { - if (!nonDeletedSet.has(partId)) { - throw new HttpError( - 400, - "orderedPartIds must match the set of non-deleted parts", - "VALIDATION_ERROR", - { partId } - ); - } - } - - mainPart.channel = "main"; - mainPart.replacesPartId = null; - - for (const plannedPart of plannedById.values()) { - if (plannedPart.deleted) continue; - if (plannedPart.partId === params.body.mainPartId) continue; - if (plannedPart.channel === "main") plannedPart.channel = "aux"; - } - - for (const plannedPart of plannedById.values()) { - if (plannedPart.deleted) continue; - if (plannedPart.partId === params.body.mainPartId) continue; - if ( - isPartDependentOnTarget({ - partId: plannedPart.partId, - targetPartId: params.body.mainPartId, - byId: plannedById, - }) - ) { - plannedPart.replacesPartId = null; - } - } - - mainPart.order = 0; - let nextOrder = 10; - for (const partId of params.body.orderedPartIds) { - if (partId === params.body.mainPartId) continue; - const plannedPart = plannedById.get(partId); - if (!plannedPart || plannedPart.deleted) continue; - plannedPart.order = nextOrder; - nextOrder += 10; - } - - const nowMs = - typeof params.nowMs === "number" && Number.isFinite(params.nowMs) - ? Math.max(0, Math.floor(params.nowMs)) - : Date.now(); - - const patches = Array.from(plannedById.values()).map((plannedPart) => ({ - partId: plannedPart.partId, - channel: plannedPart.channel, - order: plannedPart.order, - payload: plannedPart.payload, - payloadFormat: plannedPart.payloadFormat, - schemaId: plannedPart.schemaId, - label: plannedPart.label, - visibility: plannedPart.visibility, - replacesPartId: plannedPart.replacesPartId, - softDeleted: plannedPart.deleted, - softDeletedAt: plannedPart.deleted ? nowMs : null, - softDeletedBy: plannedPart.deleted ? ("user" as const) : null, - })); - - const deletedPartIds = patches.filter((item) => item.softDeleted).map((item) => item.partId); - - return { - mainPartId: params.body.mainPartId, - updatedPartIds: patches.map((item) => item.partId), - deletedPartIds, - patches, - }; -} - export function isCanonicalizationPart(part: Part): boolean { if (part.channel !== "main") return false; if (part.source !== "agent") return false; diff --git a/server/src/application/chat-runtime/chat-generation-helpers.ts b/server/src/application/chat-runtime/chat-generation-helpers.ts index e748f5a0..a75024ce 100644 --- a/server/src/application/chat-runtime/chat-generation-helpers.ts +++ b/server/src/application/chat-runtime/chat-generation-helpers.ts @@ -1,3 +1,8 @@ +import { getGenerationByIdWithDebug } from "../../services/chat-core/generations-repository"; +import { + getActiveVariantWithParts, + listEntries, +} from "../../services/chat-entry-parts/entries-repository"; import { createPart } from "../../services/chat-entry-parts/parts-repository"; import { createVariant, @@ -6,11 +11,6 @@ import { listEntryVariants, updateVariantDerived, } from "../../services/chat-entry-parts/variants-repository"; -import { - getActiveVariantWithParts, - listEntries, -} from "../../services/chat-entry-parts/entries-repository"; -import { getGenerationByIdWithDebug } from "../../services/chat-core/generations-repository"; import { pickPreviousUserEntries, resolveContinueUserTurnTarget } from "./chat-entry-helpers"; diff --git a/server/src/application/chat-runtime/generation-session.ts b/server/src/application/chat-runtime/generation-session.ts index fcaa75c3..8908ab05 100644 --- a/server/src/application/chat-runtime/generation-session.ts +++ b/server/src/application/chat-runtime/generation-session.ts @@ -13,6 +13,7 @@ async function* streamWithFinalization(params: { }): AsyncGenerator { let generationId: string | null = null; let streamError: unknown; + let finalizationError: unknown; try { for await (const event of params.events) { @@ -29,10 +30,14 @@ async function* streamWithFinalization(params: { await params.afterRun?.({ generationId }); } catch (afterRunError) { if (!streamError) { - throw afterRunError; + finalizationError = afterRunError; } } } + + if (finalizationError) { + throw finalizationError; + } } export function buildChatGenerationSession( diff --git a/server/src/application/chat-runtime/use-cases/batch-update-entry-parts.ts b/server/src/application/chat-runtime/use-cases/batch-update-entry-parts.ts index c83eb21f..8137666c 100644 --- a/server/src/application/chat-runtime/use-cases/batch-update-entry-parts.ts +++ b/server/src/application/chat-runtime/use-cases/batch-update-entry-parts.ts @@ -2,7 +2,6 @@ import { HttpError } from "@core/middleware/error-handler"; import { getActiveVariantWithParts, getEntryById } from "../../../services/chat-entry-parts/entries-repository"; import { applyPartMutableBatchPatches } from "../../../services/chat-entry-parts/parts-repository"; - import { assertBatchUpdateVariantIsActive, buildBatchUpdatePartPlan, @@ -20,6 +19,7 @@ export type BatchUpdateEntryPartsResult = { mainPartId: string; updatedPartIds: string[]; deletedPartIds: string[]; + createdParts: Array<{ clientPartId: string; partId: string }>; }; export async function batchUpdateEntryParts( @@ -41,19 +41,24 @@ export async function batchUpdateEntryParts( const plan = buildBatchUpdatePartPlan({ variantParts: activeVariant.parts ?? [], body: params.body, - nowMs: Date.now(), }); - await applyPartMutableBatchPatches({ + const applyResult = await applyPartMutableBatchPatches({ variantId: activeVariant.variantId, patches: plan.patches, + creates: plan.creates, + deletePartIds: plan.deletedPartIds, }); + const createdPartIdByClientId = new Map( + applyResult.createdParts.map((item) => [item.clientPartId, item.partId] as const) + ); return { entryId: entry.entryId, variantId: activeVariant.variantId, - mainPartId: plan.mainPartId, + mainPartId: createdPartIdByClientId.get(plan.mainPartId) ?? plan.mainPartId, updatedPartIds: plan.updatedPartIds, deletedPartIds: plan.deletedPartIds, + createdParts: applyResult.createdParts, }; } diff --git a/server/src/application/chat-runtime/use-cases/chat-entry-mutation-use-cases.test.ts b/server/src/application/chat-runtime/use-cases/chat-entry-mutation-use-cases.test.ts index 27ac1c7e..4f3e426b 100644 --- a/server/src/application/chat-runtime/use-cases/chat-entry-mutation-use-cases.test.ts +++ b/server/src/application/chat-runtime/use-cases/chat-entry-mutation-use-cases.test.ts @@ -17,8 +17,8 @@ import { initDb, resetDbForTests } from "../../../db/client"; import { chatBranches, chats, entityProfiles } from "../../../db/schema"; import { createTempDataDir, removeTempDataDir } from "../../../e2e/helpers/tmp-dir"; import * as entriesRepository from "../../../services/chat-entry-parts/entries-repository"; -import * as partsRepository from "../../../services/chat-entry-parts/parts-repository"; import { createEntryWithVariant, getActiveVariantWithParts, getEntryById } from "../../../services/chat-entry-parts/entries-repository"; +import * as partsRepository from "../../../services/chat-entry-parts/parts-repository"; import { createPart, getPartById } from "../../../services/chat-entry-parts/parts-repository"; import { createVariant, @@ -372,8 +372,9 @@ describe("chat entry mutation use cases", () => { entryId: entry.entry.entryId, variantId: entry.variant.variantId, mainPartId: auxPart.partId, - updatedPartIds: expect.arrayContaining([mainPart.partId, auxPart.partId, deletePart.partId]), + updatedPartIds: expect.arrayContaining([mainPart.partId, auxPart.partId]), deletedPartIds: [deletePart.partId], + createdParts: [], }); const refreshedEntry = await getEntryById({ entryId: entry.entry.entryId }); @@ -394,9 +395,75 @@ describe("chat entry mutation use cases", () => { payload: "Old main moved", softDeleted: false, }); - expect(refreshedParts.get(deletePart.partId)).toMatchObject({ - softDeleted: true, - softDeletedBy: "user", + expect(refreshedParts.get(deletePart.partId)).toBeUndefined(); + }); + + test("batchUpdateEntryParts creates new blocks", async () => { + const fixture = await seedChatFixture(); + const entry = await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "assistant", + }); + const mainPart = await createTextPart({ + variantId: entry.variant.variantId, + payload: "Main", + channel: "main", + order: 0, + }); + + const result = await batchUpdateEntryParts({ + entryId: entry.entry.entryId, + body: { + variantId: entry.variant.variantId, + mainPartId: "client-new-main", + orderedPartIds: [mainPart.partId, "client-new-main"], + parts: [ + { + partId: mainPart.partId, + deleted: false, + visibility: { ui: "always", prompt: true }, + payload: "Main moved", + }, + { + clientPartId: "client-new-main", + channel: "aux", + payloadFormat: "markdown", + visibility: { ui: "always", prompt: true }, + payload: "Created main", + }, + ], + }, + }); + + const createdPartId = result.createdParts[0]?.partId; + expect(result).toEqual({ + entryId: entry.entry.entryId, + variantId: entry.variant.variantId, + mainPartId: createdPartId, + updatedPartIds: [mainPart.partId], + deletedPartIds: [], + createdParts: [{ clientPartId: "client-new-main", partId: createdPartId }], + }); + + const refreshedEntry = await getEntryById({ entryId: entry.entry.entryId }); + const refreshedVariant = refreshedEntry + ? await getActiveVariantWithParts({ entry: refreshedEntry }) + : null; + const refreshedParts = new Map((refreshedVariant?.parts ?? []).map((part) => [part.partId, part] as const)); + + expect(refreshedParts.get(mainPart.partId)).toMatchObject({ + channel: "aux", + order: 0, + payload: "Main moved", + }); + expect(createdPartId).toBeTruthy(); + expect(refreshedParts.get(createdPartId ?? "")).toMatchObject({ + channel: "main", + order: 10, + payload: "Created main", + payloadFormat: "markdown", + source: "user", }); }); diff --git a/server/src/application/chat-runtime/use-cases/chat-entry-read-use-cases.test.ts b/server/src/application/chat-runtime/use-cases/chat-entry-read-use-cases.test.ts index fe481d92..3a1f4323 100644 --- a/server/src/application/chat-runtime/use-cases/chat-entry-read-use-cases.test.ts +++ b/server/src/application/chat-runtime/use-cases/chat-entry-read-use-cases.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; +import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -39,11 +40,9 @@ vi.mock("../../../services/chat-core/generations-repository", async () => { }; }); -import { eq } from "drizzle-orm"; - import { applyMigrations } from "../../../db/apply-migrations"; import { initDb, resetDbForTests } from "../../../db/client"; -import { chatBranches, chats, entityProfiles } from "../../../db/schema"; +import { chatBranches, chatEntries, chats, entityProfiles } from "../../../db/schema"; import { createTempDataDir, removeTempDataDir } from "../../../e2e/helpers/tmp-dir"; import { createEntryWithVariant } from "../../../services/chat-entry-parts/entries-repository"; import { createPart } from "../../../services/chat-entry-parts/parts-repository"; @@ -129,6 +128,7 @@ async function createEntry(params: { chatId: string; branchId: string; role: "user" | "assistant" | "system"; + meta?: unknown; }) { return createEntryWithVariant({ ownerId: "global", @@ -136,6 +136,7 @@ async function createEntry(params: { branchId: params.branchId, role: params.role, variantKind: params.role === "assistant" ? "generation" : "manual_edit", + meta: params.meta, }); } @@ -228,12 +229,183 @@ describe("chat entry read use cases", () => { hasMoreOlder: false, nextCursor: null, }); + expect(result.lastSelectedPersonaId).toBeNull(); expect(result.entries).toHaveLength(2); expect(result.entries.every((item) => item.promptUsage.estimator === "chars_div4")).toBe(true); expect(result.entries.every((item) => item.promptUsage.included)).toBe(true); expect(result.entries.every((item) => item.promptUsage.approxTokens > 0)).toBe(true); }); + test("getChatEntries returns lastSelectedPersonaId from the latest active user entry", async () => { + const fixture = await seedChatFixture(); + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "user", + meta: { + requestId: "req-1", + personaSnapshot: { + id: "persona-1", + name: "Alice", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 2)); + + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "assistant", + meta: { + personaSnapshot: { + id: "persona-assistant", + name: "Assistant Persona", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 2)); + + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "user", + meta: { + requestId: "req-2", + personaSnapshot: { + id: "persona-2", + name: "Bob", + }, + }, + }); + + const result = await getChatEntries({ + chatId: fixture.chatId, + query: { + limit: 50, + }, + }); + + expect(result.lastSelectedPersonaId).toBe("persona-2"); + }); + + test("getChatEntries ignores assistant and system persona snapshots for lastSelectedPersonaId", async () => { + const fixture = await seedChatFixture(); + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "user", + meta: { + personaSnapshot: { + id: "persona-user", + name: "User Persona", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 2)); + + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "system", + meta: { + personaSnapshot: { + id: "persona-system", + name: "System Persona", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 2)); + + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "assistant", + meta: { + personaSnapshot: { + id: "persona-assistant", + name: "Assistant Persona", + }, + }, + }); + + const result = await getChatEntries({ + chatId: fixture.chatId, + query: { + limit: 50, + }, + }); + + expect(result.lastSelectedPersonaId).toBe("persona-user"); + }); + + test("getChatEntries ignores soft-deleted user entries when resolving lastSelectedPersonaId", async () => { + const fixture = await seedChatFixture(); + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "user", + meta: { + personaSnapshot: { + id: "persona-active", + name: "Active Persona", + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 2)); + + const deletedEntry = await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "user", + meta: { + personaSnapshot: { + id: "persona-deleted", + name: "Deleted Persona", + }, + }, + }); + + const db = await initDb(); + db.update(chatEntries) + .set({ + softDeleted: true, + softDeletedAt: new Date("2026-03-06T12:00:10.000Z"), + softDeletedBy: "user", + }) + .where(eq(chatEntries.entryId, deletedEntry.entry.entryId)) + .run(); + + const result = await getChatEntries({ + chatId: fixture.chatId, + query: { + limit: 50, + }, + }); + + expect(result.lastSelectedPersonaId).toBe("persona-active"); + }); + + test("getChatEntries returns null for lastSelectedPersonaId when user entries have no persona snapshot", async () => { + const fixture = await seedChatFixture(); + await createEntry({ + chatId: fixture.chatId, + branchId: fixture.branchId, + role: "user", + meta: { + requestId: "req-no-persona", + }, + }); + + const result = await getChatEntries({ + chatId: fixture.chatId, + query: { + limit: 50, + }, + }); + + expect(result.lastSelectedPersonaId).toBeNull(); + }); + test("getChatEntries ignores greeting rerender failures", async () => { const fixture = await seedChatFixture(); const entry = await createEntry({ diff --git a/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts b/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts index c113123d..718987f0 100644 --- a/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts +++ b/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts @@ -1,6 +1,7 @@ import path from "node:path"; -import { beforeEach, afterEach, describe, expect, test, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ buildInstructionRenderContext: vi.fn(), @@ -31,13 +32,11 @@ vi.mock("../../../services/chat-core/generations-repository", async () => { }; }); -import { eq } from "drizzle-orm"; - -import { resetDbForTests, initDb } from "../../../db/client"; import { applyMigrations } from "../../../db/apply-migrations"; +import { resetDbForTests, initDb } from "../../../db/client"; import { chatBranches, chatEntries, chats, entityProfiles, entryVariants, variantParts } from "../../../db/schema"; import { createTempDataDir, removeTempDataDir } from "../../../e2e/helpers/tmp-dir"; -import { createEntryWithVariant, getEntryById } from "../../../services/chat-entry-parts/entries-repository"; +import { createEntryWithVariant } from "../../../services/chat-entry-parts/entries-repository"; import * as partsRepository from "../../../services/chat-entry-parts/parts-repository"; import { listEntryVariants } from "../../../services/chat-entry-parts/variants-repository"; diff --git a/server/src/application/chat-runtime/use-cases/continue-generation.ts b/server/src/application/chat-runtime/use-cases/continue-generation.ts index 08a08149..2d1ad3ec 100644 --- a/server/src/application/chat-runtime/use-cases/continue-generation.ts +++ b/server/src/application/chat-runtime/use-cases/continue-generation.ts @@ -1,5 +1,6 @@ import { HttpError } from "@core/middleware/error-handler"; +import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; import { getBranchCurrentTurn, incrementBranchTurn } from "../../../services/chat-entry-parts/branch-turn-repository"; import { @@ -8,13 +9,11 @@ import { listEntries, } from "../../../services/chat-entry-parts/entries-repository"; import { createPart } from "../../../services/chat-entry-parts/parts-repository"; -import { withDbTransaction } from "../../../db/client"; - +import { resolveContinueUserTurnTarget } from "../chat-entry-helpers"; import { createAssistantReasoningPart, finalizeChatGenerationArtifacts, } from "../chat-generation-helpers"; -import { resolveContinueUserTurnTarget } from "../chat-entry-helpers"; import { buildChatGenerationSession } from "../generation-session"; import type { ChatGenerationSession } from "../contracts"; diff --git a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts index 505334bf..dc69a0f8 100644 --- a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts +++ b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts @@ -1,21 +1,20 @@ import { HttpError } from "@core/middleware/error-handler"; +import { withDbTransaction } from "../../../db/client"; +import { getChatById } from "../../../services/chat-core/chats-repository"; import { buildInstructionRenderContext, resolveAndApplyWorldInfoToTemplateContext, } from "../../../services/chat-core/prompt-template-context"; import { getSelectedUserPerson } from "../../../services/chat-core/user-persons-repository"; -import { getChatById } from "../../../services/chat-core/chats-repository"; import { getBranchCurrentTurn, incrementBranchTurn } from "../../../services/chat-entry-parts/branch-turn-repository"; import { createEntryWithVariant } from "../../../services/chat-entry-parts/entries-repository"; import { createPart } from "../../../services/chat-entry-parts/parts-repository"; -import { withDbTransaction } from "../../../db/client"; - +import { buildUserEntryMeta, renderUserInputWithLiquid } from "../chat-entry-helpers"; import { createAssistantReasoningPart, finalizeChatGenerationArtifacts, } from "../chat-generation-helpers"; -import { buildUserEntryMeta, renderUserInputWithLiquid } from "../chat-entry-helpers"; import { buildChatGenerationSession } from "../generation-session"; import type { ChatGenerationSession } from "../contracts"; diff --git a/server/src/application/chat-runtime/use-cases/get-chat-entries.ts b/server/src/application/chat-runtime/use-cases/get-chat-entries.ts index 6620e671..4a04d761 100644 --- a/server/src/application/chat-runtime/use-cases/get-chat-entries.ts +++ b/server/src/application/chat-runtime/use-cases/get-chat-entries.ts @@ -1,21 +1,22 @@ import { HttpError } from "@core/middleware/error-handler"; -import { - buildEntryPromptUsage, - buildPromptApproxTokensByEntryId, - PROMPT_USAGE_HISTORY_LIMIT, - type EntryPromptUsage, -} from "../chat-entry-helpers"; import { getChatById } from "../../../services/chat-core/chats-repository"; import { rerenderGreetingTemplatesIfPreplay } from "../../../services/chat-core/greeting-template-rerender"; import { getBranchCurrentTurn } from "../../../services/chat-entry-parts/branch-turn-repository"; import { + getLatestSelectedPersonaIdForChatBranch, listEntriesWithActiveVariants, listEntriesWithActiveVariantsPage, type EntriesPageInfo, } from "../../../services/chat-entry-parts/entries-repository"; -import { serializePart } from "../../../services/chat-entry-parts/prompt-serializers"; import { getPromptProjectionWithEntryIds } from "../../../services/chat-entry-parts/projection"; +import { serializePart } from "../../../services/chat-entry-parts/prompt-serializers"; +import { + buildEntryPromptUsage, + buildPromptApproxTokensByEntryId, + PROMPT_USAGE_HISTORY_LIMIT, + type EntryPromptUsage, +} from "../chat-entry-helpers"; import type { Entry, Variant } from "@shared/types/chat-entry-parts"; @@ -33,6 +34,7 @@ export type GetChatEntriesInput = { export type GetChatEntriesResult = { branchId: string; currentTurn: number; + lastSelectedPersonaId: string | null; entries: Array<{ entry: Entry; variant: Variant | null; @@ -72,12 +74,18 @@ export async function getChatEntries( cursorEntryId: params.query.cursorEntryId, }); - const currentTurn = await getBranchCurrentTurn({ branchId }); - const promptWindowEntries = await listEntriesWithActiveVariants({ - chatId: params.chatId, - branchId, - limit: PROMPT_USAGE_HISTORY_LIMIT, - }); + const [currentTurn, promptWindowEntries, lastSelectedPersonaId] = await Promise.all([ + getBranchCurrentTurn({ branchId }), + listEntriesWithActiveVariants({ + chatId: params.chatId, + branchId, + limit: PROMPT_USAGE_HISTORY_LIMIT, + }), + getLatestSelectedPersonaIdForChatBranch({ + chatId: params.chatId, + branchId, + }), + ]); const promptProjected = getPromptProjectionWithEntryIds({ entries: promptWindowEntries, currentTurn, @@ -88,6 +96,7 @@ export async function getChatEntries( return { branchId, currentTurn, + lastSelectedPersonaId, entries: page.entries.map((item) => ({ ...item, promptUsage: buildEntryPromptUsage({ diff --git a/server/src/application/chat-runtime/use-cases/get-latest-world-info-activations.ts b/server/src/application/chat-runtime/use-cases/get-latest-world-info-activations.ts index 1d84d5c8..01a79eb4 100644 --- a/server/src/application/chat-runtime/use-cases/get-latest-world-info-activations.ts +++ b/server/src/application/chat-runtime/use-cases/get-latest-world-info-activations.ts @@ -1,11 +1,11 @@ import { HttpError } from "@core/middleware/error-handler"; +import { getChatById } from "../../../services/chat-core/chats-repository"; +import { getLatestGenerationByChatBranchWithDebug } from "../../../services/chat-core/generations-repository"; import { buildLatestWorldInfoActivationsFromGeneration, emptyLatestWorldInfoActivationsResponse, } from "../chat-entry-helpers"; -import { getChatById } from "../../../services/chat-core/chats-repository"; -import { getLatestGenerationByChatBranchWithDebug } from "../../../services/chat-core/generations-repository"; import type { LatestWorldInfoActivationsResponse } from "../chat-entry-helpers"; diff --git a/server/src/application/chat-runtime/use-cases/get-prompt-diagnostics.ts b/server/src/application/chat-runtime/use-cases/get-prompt-diagnostics.ts index 68a612f1..634cac9e 100644 --- a/server/src/application/chat-runtime/use-cases/get-prompt-diagnostics.ts +++ b/server/src/application/chat-runtime/use-cases/get-prompt-diagnostics.ts @@ -5,7 +5,6 @@ import { } from "../../../services/chat-core/generations-repository"; import { getEntryById } from "../../../services/chat-entry-parts/entries-repository"; import { getVariantById } from "../../../services/chat-entry-parts/variants-repository"; - import { buildPromptDiagnosticsFromDebug, buildPromptDiagnosticsFromSnapshot, diff --git a/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts b/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts index e063b5b3..837d22b4 100644 --- a/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts +++ b/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts @@ -1,10 +1,11 @@ import { HttpError } from "@core/middleware/error-handler"; +import { withDbTransaction } from "../../../db/client"; +import { getChatById } from "../../../services/chat-core/chats-repository"; import { buildInstructionRenderContext, resolveAndApplyWorldInfoToTemplateContext, } from "../../../services/chat-core/prompt-template-context"; -import { getChatById } from "../../../services/chat-core/chats-repository"; import { getBranchCurrentTurn } from "../../../services/chat-entry-parts/branch-turn-repository"; import { getActiveVariantWithParts, @@ -12,8 +13,6 @@ import { updateEntryMeta, } from "../../../services/chat-entry-parts/entries-repository"; import { applyManualEditToPart } from "../../../services/chat-entry-parts/parts-repository"; -import { withDbTransaction } from "../../../db/client"; - import { isRecord, renderUserInputWithLiquid, diff --git a/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts b/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts index 79c943a3..8f02e86c 100644 --- a/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts +++ b/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts @@ -1,5 +1,6 @@ import { HttpError } from "@core/middleware/error-handler"; +import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; import { getBranchCurrentTurn, @@ -8,8 +9,6 @@ import { import { getEntryById } from "../../../services/chat-entry-parts/entries-repository"; import { createPart } from "../../../services/chat-entry-parts/parts-repository"; import { selectActiveVariant } from "../../../services/chat-entry-parts/variants-repository"; -import { withDbTransaction } from "../../../db/client"; - import { createAssistantReasoningPart, createDetachedGenerationVariant, diff --git a/server/src/application/chat-runtime/use-cases/set-entry-prompt-visibility.ts b/server/src/application/chat-runtime/use-cases/set-entry-prompt-visibility.ts index c9a602ed..d1b3ec01 100644 --- a/server/src/application/chat-runtime/use-cases/set-entry-prompt-visibility.ts +++ b/server/src/application/chat-runtime/use-cases/set-entry-prompt-visibility.ts @@ -1,7 +1,6 @@ import { HttpError } from "@core/middleware/error-handler"; import { getEntryById, updateEntryMeta } from "../../../services/chat-entry-parts/entries-repository"; - import { mergeEntryPromptVisibilityMeta } from "../chat-entry-helpers"; export type SetEntryPromptVisibilityInput = { diff --git a/server/src/application/chat-runtime/use-cases/undo-part-canonicalization.ts b/server/src/application/chat-runtime/use-cases/undo-part-canonicalization.ts index f138b2f6..055da6c6 100644 --- a/server/src/application/chat-runtime/use-cases/undo-part-canonicalization.ts +++ b/server/src/application/chat-runtime/use-cases/undo-part-canonicalization.ts @@ -3,7 +3,6 @@ import { HttpError } from "@core/middleware/error-handler"; import { withDbTransaction } from "../../../db/client"; import { getPartWithVariantContextById, softDeletePart } from "../../../services/chat-entry-parts/parts-repository"; import { listEntryVariants } from "../../../services/chat-entry-parts/variants-repository"; - import { isCanonicalizationPart, resolveActiveUndoCascade, diff --git a/server/src/application/entity-profiles/use-cases/create-chat-from-entity-profile.ts b/server/src/application/entity-profiles/use-cases/create-chat-from-entity-profile.ts index b9c2839a..a48d5691 100644 --- a/server/src/application/entity-profiles/use-cases/create-chat-from-entity-profile.ts +++ b/server/src/application/entity-profiles/use-cases/create-chat-from-entity-profile.ts @@ -1,6 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; -import { renderGreetingTemplateSinglePass } from "../greeting-template"; import { createChat, createImportedAssistantMessage, @@ -17,6 +16,7 @@ import { createVariant, updateVariantDerived, } from "../../../services/chat-entry-parts/variants-repository"; +import { renderGreetingTemplateSinglePass } from "../greeting-template"; export type CreateChatFromEntityProfileInput = { entityProfileId: string; diff --git a/server/src/application/operations/use-cases/import-operation-profiles.ts b/server/src/application/operations/use-cases/import-operation-profiles.ts index 5d1e071a..f3aff114 100644 --- a/server/src/application/operations/use-cases/import-operation-profiles.ts +++ b/server/src/application/operations/use-cases/import-operation-profiles.ts @@ -1,9 +1,9 @@ -import { validateOperationProfileImport } from "../../../services/operations/operation-profile-validator"; import { createOperationBlock, listOperationBlocks, resolveImportedOperationBlockName, } from "../../../services/operations/operation-blocks-repository"; +import { validateOperationProfileImport } from "../../../services/operations/operation-profile-validator"; import { createOperationProfile, listOperationProfiles, diff --git a/server/src/application/operations/use-cases/set-active-operation-profile.ts b/server/src/application/operations/use-cases/set-active-operation-profile.ts index 2f3c9dd2..65471d5d 100644 --- a/server/src/application/operations/use-cases/set-active-operation-profile.ts +++ b/server/src/application/operations/use-cases/set-active-operation-profile.ts @@ -1,7 +1,7 @@ import { HttpError } from "@core/middleware/error-handler"; -import { getOperationProfileById } from "../../../services/operations/operation-profiles-repository"; import { setActiveOperationProfile } from "../../../services/operations/operation-profile-settings-repository"; +import { getOperationProfileById } from "../../../services/operations/operation-profiles-repository"; export async function setActiveOperationProfileWithValidation( activeProfileId: string | null diff --git a/server/src/application/world-info/use-cases/replace-world-info-bindings.ts b/server/src/application/world-info/use-cases/replace-world-info-bindings.ts index ae34581a..d9cd84d1 100644 --- a/server/src/application/world-info/use-cases/replace-world-info-bindings.ts +++ b/server/src/application/world-info/use-cases/replace-world-info-bindings.ts @@ -4,6 +4,7 @@ import { getWorldInfoBooksByIds, replaceWorldInfoBindings, } from "../../../services/world-info/world-info-repositories"; + import type { worldInfoBindingRoles, worldInfoScopes } from "../../../services/world-info/world-info-types"; export async function replaceWorldInfoBindingsWithValidation(params: { diff --git a/server/src/chat-core/schemas.instructions.test.ts b/server/src/chat-core/schemas.instructions.test.ts new file mode 100644 index 00000000..df1086a1 --- /dev/null +++ b/server/src/chat-core/schemas.instructions.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "vitest"; + +import { + createInstructionBodySchema, + updateInstructionBodySchema, +} from "./schemas"; + +describe("instruction schemas", () => { + test("accepts basic instruction payload", () => { + const parsed = createInstructionBodySchema.safeParse({ + name: "Basic", + kind: "basic", + templateText: "{{char.name}}", + }); + + expect(parsed.success).toBe(true); + }); + + test("accepts st_base instruction payload", () => { + const parsed = createInstructionBodySchema.safeParse({ + name: "ST", + kind: "st_base", + stBase: { + rawPreset: {}, + prompts: [ + { + identifier: "main", + content: "Hello", + injection_position: 1, + injection_depth: 2, + injection_order: 50, + }, + ], + promptOrder: [ + { + character_id: 100001, + order: [{ identifier: "main", enabled: true }], + }, + ], + responseConfig: {}, + importInfo: { + source: "sillytavern", + fileName: "Default.json", + importedAt: "2026-03-10T00:00:00.000Z", + }, + }, + }); + + expect(parsed.success).toBe(true); + }); + + test("rejects invalid prompt injection metadata", () => { + const parsed = createInstructionBodySchema.safeParse({ + name: "ST", + kind: "st_base", + stBase: { + rawPreset: {}, + prompts: [ + { + identifier: "main", + content: "Hello", + injection_position: 2, + injection_depth: -1, + injection_order: -5, + }, + ], + promptOrder: [ + { + character_id: 100001, + order: [{ identifier: "main", enabled: true }], + }, + ], + responseConfig: {}, + importInfo: { + source: "sillytavern", + fileName: "Default.json", + importedAt: "2026-03-10T00:00:00.000Z", + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + + test("rejects mixed create payload", () => { + const parsed = createInstructionBodySchema.safeParse({ + name: "Bad", + kind: "st_base", + templateText: "{{char.name}}", + stBase: { + rawPreset: {}, + prompts: [], + promptOrder: [], + responseConfig: {}, + importInfo: { + source: "sillytavern", + fileName: "Default.json", + importedAt: "2026-03-10T00:00:00.000Z", + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + + test("rejects update payload that omits stBase for st_base-only fields", () => { + const parsed = updateInstructionBodySchema.safeParse({ + kind: "basic", + stBase: { + rawPreset: {}, + prompts: [], + promptOrder: [], + responseConfig: {}, + importInfo: { + source: "sillytavern", + fileName: "Default.json", + importedAt: "2026-03-10T00:00:00.000Z", + }, + }, + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/server/src/chat-core/schemas.ts b/server/src/chat-core/schemas.ts index acce3557..8e510575 100644 --- a/server/src/chat-core/schemas.ts +++ b/server/src/chat-core/schemas.ts @@ -7,6 +7,7 @@ export const idSchema = z.string().min(1); export const ownerIdSchema = z.string().min(1).default("global"); export const jsonValueSchema: z.ZodType = z.unknown(); +export const jsonRecordSchema = z.record(z.string(), z.unknown()); export const messageRoleSchema = z.enum(["user", "assistant", "system"]); @@ -108,20 +109,103 @@ export const listInstructionsQuerySchema = z.object({ ownerId: ownerIdSchema.optional(), }); -export const createInstructionBodySchema = z.object({ +const stBasePromptRoleSchema = z.enum(["system", "user", "assistant"]); + +const stBasePromptSchema = z.object({ + identifier: z.string().min(1), + name: z.string().min(1).optional(), + role: stBasePromptRoleSchema.optional(), + content: z.string().optional(), + system_prompt: z.boolean().optional(), + marker: z.boolean().optional(), + injection_position: z.union([z.literal(0), z.literal(1)]).optional(), + injection_depth: z.number().int().min(0).optional(), + injection_order: z.number().int().min(0).optional(), +}).strict(); + +const stBasePromptOrderEntrySchema = z.object({ + identifier: z.string().min(1), + enabled: z.boolean(), +}).strict(); + +const stBasePromptOrderSchema = z.object({ + character_id: z.number().int(), + order: z.array(stBasePromptOrderEntrySchema), +}).strict(); + +const stBaseResponseConfigSchema = z.object({ + temperature: z.number().optional(), + top_p: z.number().optional(), + top_k: z.number().optional(), + top_a: z.number().optional(), + min_p: z.number().optional(), + repetition_penalty: z.number().optional(), + frequency_penalty: z.number().optional(), + presence_penalty: z.number().optional(), + openai_max_tokens: z.number().int().positive().optional(), + seed: z.number().optional(), + n: z.number().int().positive().optional(), + reasoning_effort: z.string().min(1).optional(), + verbosity: z.string().min(1).optional(), + enable_web_search: z.boolean().optional(), + stream_openai: z.boolean().optional(), +}).strict(); + +export const stBaseConfigSchema = z.object({ + rawPreset: jsonRecordSchema, + prompts: z.array(stBasePromptSchema), + promptOrder: z.array(stBasePromptOrderSchema), + responseConfig: stBaseResponseConfigSchema, + importInfo: z.object({ + source: z.literal("sillytavern"), + fileName: z.string().min(1), + importedAt: z.string().min(1), + }).strict(), +}).strict(); + +const createBasicInstructionBodySchema = z.object({ ownerId: ownerIdSchema.optional(), name: z.string().min(1), + kind: z.literal("basic"), engine: z.literal("liquidjs").optional().default("liquidjs"), templateText: z.string().min(1), - meta: jsonValueSchema.optional(), -}); + meta: jsonRecordSchema.optional(), +}).strict(); + +const createStBaseInstructionBodySchema = z.object({ + ownerId: ownerIdSchema.optional(), + name: z.string().min(1), + kind: z.literal("st_base"), + engine: z.literal("liquidjs").optional().default("liquidjs"), + stBase: stBaseConfigSchema, + meta: jsonRecordSchema.optional(), +}).strict(); -export const updateInstructionBodySchema = z.object({ +export const createInstructionBodySchema = z.discriminatedUnion("kind", [ + createBasicInstructionBodySchema, + createStBaseInstructionBodySchema, +]); + +const updateBasicInstructionBodySchema = z.object({ + kind: z.literal("basic"), name: z.string().min(1).optional(), engine: z.literal("liquidjs").optional(), templateText: z.string().min(1).optional(), - meta: jsonValueSchema.optional(), -}); + meta: jsonRecordSchema.optional(), +}).strict(); + +const updateStBaseInstructionBodySchema = z.object({ + kind: z.literal("st_base"), + name: z.string().min(1).optional(), + engine: z.literal("liquidjs").optional(), + stBase: stBaseConfigSchema.optional(), + meta: jsonRecordSchema.optional(), +}).strict(); + +export const updateInstructionBodySchema = z.discriminatedUnion("kind", [ + updateBasicInstructionBodySchema, + updateStBaseInstructionBodySchema, +]); // ---- Variants (minimal for v1 endpoints later) diff --git a/server/src/core/middleware/error-handler.ts b/server/src/core/middleware/error-handler.ts index e8bc863a..3f8979b6 100644 --- a/server/src/core/middleware/error-handler.ts +++ b/server/src/core/middleware/error-handler.ts @@ -2,6 +2,7 @@ import { type NextFunction, type Request, type Response } from "express"; import { type ApiErrorBody } from "@core/http/response"; import { type Logger } from "@core/types/common"; + import { getRequestContext } from "../request-context/request-context"; export class HttpError extends Error { diff --git a/server/src/core/middleware/request-lifecycle-logger.ts b/server/src/core/middleware/request-lifecycle-logger.ts index 2b8fb373..263e8ab6 100644 --- a/server/src/core/middleware/request-lifecycle-logger.ts +++ b/server/src/core/middleware/request-lifecycle-logger.ts @@ -1,7 +1,8 @@ +import { structuredLogger } from "../logging/structured-logger"; +import { getRequestContext } from "../request-context/request-context"; + import type { RequestHandler } from "express"; -import { getRequestContext } from "../request-context/request-context"; -import { structuredLogger } from "../logging/structured-logger"; export const requestLifecycleLogger: RequestHandler = (req, res, next) => { const startedAt = Date.now(); diff --git a/server/src/core/operation-orchestrator/executor.ts b/server/src/core/operation-orchestrator/executor.ts index f7bfa987..1c737dda 100644 --- a/server/src/core/operation-orchestrator/executor.ts +++ b/server/src/core/operation-orchestrator/executor.ts @@ -1,4 +1,5 @@ import { OrchestratorError } from "./errors"; +import { isTaskSkipError } from "./skip"; import { abortReasonToString, createSafeEventEmitter, @@ -30,6 +31,13 @@ type ExecutePlanArgs = { type TaskCompletion = | { taskId: TaskId; status: "done"; startedAt: number; finishedAt: number; result: unknown } + | { + taskId: TaskId; + status: "skipped"; + startedAt: number; + finishedAt: number; + reason: "runtime_condition"; + } | { taskId: TaskId; status: "error"; @@ -249,6 +257,16 @@ export async function executeOrchestratorPlan(args: ExecutePlanArgs): Promise = { taskId: TaskId; @@ -135,4 +136,3 @@ export type OrchestratorRunOptions = { now?: () => number; classifyAbortError?: (error: unknown, ctx: { signal: AbortSignal }) => boolean; }; - diff --git a/server/src/core/request-context/request-context.ts b/server/src/core/request-context/request-context.ts index 5e4bd2d4..c06c3ed2 100644 --- a/server/src/core/request-context/request-context.ts +++ b/server/src/core/request-context/request-context.ts @@ -19,11 +19,9 @@ export type RequestContext = { }; }; -declare global { - namespace Express { - interface Request { - context?: RequestContext; - } +declare module "express-serve-static-core" { + interface Request { + context?: RequestContext; } } diff --git a/server/src/core/services/base-service.ts b/server/src/core/services/base-service.ts index ecc23a92..f208930c 100644 --- a/server/src/core/services/base-service.ts +++ b/server/src/core/services/base-service.ts @@ -1,7 +1,7 @@ import fs from "fs/promises"; +import { randomUUID as uuidv4 } from "node:crypto"; import path from "path"; -import { randomUUID as uuidv4 } from "node:crypto"; import { HttpError } from "@core/middleware/error-handler"; import { type BaseEntity, type Logger, type ServiceOptions } from "@core/types/common"; diff --git a/server/src/db/ensure-instructions-schema.ts b/server/src/db/ensure-instructions-schema.ts index ac7d68a5..0f69fe23 100644 --- a/server/src/db/ensure-instructions-schema.ts +++ b/server/src/db/ensure-instructions-schema.ts @@ -61,8 +61,10 @@ export async function ensureInstructionsSchema(): Promise { "`id` text PRIMARY KEY NOT NULL," + "`owner_id` text DEFAULT 'global' NOT NULL," + "`name` text NOT NULL," + + "`kind` text DEFAULT 'basic' NOT NULL," + "`engine` text DEFAULT 'liquidjs' NOT NULL," + "`template_text` text NOT NULL," + + "`st_base_json` text," + "`meta_json` text," + "`created_at` integer NOT NULL," + "`updated_at` integer NOT NULL" + @@ -82,6 +84,50 @@ export async function ensureInstructionsSchema(): Promise { await db.run(sql.raw("DROP INDEX IF EXISTS `prompt_templates_owner_updated_at_idx`;")); } + if (!(await columnExists("instructions", "kind"))) { + await db.run( + sql.raw( + "ALTER TABLE `instructions` ADD COLUMN `kind` text DEFAULT 'basic' NOT NULL;" + ) + ); + } + + if (!(await columnExists("instructions", "st_base_json"))) { + await db.run( + sql.raw("ALTER TABLE `instructions` ADD COLUMN `st_base_json` text;") + ); + } + + await db.run( + sql.raw( + "UPDATE `instructions` " + + "SET `kind` = CASE " + + "WHEN json_valid(`meta_json`) AND json_extract(`meta_json`, '$.tsInstruction.mode') = 'st_advanced' THEN 'st_base' " + + "WHEN `kind` IS NULL OR trim(`kind`) = '' THEN 'basic' " + + "ELSE `kind` END;" + ) + ); + + await db.run( + sql.raw( + "UPDATE `instructions` " + + "SET `st_base_json` = CASE " + + "WHEN `st_base_json` IS NOT NULL THEN `st_base_json` " + + "WHEN json_valid(`meta_json`) AND json_type(`meta_json`, '$.stBase') IS NOT NULL THEN json_extract(`meta_json`, '$.stBase') " + + "WHEN json_valid(`meta_json`) AND json_type(`meta_json`, '$.tsInstruction.stAdvanced') IS NOT NULL THEN json_extract(`meta_json`, '$.tsInstruction.stAdvanced') " + + "ELSE `st_base_json` END;" + ) + ); + + await db.run( + sql.raw( + "UPDATE `instructions` " + + "SET `meta_json` = CASE " + + "WHEN json_valid(`meta_json`) AND json_type(`meta_json`, '$.tsInstruction') IS NOT NULL THEN json_remove(`meta_json`, '$.tsInstruction') " + + "ELSE `meta_json` END;" + ) + ); + const hasInstructionId = await columnExists("chats", "instruction_id"); const hasPromptTemplateId = await columnExists("chats", "prompt_template_id"); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 01941818..06e4836f 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -5,8 +5,10 @@ export * from "./schema/instructions"; export * from "./schema/operation-profiles"; export * from "./schema/llm"; export * from "./schema/rag"; +export * from "./schema/app-backgrounds"; export * from "./schema/ui"; export * from "./schema/ui-theme"; export * from "./schema/user-persons"; export * from "./schema/world-info"; +export * from "./schema/chat-knowledge"; diff --git a/server/src/db/schema/app-backgrounds.ts b/server/src/db/schema/app-backgrounds.ts new file mode 100644 index 00000000..b60b1b46 --- /dev/null +++ b/server/src/db/schema/app-backgrounds.ts @@ -0,0 +1,9 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const uiAppBackgrounds = sqliteTable("ui_app_backgrounds", { + id: text("id").primaryKey(), + name: text("name").notNull(), + fileName: text("file_name").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), +}); diff --git a/server/src/db/schema/chat-knowledge.ts b/server/src/db/schema/chat-knowledge.ts new file mode 100644 index 00000000..fe0b1cbe --- /dev/null +++ b/server/src/db/schema/chat-knowledge.ts @@ -0,0 +1,188 @@ +import { sql } from "drizzle-orm"; +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +import { chatBranches, chats } from "./chat-core"; + +export const knowledgeCollections = sqliteTable( + "knowledge_collections", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + chatId: text("chat_id") + .notNull() + .references(() => chats.id, { onDelete: "cascade" }), + branchId: text("branch_id").references(() => chatBranches.id, { onDelete: "cascade" }), + scope: text("scope", { enum: ["chat", "branch"] }).notNull(), + name: text("name").notNull(), + kind: text("kind"), + description: text("description"), + status: text("status", { enum: ["active", "archived", "deleted"] }).notNull().default("active"), + origin: text("origin", { + enum: ["import", "author", "system_seed", "user", "llm"], + }) + .notNull() + .default("author"), + layer: text("layer", { enum: ["baseline", "runtime"] }).notNull().default("baseline"), + metaJson: text("meta_json"), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (t) => ({ + ownerChatUpdatedAtIdx: index("knowledge_collections_owner_chat_updated_at_idx").on( + t.ownerId, + t.chatId, + t.updatedAt + ), + ownerChatBranchNameUnique: uniqueIndex("knowledge_collections_owner_chat_branch_name_uq").on( + t.ownerId, + t.chatId, + t.branchId, + t.name + ), + }) +); + +export const knowledgeRecords = sqliteTable( + "knowledge_records", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + chatId: text("chat_id") + .notNull() + .references(() => chats.id, { onDelete: "cascade" }), + branchId: text("branch_id").references(() => chatBranches.id, { onDelete: "cascade" }), + collectionId: text("collection_id") + .notNull() + .references(() => knowledgeCollections.id, { onDelete: "cascade" }), + recordType: text("record_type").notNull(), + key: text("key").notNull(), + title: text("title").notNull(), + aliasesJson: text("aliases_json").notNull().default("[]"), + tagsJson: text("tags_json").notNull().default("[]"), + summary: text("summary"), + contentJson: text("content_json").notNull().default("null"), + searchText: text("search_text").notNull().default(""), + accessMode: text("access_mode", { + enum: ["public", "discoverable", "hidden", "internal"], + }) + .notNull() + .default("public"), + origin: text("origin", { + enum: ["import", "author", "system_seed", "user", "llm"], + }) + .notNull() + .default("author"), + layer: text("layer", { enum: ["baseline", "runtime"] }).notNull().default("baseline"), + derivedFromRecordId: text("derived_from_record_id"), + sourceMessageId: text("source_message_id"), + sourceOperationId: text("source_operation_id"), + status: text("status", { enum: ["active", "archived", "deleted"] }).notNull().default("active"), + gatePolicyJson: text("gate_policy_json"), + metaJson: text("meta_json"), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (t) => ({ + ownerChatBranchUpdatedAtIdx: index("knowledge_records_owner_chat_branch_updated_at_idx").on( + t.ownerId, + t.chatId, + t.branchId, + t.updatedAt + ), + collectionRecordTypeIdx: index("knowledge_records_collection_record_type_idx").on( + t.collectionId, + t.recordType + ), + scopeCollectionKeyUnique: uniqueIndex("knowledge_records_scope_collection_key_uq").on( + t.chatId, + t.branchId, + t.collectionId, + t.key + ), + activeCollectionKeyUnique: uniqueIndex("knowledge_records_active_scope_collection_key_uq") + .on(t.chatId, t.branchId, t.collectionId, t.key) + .where(sql`${t.status} = 'active'`), + }) +); + +export const knowledgeRecordLinks = sqliteTable( + "knowledge_record_links", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + chatId: text("chat_id") + .notNull() + .references(() => chats.id, { onDelete: "cascade" }), + branchId: text("branch_id").references(() => chatBranches.id, { onDelete: "cascade" }), + fromRecordId: text("from_record_id") + .notNull() + .references(() => knowledgeRecords.id, { onDelete: "cascade" }), + relationType: text("relation_type").notNull(), + toRecordId: text("to_record_id") + .notNull() + .references(() => knowledgeRecords.id, { onDelete: "cascade" }), + metaJson: text("meta_json"), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (t) => ({ + ownerChatBranchRelationIdx: index("knowledge_record_links_owner_chat_branch_relation_idx").on( + t.ownerId, + t.chatId, + t.branchId, + t.relationType + ), + fromRelationToUnique: uniqueIndex("knowledge_record_links_from_relation_to_uq").on( + t.fromRecordId, + t.relationType, + t.toRecordId + ), + }) +); + +export const knowledgeRecordAccessState = sqliteTable( + "knowledge_record_access_state", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + chatId: text("chat_id") + .notNull() + .references(() => chats.id, { onDelete: "cascade" }), + branchId: text("branch_id").references(() => chatBranches.id, { onDelete: "cascade" }), + recordId: text("record_id") + .notNull() + .references(() => knowledgeRecords.id, { onDelete: "cascade" }), + discoverState: text("discover_state", { + enum: ["hidden", "discoverable", "visible"], + }) + .notNull() + .default("hidden"), + readState: text("read_state", { enum: ["blocked", "partial", "full"] }) + .notNull() + .default("blocked"), + promptState: text("prompt_state", { enum: ["blocked", "allowed"] }) + .notNull() + .default("blocked"), + revealState: text("reveal_state", { enum: ["hidden", "revealed"] }) + .notNull() + .default("hidden"), + revealedAt: integer("revealed_at", { mode: "timestamp_ms" }), + revealedBy: text("revealed_by", { enum: ["system", "user", "llm", "import"] }), + revealReason: text("reveal_reason"), + flagsJson: text("flags_json").notNull().default("{}"), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (t) => ({ + chatBranchRevealIdx: index("knowledge_record_access_state_chat_branch_reveal_idx").on( + t.chatId, + t.branchId, + t.revealState, + t.updatedAt + ), + scopeRecordUnique: uniqueIndex("knowledge_record_access_state_scope_record_uq").on( + t.chatId, + t.branchId, + t.recordId + ), + }) +); diff --git a/server/src/db/schema/instructions.ts b/server/src/db/schema/instructions.ts index 886048b5..0b8b4d6c 100644 --- a/server/src/db/schema/instructions.ts +++ b/server/src/db/schema/instructions.ts @@ -7,8 +7,10 @@ export const instructions = sqliteTable( ownerId: text("owner_id").notNull().default("global"), name: text("name").notNull(), + kind: text("kind").notNull().default("basic"), engine: text("engine").notNull().default("liquidjs"), templateText: text("template_text").notNull(), + stBaseJson: text("st_base_json"), metaJson: text("meta_json"), createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), diff --git a/server/src/db/schema/ui.ts b/server/src/db/schema/ui.ts index 582b1798..1a911c99 100644 --- a/server/src/db/schema/ui.ts +++ b/server/src/db/schema/ui.ts @@ -13,6 +13,13 @@ export const uiAppSettings = sqliteTable("ui_app_settings", { autoSelectCurrentPersona: integer("auto_select_current_persona", { mode: "boolean" }) .notNull() .default(false), + activeAppBackgroundId: text("active_app_background_id"), + bindChatCompletionPresetToConnection: integer( + "bind_chat_completion_preset_to_connection", + { mode: "boolean" } + ) + .notNull() + .default(false), createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }); diff --git a/server/src/e2e/full-matrix-extra.e2e.spec.ts b/server/src/e2e/full-matrix-extra.e2e.spec.ts index b62f2635..b1664824 100644 --- a/server/src/e2e/full-matrix-extra.e2e.spec.ts +++ b/server/src/e2e/full-matrix-extra.e2e.spec.ts @@ -430,4 +430,73 @@ describe("backend e2e full matrix extra", () => { expectApiSuccess(await requestJson({ baseUrl, method: "GET", path: `/api/entries/${meta.assistantEntryId}/prompt-diagnostics` })); }); + + test("app backgrounds lifecycle", async () => { + const baseUrl = appServer!.baseUrl; + + const initialCatalog = expectApiSuccess<{ + items: Array<{ id: string; source: string; deletable: boolean }>; + activeBackgroundId: string | null; + }>(await requestJson({ baseUrl, method: "GET", path: "/api/app-backgrounds" })); + + expect(initialCatalog.items.some((item) => item.id === "builtin:default")).toBe(true); + expect(initialCatalog.activeBackgroundId).toBe("builtin:default"); + + expectApiError( + await requestJson({ + baseUrl, + method: "DELETE", + path: "/api/app-backgrounds/builtin%3Adefault", + }), + 400, + "VALIDATION_ERROR" + ); + + const uploadForm = new FormData(); + uploadForm.append("image", new Blob([Buffer.from(PNG_1X1_BASE64, "base64")], { type: "image/png" }), "app-bg.png"); + const uploaded = expectApiSuccess<{ id: string; name: string; deletable: boolean }>( + await requestForm({ + baseUrl, + method: "POST", + path: "/api/app-backgrounds/import", + form: uploadForm, + }) + ); + + expect(uploaded.id.startsWith("upload:")).toBe(true); + expect(uploaded.deletable).toBe(true); + + expectApiSuccess( + await requestJson({ + baseUrl, + method: "PUT", + path: "/api/app-backgrounds/active", + body: { activeBackgroundId: uploaded.id }, + }) + ); + + const selectedCatalog = expectApiSuccess<{ + items: Array<{ id: string }>; + activeBackgroundId: string | null; + }>(await requestJson({ baseUrl, method: "GET", path: "/api/app-backgrounds" })); + expect(selectedCatalog.items.some((item) => item.id === uploaded.id)).toBe(true); + expect(selectedCatalog.activeBackgroundId).toBe(uploaded.id); + + const deleted = expectApiSuccess<{ deletedId: string; activeBackgroundId: string | null }>( + await requestJson({ + baseUrl, + method: "DELETE", + path: `/api/app-backgrounds/${encodeURIComponent(uploaded.id)}`, + }) + ); + expect(deleted.deletedId).toBe(uploaded.id); + expect(deleted.activeBackgroundId).toBe("builtin:default"); + + const finalCatalog = expectApiSuccess<{ + items: Array<{ id: string }>; + activeBackgroundId: string | null; + }>(await requestJson({ baseUrl, method: "GET", path: "/api/app-backgrounds" })); + expect(finalCatalog.items.some((item) => item.id === uploaded.id)).toBe(false); + expect(finalCatalog.activeBackgroundId).toBe("builtin:default"); + }); }); diff --git a/server/src/e2e/full-matrix.e2e.spec.ts b/server/src/e2e/full-matrix.e2e.spec.ts index 48106f8b..1b412ef5 100644 --- a/server/src/e2e/full-matrix.e2e.spec.ts +++ b/server/src/e2e/full-matrix.e2e.spec.ts @@ -224,7 +224,7 @@ describe("backend e2e full matrix", () => { baseUrl, method: "POST", path: "/api/instructions", - body: { name: "Matrix instruction", templateText: "System prompt" }, + body: { name: "Matrix instruction", kind: "basic", templateText: "System prompt" }, }); expect(instructionRes.status).toBe(200); expect( @@ -233,7 +233,7 @@ describe("backend e2e full matrix", () => { baseUrl, method: "PUT", path: `/api/instructions/${instructionRes.data.data.id}`, - body: { name: "Matrix instruction updated" }, + body: { kind: "basic", name: "Matrix instruction updated" }, }) ).status ).toBe(200); diff --git a/server/src/e2e/helpers/mock-ai-server.ts b/server/src/e2e/helpers/mock-ai-server.ts index 1461dc13..ad3aa6ec 100644 --- a/server/src/e2e/helpers/mock-ai-server.ts +++ b/server/src/e2e/helpers/mock-ai-server.ts @@ -84,6 +84,7 @@ async function streamScenario(req: Request, res: Response): Promise { const token = getToken(req); if (scenario === "fallback_token_first_fails_second_succeeds" && token === "tok_fail") { + res.setHeader("x-should-retry", "false"); res.status(500).json({ error: { message: "first token failed" }, }); diff --git a/server/src/legacy/README.md b/server/src/legacy/README.md deleted file mode 100644 index d9535a5c..00000000 --- a/server/src/legacy/README.md +++ /dev/null @@ -1,7 +0,0 @@ -## Legacy archive - -This folder contains archived (legacy) server code kept for reference. - -- Not used by the current app runtime -- Excluded from TypeScript build (`server/tsconfig.json`) - diff --git a/server/src/legacy/api/agent-cards.api.ts b/server/src/legacy/api/agent-cards.api.ts deleted file mode 100644 index 21841dcb..00000000 --- a/server/src/legacy/api/agent-cards.api.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { GeneralController } from "@core/factories/controller-factory"; -import { RouteFactory } from "@core/factories/route-factory"; -import { chatService } from "@services/agent-cards.service"; - -const chatController = new GeneralController( - chatService.service, - chatService.settings -); - -const chatRoutes = new RouteFactory({ general: chatController }, "agent-cards"); - -export default chatRoutes.getRouter(); diff --git a/server/src/legacy/api/templates.api.ts b/server/src/legacy/api/templates.api.ts deleted file mode 100644 index 893d21c9..00000000 --- a/server/src/legacy/api/templates.api.ts +++ /dev/null @@ -1,170 +0,0 @@ -import express, { type Request } from "express"; -import { z } from "zod"; - -import { asyncHandler } from "@core/middleware/async-handler"; -import { HttpError } from "@core/middleware/error-handler"; -import { validate } from "@core/middleware/validate"; -import { templatesService } from "@services/templates.service"; -import { - type TemplateSettingsType, - type TemplateType, -} from "@shared/types/templates"; -import { - createPromptTemplate, - deletePromptTemplate, - getPromptTemplateById, - listPromptTemplates, - updatePromptTemplate, -} from "../../services/chat-core/prompt-templates-repository"; - -const router = express.Router(); - -function toTemplateType(dto: { - id: string; - name: string; - templateText: string; - createdAt: Date; - updatedAt: Date; -}): TemplateType { - return { - id: dto.id, - name: dto.name, - template: dto.templateText, - createdAt: dto.createdAt.toISOString(), - updatedAt: dto.updatedAt.toISOString(), - }; -} - -router.get( - "/settings/templates", - asyncHandler(async () => { - const settings = await templatesService.templatesSettings.getConfig(); - return { data: settings }; - }) -); - -router.post( - "/settings/templates", - validate({ - body: z.object({ - selectedId: z.string().nullable(), - enabled: z.boolean(), - }) satisfies z.ZodType, - }), - asyncHandler(async (req: Request) => { - const saved = await templatesService.templatesSettings.saveConfig( - req.body as TemplateSettingsType - ); - return { data: saved }; - }) -); - -router.get( - "/templates", - asyncHandler(async () => { - const items = await listPromptTemplates({ - ownerId: "global", - }); - return { data: items.map(toTemplateType) }; - }) -); - -router.get( - "/templates/:id", - asyncHandler(async (req: Request) => { - const id = String((req.params as unknown as { id: string }).id); - const item = await getPromptTemplateById(id); - if (!item) throw new HttpError(404, "Template не найден", "NOT_FOUND"); - return { data: toTemplateType(item) }; - }) -); - -router.post( - "/templates", - validate({ - body: z.object({ - id: z.string().min(1), - name: z.string().min(1), - template: z.string(), - createdAt: z.string().min(1), - updatedAt: z.string().min(1), - }) satisfies z.ZodType, - }), - asyncHandler(async (req: Request) => { - const body = req.body as TemplateType; - - const created = await createPromptTemplate({ - id: body.id, - ownerId: "global", - name: body.name, - templateText: body.template ?? "", - meta: { legacy: true }, - }); - - const settings = await templatesService.templatesSettings.getConfig(); - await templatesService.templatesSettings.saveConfig({ - ...settings, - selectedId: created.id, - }); - return { data: toTemplateType(created) }; - }) -); - -router.put( - "/templates/:id", - validate({ - body: z.object({ - id: z.string().min(1), - name: z.string().min(1), - template: z.string(), - createdAt: z.string().min(1), - updatedAt: z.string().min(1), - }) satisfies z.ZodType, - }), - asyncHandler(async (req: Request) => { - const params = req.params as unknown as { id: string }; - const body = req.body as TemplateType; - if (params.id !== body.id) { - throw new HttpError( - 400, - "id в path/body должен совпадать", - "VALIDATION_ERROR" - ); - } - - const updated = await updatePromptTemplate({ - id: body.id, - name: body.name, - templateText: body.template ?? "", - }); - if (!updated) throw new HttpError(404, "Template не найден", "NOT_FOUND"); - return { data: toTemplateType(updated) }; - }) -); - -router.delete( - "/templates/:id", - asyncHandler(async (req: Request) => { - const id = String((req.params as unknown as { id: string }).id); - const exists = await getPromptTemplateById(id); - if (!exists) throw new HttpError(404, "Template не найден", "NOT_FOUND"); - - await deletePromptTemplate(id); - - const settings = await templatesService.templatesSettings.getConfig(); - if (settings.selectedId === id) { - const remaining = await listPromptTemplates({ - ownerId: "global", - }); - const nextSelected = remaining[0]?.id ?? null; - await templatesService.templatesSettings.saveConfig({ - ...settings, - selectedId: nextSelected, - }); - } - - return { data: { id } }; - }) -); - -export default router; diff --git a/server/src/legacy/routes/chat-routes.ts b/server/src/legacy/routes/chat-routes.ts deleted file mode 100644 index cdf95978..00000000 --- a/server/src/legacy/routes/chat-routes.ts +++ /dev/null @@ -1,71 +0,0 @@ -import express, { type Request } from "express"; -import { z } from "zod"; - -import { asyncHandler } from "@core/middleware/async-handler"; -import { validate } from "@core/middleware/validate"; - -import { chatService } from "../../services/agent-cards.service"; -import { type Chat } from "../../types"; - -const router = express.Router(); - -const chatIdParamsSchema = z.object({ - chatId: z.string().min(1), -}); - -router.get( - "/chats", - asyncHandler(async () => { - const chats = await chatService.service.getChatList(); - return { data: chats }; - }) -); - -router.get( - "/chats/:chatId", - validate({ params: chatIdParamsSchema }), - asyncHandler(async (req: Request) => { - const chatId = req.params.chatId as unknown as string; - const chat = await chatService.service.getChat(chatId); - return { data: chat }; - }) -); - -router.post( - "/chats", - asyncHandler(async (req: Request) => { - const newChat = await chatService.service.createChat(req.body as Chat); - return { data: newChat }; - }) -); - -router.post( - "/chats/:chatId/duplicate", - validate({ params: chatIdParamsSchema }), - asyncHandler(async (req: Request) => { - const chatId = req.params.chatId as unknown as string; - const duplicatedChat = await chatService.service.duplicateChat(chatId); - return { data: duplicatedChat }; - }) -); - -router.put( - "/chats/:chatId", - validate({ params: chatIdParamsSchema }), - asyncHandler(async (req: Request) => { - const updatedChat = await chatService.service.updateChat(req.body as Chat); - return { data: updatedChat }; - }) -); - -router.delete( - "/chats/:chatId", - validate({ params: chatIdParamsSchema }), - asyncHandler(async (req: Request) => { - const chatId = req.params.chatId as unknown as string; - const deletedChat = await chatService.service.deleteChat(chatId); - return { data: deletedChat }; - }) -); - -export default router; diff --git a/server/src/legacy/services/agent-cards.service.ts b/server/src/legacy/services/agent-cards.service.ts deleted file mode 100644 index c808c2f9..00000000 --- a/server/src/legacy/services/agent-cards.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type AgentCardSettingsType } from "@shared/types/agent-card"; - -import { BaseService } from "@core/services/base-service"; -import { ConfigService } from "@core/services/config-service"; - -import { type Chat } from "../../types"; - -class ChatService extends BaseService { - constructor() { - super("agent-cards", { logger: console }); - } - - async getChatList(): Promise { - return await this.getAll(); - } - - async getChat(chatId: string): Promise { - return await this.getById(chatId); - } - - async createChat(chat: Chat): Promise { - const now = new Date().toISOString(); - - const newChat: Chat = { - ...chat, - id: chat.id || this.createUUID(), - createdAt: chat.createdAt || now, - updatedAt: now, - }; - - return await this.create(newChat); - } - - async updateChat(chat: Chat): Promise { - if (!chat.id) { - throw new Error("chat.id обязателен"); - } - - const now = new Date().toISOString(); - - const updatedChat: Chat = { - ...chat, - updatedAt: now, - createdAt: chat.createdAt || now, - }; - - return await this.update(updatedChat); - } - - async deleteChat(chatId: string): Promise { - return await this.delete(chatId); - } - - async duplicateChat(chatId: string): Promise { - const original = await this.getById(chatId); - const now = new Date().toISOString(); - - const duplicated: Chat = { - ...original, - id: this.createUUID(), - createdAt: now, - updatedAt: now, - }; - - return await this.create(duplicated); - } -} - -class ChatSettings extends ConfigService { - constructor() { - super("agent-cards.json", { logger: console }); - } - - getDefaultConfig(): AgentCardSettingsType { - return { - selectedId: null, - enabled: true, - }; - } -} - -export const chatService = { - service: new ChatService(), - settings: new ChatSettings(), -}; diff --git a/server/src/legacy/services/templates.service.ts b/server/src/legacy/services/templates.service.ts deleted file mode 100644 index cd9d6ef6..00000000 --- a/server/src/legacy/services/templates.service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - type TemplateSettingsType, - type TemplateType, -} from "@shared/types/templates"; - -import { BaseService } from "@core/services/base-service"; -import { ConfigService } from "@core/services/config-service"; - -class Templates extends BaseService { - constructor() { - super("templates"); - } -} - -class TemplatesSettings extends ConfigService { - constructor() { - super("templates-settings.json", { logger: console }); - } - - protected getDefaultConfig(): TemplateSettingsType { - return { - selectedId: null, - enabled: true, - }; - } -} - -export const templatesService = { - templates: new Templates(), - templatesSettings: new TemplatesSettings(), -}; diff --git a/server/src/routes/app-settings-routes.test.ts b/server/src/routes/app-settings-routes.test.ts index 5c58cad8..6e2fc4e6 100644 --- a/server/src/routes/app-settings-routes.test.ts +++ b/server/src/routes/app-settings-routes.test.ts @@ -13,6 +13,7 @@ describe("app settings route schema", () => { language: "en", openLastChat: true, autoSelectCurrentPersona: false, + bindChatCompletionPresetToConnection: true, }); expect(parsed.success).toBe(true); diff --git a/server/src/routes/app-settings-routes.ts b/server/src/routes/app-settings-routes.ts index 2211a745..b33193f8 100644 --- a/server/src/routes/app-settings-routes.ts +++ b/server/src/routes/app-settings-routes.ts @@ -14,6 +14,7 @@ export const appSettingsPatchSchema = z language: z.enum(["ru", "en"]).optional(), openLastChat: z.boolean().optional(), autoSelectCurrentPersona: z.boolean().optional(), + bindChatCompletionPresetToConnection: z.boolean().optional(), }) .strict(); diff --git a/server/src/routes/generate-routes.ts b/server/src/routes/generate-routes.ts index 7eccbfc8..d59866b0 100644 --- a/server/src/routes/generate-routes.ts +++ b/server/src/routes/generate-routes.ts @@ -1,5 +1,6 @@ -import express, { type Request, type Response } from "express"; import { randomUUID as uuidv4 } from "node:crypto"; + +import express, { type Request, type Response } from "express"; import { z } from "zod"; import { asyncHandler } from "@core/middleware/async-handler"; diff --git a/server/src/services/app-backgrounds/app-backgrounds-manifest.ts b/server/src/services/app-backgrounds/app-backgrounds-manifest.ts new file mode 100644 index 00000000..1c0afd1e --- /dev/null +++ b/server/src/services/app-backgrounds/app-backgrounds-manifest.ts @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +import { resolveMonorepoRoot } from "../../config/path-resolver"; + +import type { AppBackgroundAsset } from "@shared/types/app-background"; + +const backgroundManifestSchema = z + .object({ + items: z + .array( + z + .object({ + id: z.string().min(1), + name: z.string().min(1).max(256), + fileName: z.string().min(1), + }) + .strict() + ) + .min(1), + }) + .strict(); + +export function resolveBuiltInBackgroundsDir(monorepoRoot: string = resolveMonorepoRoot()): string { + return path.join(monorepoRoot, "default", "backgrounds"); +} + +function toBuiltInImageUrl(fileName: string): string { + return `/defaults/backgrounds/${encodeURIComponent(fileName)}`; +} + +export async function loadBuiltInAppBackgrounds(params?: { + monorepoRoot?: string; +}): Promise { + const monorepoRoot = params?.monorepoRoot ?? resolveMonorepoRoot(); + const backgroundsDir = resolveBuiltInBackgroundsDir(monorepoRoot); + const manifestPath = path.join(backgroundsDir, "manifest.json"); + const parsed = backgroundManifestSchema.parse( + JSON.parse(await fs.readFile(manifestPath, "utf8")) as unknown + ); + + await Promise.all( + parsed.items.map(async (item) => { + await fs.access(path.join(backgroundsDir, item.fileName)); + }) + ); + + return parsed.items.map((item) => ({ + id: item.id, + name: item.name, + source: "builtin" as const, + imageUrl: toBuiltInImageUrl(item.fileName), + deletable: false, + })); +} diff --git a/server/src/services/app-backgrounds/app-backgrounds-repository.test.ts b/server/src/services/app-backgrounds/app-backgrounds-repository.test.ts new file mode 100644 index 00000000..f89892be --- /dev/null +++ b/server/src/services/app-backgrounds/app-backgrounds-repository.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + loadBuiltInAppBackgrounds, + mergeAppBackgroundAssets, + resolveActiveBackgroundId, +} from "./app-backgrounds-repository"; + +import type { AppBackgroundAsset } from "@shared/types/app-background"; + +describe("app-backgrounds-repository", () => { + test("loads built-in backgrounds from manifest", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "ts-app-backgrounds-")); + const backgroundsDir = path.join(rootDir, "default", "backgrounds"); + + await fs.mkdir(backgroundsDir, { recursive: true }); + await fs.writeFile(path.join(backgroundsDir, "mist.png"), "fixture"); + await fs.writeFile( + path.join(backgroundsDir, "manifest.json"), + JSON.stringify({ + items: [ + { + id: "builtin:mist", + name: "Mist", + fileName: "mist.png", + }, + ], + }) + ); + + await expect(loadBuiltInAppBackgrounds({ monorepoRoot: rootDir })).resolves.toEqual([ + { + id: "builtin:mist", + name: "Mist", + source: "builtin", + imageUrl: "/defaults/backgrounds/mist.png", + deletable: false, + }, + ]); + + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + test("merges built-in assets before uploaded assets", () => { + const builtIns: AppBackgroundAsset[] = [ + { + id: "builtin:mist", + name: "Mist", + source: "builtin", + imageUrl: "/defaults/backgrounds/mist.png", + deletable: false, + }, + ]; + const uploads: AppBackgroundAsset[] = [ + { + id: "upload:123", + name: "User Mist", + source: "uploaded", + imageUrl: "/media/images/app-backgrounds/user-mist.png", + deletable: true, + }, + ]; + + expect(mergeAppBackgroundAssets(builtIns, uploads)).toEqual([...builtIns, ...uploads]); + }); + + test("falls back to first built-in background when active id is missing", () => { + const items: AppBackgroundAsset[] = [ + { + id: "builtin:mist", + name: "Mist", + source: "builtin", + imageUrl: "/defaults/backgrounds/mist.png", + deletable: false, + }, + { + id: "upload:123", + name: "User Mist", + source: "uploaded", + imageUrl: "/media/images/app-backgrounds/user-mist.png", + deletable: true, + }, + ]; + + expect(resolveActiveBackgroundId({ activeBackgroundId: "upload:missing", items })).toBe("builtin:mist"); + }); +}); diff --git a/server/src/services/app-backgrounds/app-backgrounds-repository.ts b/server/src/services/app-backgrounds/app-backgrounds-repository.ts new file mode 100644 index 00000000..fec2db52 --- /dev/null +++ b/server/src/services/app-backgrounds/app-backgrounds-repository.ts @@ -0,0 +1,198 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { eq } from "drizzle-orm"; + +import { resolveSafePath } from "@core/files/safe-path"; +import { HttpError } from "@core/middleware/error-handler"; + +import { initDb } from "../../db/client"; +import { uiAppBackgrounds, uiAppSettings } from "../../db/schema"; +import { createDataPath } from "../../utils"; +import { getAppSettings } from "../app-settings/app-settings-repository"; + +import { loadBuiltInAppBackgrounds } from "./app-backgrounds-manifest"; + +import type { + AppBackgroundActiveSelection, + AppBackgroundAsset, + AppBackgroundCatalog, +} from "@shared/types/app-background"; + +const SETTINGS_ROW_ID = "global"; +const APP_BACKGROUNDS_FOLDER = createDataPath("media", "images", "app-backgrounds"); + +type AppBackgroundRow = typeof uiAppBackgrounds.$inferSelect; + +function rowToAsset(row: AppBackgroundRow): AppBackgroundAsset { + return { + id: row.id, + name: row.name, + source: "uploaded", + imageUrl: `/media/images/app-backgrounds/${encodeURIComponent(row.fileName)}`, + deletable: true, + }; +} + +function resolveUploadedBackgroundName(originalName: string): string { + const baseName = path.basename(originalName, path.extname(originalName)).trim(); + return baseName.length > 0 ? baseName : "Imported background"; +} + +async function ensureSettingsRow(): Promise { + await getAppSettings(); +} + +async function listUploadedBackgrounds(): Promise { + const db = await initDb(); + const rows = await db.select().from(uiAppBackgrounds); + return rows.map(rowToAsset); +} + +async function readStoredActiveBackgroundId(): Promise { + await ensureSettingsRow(); + const db = await initDb(); + const rows = await db + .select({ activeAppBackgroundId: uiAppSettings.activeAppBackgroundId }) + .from(uiAppSettings) + .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)) + .limit(1); + return rows[0]?.activeAppBackgroundId ?? null; +} + +async function persistActiveBackgroundId(activeBackgroundId: string | null): Promise { + await ensureSettingsRow(); + const db = await initDb(); + await db + .update(uiAppSettings) + .set({ + activeAppBackgroundId: activeBackgroundId, + updatedAt: new Date(), + }) + .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)); +} + +export function mergeAppBackgroundAssets( + builtInAssets: AppBackgroundAsset[], + uploadedAssets: AppBackgroundAsset[] +): AppBackgroundAsset[] { + return [...builtInAssets, ...uploadedAssets]; +} + +export function resolveActiveBackgroundId(params: { + activeBackgroundId: string | null; + items: AppBackgroundAsset[]; +}): string | null { + const activeItem = params.activeBackgroundId + ? params.items.find((item) => item.id === params.activeBackgroundId) + : null; + if (activeItem) return activeItem.id; + + const builtIn = params.items.find((item) => item.source === "builtin"); + if (builtIn) return builtIn.id; + + return params.items[0]?.id ?? null; +} + +export { loadBuiltInAppBackgrounds }; + +export async function getAppBackgroundCatalog(): Promise { + const [builtIns, uploads, storedActiveBackgroundId] = await Promise.all([ + loadBuiltInAppBackgrounds(), + listUploadedBackgrounds(), + readStoredActiveBackgroundId(), + ]); + const items = mergeAppBackgroundAssets(builtIns, uploads); + const activeBackgroundId = resolveActiveBackgroundId({ + activeBackgroundId: storedActiveBackgroundId, + items, + }); + + if (activeBackgroundId !== storedActiveBackgroundId) { + await persistActiveBackgroundId(activeBackgroundId); + } + + return { items, activeBackgroundId }; +} + +export async function setAppBackgroundActive( + selection: AppBackgroundActiveSelection +): Promise { + const catalog = await getAppBackgroundCatalog(); + + if ( + selection.activeBackgroundId !== null && + !catalog.items.some((item) => item.id === selection.activeBackgroundId) + ) { + throw new HttpError(404, "App background not found", "NOT_FOUND"); + } + + const activeBackgroundId = resolveActiveBackgroundId({ + activeBackgroundId: selection.activeBackgroundId, + items: catalog.items, + }); + await persistActiveBackgroundId(activeBackgroundId); + return { activeBackgroundId }; +} + +export async function importAppBackground(params: { + fileBuffer: Buffer; + originalName: string; +}): Promise { + await fs.mkdir(APP_BACKGROUNDS_FOLDER, { recursive: true }); + + const extension = path.extname(params.originalName).toLowerCase(); + const filename = `${randomUUID()}${extension}`; + const filePath = resolveSafePath(APP_BACKGROUNDS_FOLDER, filename); + await fs.writeFile(filePath, params.fileBuffer); + + const now = new Date(); + const id = `upload:${randomUUID()}`; + const db = await initDb(); + await db.insert(uiAppBackgrounds).values({ + id, + name: resolveUploadedBackgroundName(params.originalName), + fileName: filename, + createdAt: now, + updatedAt: now, + }); + + return rowToAsset({ + id, + name: resolveUploadedBackgroundName(params.originalName), + fileName: filename, + createdAt: now, + updatedAt: now, + }); +} + +export async function deleteAppBackground(params: { + id: string; +}): Promise { + if (params.id.startsWith("builtin:")) { + throw new HttpError(400, "Built-in backgrounds cannot be deleted", "VALIDATION_ERROR"); + } + + const db = await initDb(); + const rows = await db + .select() + .from(uiAppBackgrounds) + .where(eq(uiAppBackgrounds.id, params.id)) + .limit(1); + const row = rows[0]; + if (!row) { + throw new HttpError(404, "App background not found", "NOT_FOUND"); + } + + await db.delete(uiAppBackgrounds).where(eq(uiAppBackgrounds.id, params.id)); + await fs.rm(resolveSafePath(APP_BACKGROUNDS_FOLDER, row.fileName), { + force: true, + }); + + const catalog = await getAppBackgroundCatalog(); + return { + deletedId: params.id, + activeBackgroundId: catalog.activeBackgroundId, + }; +} diff --git a/server/src/services/app-settings/app-settings-repository.test.ts b/server/src/services/app-settings/app-settings-repository.test.ts index cead9599..3023f0b9 100644 --- a/server/src/services/app-settings/app-settings-repository.test.ts +++ b/server/src/services/app-settings/app-settings-repository.test.ts @@ -5,85 +5,34 @@ import { normalizeLegacyAppSettings, } from "./app-settings-repository"; -describe("app settings legacy normalization", () => { - test("normalizes flat valid object", () => { - const normalized = normalizeLegacyAppSettings({ - language: "en", - openLastChat: true, - autoSelectCurrentPersona: true, - }); - - expect(normalized).toEqual({ - language: "en", - openLastChat: true, - autoSelectCurrentPersona: true, - }); - }); - - test("unwraps recursive data chain and keeps deepest valid values", () => { - const normalized = normalizeLegacyAppSettings({ - language: "ru", - openLastChat: true, - autoSelectCurrentPersona: false, - data: { - language: "en", - openLastChat: false, - autoSelectCurrentPersona: true, - }, - }); - - expect(normalized).toEqual({ - language: "en", - openLastChat: false, - autoSelectCurrentPersona: true, - }); - }); - - test("falls back for partially broken values", () => { - const normalized = normalizeLegacyAppSettings({ - language: "de", - openLastChat: "yes", - autoSelectCurrentPersona: 1, - data: { language: "ru" }, - }); - - expect(normalized).toEqual({ - language: "ru", - openLastChat: false, - autoSelectCurrentPersona: false, +describe("app-settings-repository", () => { + test("normalizes bind setting from legacy payload", () => { + expect( + normalizeLegacyAppSettings({ + data: { + bindChatCompletionPresetToConnection: true, + }, + }) + ).toMatchObject({ + bindChatCompletionPresetToConnection: true, }); }); - test("returns defaults when source is missing or non-object", () => { - expect(normalizeLegacyAppSettings(null)).toEqual({ - language: "ru", - openLastChat: false, - autoSelectCurrentPersona: false, - }); - expect(normalizeLegacyAppSettings("")).toEqual({ - language: "ru", - openLastChat: false, - autoSelectCurrentPersona: false, - }); - }); -}); - -describe("app settings merge", () => { - test("updates only whitelist fields", () => { - const current = { - language: "ru" as const, - openLastChat: false, - autoSelectCurrentPersona: false, - }; - const merged = mergeAppSettings(current, { - language: "en", - openLastChat: true, - } as any); - - expect(merged).toEqual({ - language: "en", - openLastChat: true, - autoSelectCurrentPersona: false, + test("merges bind setting patch", () => { + expect( + mergeAppSettings( + { + language: "ru", + openLastChat: false, + autoSelectCurrentPersona: false, + bindChatCompletionPresetToConnection: false, + }, + { + bindChatCompletionPresetToConnection: true, + } + ) + ).toMatchObject({ + bindChatCompletionPresetToConnection: true, }); }); }); diff --git a/server/src/services/app-settings/app-settings-repository.ts b/server/src/services/app-settings/app-settings-repository.ts index e5f1c3e4..08276c81 100644 --- a/server/src/services/app-settings/app-settings-repository.ts +++ b/server/src/services/app-settings/app-settings-repository.ts @@ -15,6 +15,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { language: "ru", openLastChat: false, autoSelectCurrentPersona: false, + bindChatCompletionPresetToConnection: false, }; function isObjectRecord(value: unknown): value is Record { @@ -52,6 +53,10 @@ export function normalizeLegacyAppSettings(input: unknown): AppSettings { if (typeof item.autoSelectCurrentPersona === "boolean") { result.autoSelectCurrentPersona = item.autoSelectCurrentPersona; } + if (typeof item.bindChatCompletionPresetToConnection === "boolean") { + result.bindChatCompletionPresetToConnection = + item.bindChatCompletionPresetToConnection; + } } return result; @@ -71,6 +76,10 @@ export function mergeAppSettings( typeof patch.autoSelectCurrentPersona === "boolean" ? patch.autoSelectCurrentPersona : current.autoSelectCurrentPersona, + bindChatCompletionPresetToConnection: + typeof patch.bindChatCompletionPresetToConnection === "boolean" + ? patch.bindChatCompletionPresetToConnection + : current.bindChatCompletionPresetToConnection, }; } @@ -79,6 +88,8 @@ function rowToDto(row: typeof uiAppSettings.$inferSelect): AppSettings { language: row.language, openLastChat: row.openLastChat, autoSelectCurrentPersona: row.autoSelectCurrentPersona, + bindChatCompletionPresetToConnection: + row.bindChatCompletionPresetToConnection, }; } @@ -102,6 +113,8 @@ async function insertInitialSettings(settings: AppSettings): Promise { language: settings.language, openLastChat: settings.openLastChat, autoSelectCurrentPersona: settings.autoSelectCurrentPersona, + bindChatCompletionPresetToConnection: + settings.bindChatCompletionPresetToConnection, createdAt: now, updatedAt: now, }) @@ -111,6 +124,8 @@ async function insertInitialSettings(settings: AppSettings): Promise { language: settings.language, openLastChat: settings.openLastChat, autoSelectCurrentPersona: settings.autoSelectCurrentPersona, + bindChatCompletionPresetToConnection: + settings.bindChatCompletionPresetToConnection, updatedAt: now, }, }); @@ -148,6 +163,8 @@ export async function updateAppSettings( language: next.language, openLastChat: next.openLastChat, autoSelectCurrentPersona: next.autoSelectCurrentPersona, + bindChatCompletionPresetToConnection: + next.bindChatCompletionPresetToConnection, createdAt: now, updatedAt: now, }) @@ -157,6 +174,8 @@ export async function updateAppSettings( language: next.language, openLastChat: next.openLastChat, autoSelectCurrentPersona: next.autoSelectCurrentPersona, + bindChatCompletionPresetToConnection: + next.bindChatCompletionPresetToConnection, updatedAt: now, }, }); diff --git a/server/src/services/bundles/bundle-archive.test.ts b/server/src/services/bundles/bundle-archive.test.ts new file mode 100644 index 00000000..5002930a --- /dev/null +++ b/server/src/services/bundles/bundle-archive.test.ts @@ -0,0 +1,56 @@ +import { + createBundleResourceId, + type TaleSpinnerBundle, +} from "@shared/types/bundles"; +import { describe, expect, test } from "vitest"; + +import { decodeBundleArchive, encodeBundleArchive } from "./bundle-archive"; + +describe("bundle archive", () => { + test("round-trips manifest and files through .tsbundle archive", async () => { + const resourceId = createBundleResourceId("entity_profile", "hero"); + const manifest: TaleSpinnerBundle = { + type: "talespinner.bundle", + version: 1, + bundleId: "bundle-1", + createdAt: "2026-03-13T10:00:00.000Z", + container: "archive", + sourceResourceId: resourceId, + resources: [ + { + resourceId, + kind: "entity_profile", + schemaVersion: 1, + role: "primary", + title: "Hero", + payload: { + name: "Hero", + kind: "CharSpec", + spec: { name: "Hero" }, + isFavorite: false, + avatarFile: { + path: "files/hero/avatar.png", + fileName: "avatar.png", + mediaType: "image/png", + }, + }, + }, + ], + }; + + const archive = await encodeBundleArchive({ + manifest, + files: { + "files/hero/avatar.png": { + fileName: "avatar.png", + mediaType: "image/png", + data: Buffer.from([1, 2, 3, 4]), + }, + }, + }); + const decoded = await decodeBundleArchive(archive); + + expect(decoded.manifest).toEqual(manifest); + expect(decoded.files["files/hero/avatar.png"]?.data).toEqual(Buffer.from([1, 2, 3, 4])); + }); +}); diff --git a/server/src/services/bundles/bundle-archive.ts b/server/src/services/bundles/bundle-archive.ts new file mode 100644 index 00000000..c13dce87 --- /dev/null +++ b/server/src/services/bundles/bundle-archive.ts @@ -0,0 +1,89 @@ +import { createHash } from "node:crypto"; +import { promisify } from "node:util"; +import { gunzip, gzip } from "node:zlib"; + +import { parseTaleSpinnerBundle, type TaleSpinnerBundle } from "@shared/types/bundles"; +import { z } from "zod"; + +const gzipAsync = promisify(gzip); +const gunzipAsync = promisify(gunzip); + +const archiveFileSchema = z + .object({ + fileName: z.string().min(1), + mediaType: z.string().min(1), + dataBase64: z.string().min(1), + sha256: z.string().min(1), + }) + .strict(); + +const archivePackageSchema = z + .object({ + manifest: z.unknown(), + files: z.record(z.string(), archiveFileSchema), + }) + .strict(); + +export type BundleArchiveFile = { + fileName: string; + mediaType: string; + data: Buffer; +}; + +export async function encodeBundleArchive(input: { + manifest: TaleSpinnerBundle; + files: Record; +}): Promise { + const files = Object.fromEntries( + Object.entries(input.files).map(([archivePath, file]) => { + const sha256 = createHash("sha256").update(file.data).digest("hex"); + return [ + archivePath, + { + fileName: file.fileName, + mediaType: file.mediaType, + dataBase64: file.data.toString("base64"), + sha256, + }, + ]; + }) + ); + + const payload = Buffer.from( + JSON.stringify({ + manifest: input.manifest, + files, + }), + "utf8" + ); + return gzipAsync(payload); +} + +export async function decodeBundleArchive(buffer: Buffer): Promise<{ + manifest: TaleSpinnerBundle; + files: Record; +}> { + const decodedText = (await gunzipAsync(buffer)).toString("utf8"); + const parsed = archivePackageSchema.parse(JSON.parse(decodedText) as unknown); + const manifest = parseTaleSpinnerBundle(parsed.manifest); + + const files = Object.fromEntries( + Object.entries(parsed.files).map(([archivePath, file]) => { + const data = Buffer.from(file.dataBase64, "base64"); + const sha256 = createHash("sha256").update(data).digest("hex"); + if (sha256 !== file.sha256) { + throw new Error(`Archive file checksum mismatch: ${archivePath}`); + } + return [ + archivePath, + { + fileName: file.fileName, + mediaType: file.mediaType, + data, + }, + ]; + }) + ); + + return { manifest, files }; +} diff --git a/server/src/services/bundles/bundle-contract.test.ts b/server/src/services/bundles/bundle-contract.test.ts new file mode 100644 index 00000000..1ec1739a --- /dev/null +++ b/server/src/services/bundles/bundle-contract.test.ts @@ -0,0 +1,124 @@ +import { + createBundleResourceId, + parseTaleSpinnerBundle, + validateBundleResourceGraph, +} from "@shared/types/bundles"; +import { describe, expect, test } from "vitest"; + +describe("bundle contract", () => { + test("parses valid sampler preset resource", () => { + const samplerResourceId = createBundleResourceId("sampler_preset", "storyteller"); + + const parsed = parseTaleSpinnerBundle({ + type: "talespinner.bundle", + version: 1, + bundleId: "bundle-sampler-1", + createdAt: "2026-03-13T10:00:00.000Z", + container: "json", + sourceResourceId: samplerResourceId, + resources: [ + { + resourceId: samplerResourceId, + kind: "sampler_preset", + schemaVersion: 1, + role: "primary", + title: "Storyteller", + payload: { + name: "Storyteller", + settings: { + temperature: 0.9, + topP: 0.95, + reasoning: { + enabled: true, + effort: "medium", + }, + }, + }, + }, + ], + }); + + expect(parsed.resources).toHaveLength(1); + expect(parsed.resources[0]).toMatchObject({ + kind: "sampler_preset", + payload: { + name: "Storyteller", + }, + }); + expect(() => validateBundleResourceGraph(parsed)).not.toThrow(); + }); + + test("parses valid bundle manifest", () => { + const blockResourceId = createBundleResourceId("operation_block", "scene"); + const profileResourceId = createBundleResourceId("operation_profile", "main"); + + const parsed = parseTaleSpinnerBundle({ + type: "talespinner.bundle", + version: 1, + bundleId: "bundle-1", + createdAt: "2026-03-13T10:00:00.000Z", + container: "json", + sourceResourceId: profileResourceId, + resources: [ + { + resourceId: blockResourceId, + kind: "operation_block", + schemaVersion: 1, + role: "dependency", + title: "Scene block", + payload: { + name: "Scene block", + enabled: true, + operations: [], + }, + }, + { + resourceId: profileResourceId, + kind: "operation_profile", + schemaVersion: 1, + role: "primary", + title: "Main profile", + payload: { + name: "Main profile", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "2d9f1f5c-6f38-4f94-9caa-0ea4f36f2db8", + blockRefs: [{ resourceId: blockResourceId, enabled: true, order: 0 }], + }, + }, + ], + }); + + expect(parsed.type).toBe("talespinner.bundle"); + expect(parsed.resources).toHaveLength(2); + expect(() => validateBundleResourceGraph(parsed)).not.toThrow(); + }); + + test("rejects missing cross-resource references", () => { + const parsed = parseTaleSpinnerBundle({ + type: "talespinner.bundle", + version: 1, + bundleId: "bundle-1", + createdAt: "2026-03-13T10:00:00.000Z", + container: "json", + resources: [ + { + resourceId: createBundleResourceId("operation_profile", "main"), + kind: "operation_profile", + schemaVersion: 1, + role: "primary", + title: "Main profile", + payload: { + name: "Main profile", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "2d9f1f5c-6f38-4f94-9caa-0ea4f36f2db8", + blockRefs: [{ resourceId: "block-local-db-id", enabled: true, order: 0 }], + }, + }, + ], + }); + + expect(() => validateBundleResourceGraph(parsed)).toThrow(/Unknown operation_block resourceId/i); + }); +}); diff --git a/server/src/services/bundles/bundle-import-export.test.ts b/server/src/services/bundles/bundle-import-export.test.ts new file mode 100644 index 00000000..20cfb5f3 --- /dev/null +++ b/server/src/services/bundles/bundle-import-export.test.ts @@ -0,0 +1,323 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { ensureInstructionsSchema } from "../../db/ensure-instructions-schema"; +import { ensureOperationBlocksCutover } from "../../db/ensure-operation-blocks-cutover"; +import { createInstruction } from "../chat-core/instructions-repository"; +import { createOperationBlock, listOperationBlocks } from "../operations/operation-blocks-repository"; +import { createOperationProfile, getOperationProfileById } from "../operations/operation-profiles-repository"; +import { samplersService } from "../samplers.service"; +import { createUiThemePreset } from "../ui-theme/ui-theme-repository"; + +import { exportBundleSelection } from "./export-bundle-selection"; +import { importBundleFile } from "./import-bundle-file"; + +describe("bundle import/export", () => { + let tempDir = ""; + let dbPath = ""; + let prevSamplersDir = ""; + let prevSamplersReady: Promise | null = null; + let prevSamplerConfigPath = ""; + let prevSamplerConfigReady: Promise | null = null; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-bundles-")); + dbPath = path.join(tempDir, "db.sqlite"); + await initDb({ dbPath }); + await applyMigrations(); + await ensureInstructionsSchema(); + await ensureOperationBlocksCutover(); + + const samplerDir = path.join(tempDir, "samplers"); + const samplerConfigPath = path.join(tempDir, "config", "samplers.json"); + const samplersStore = samplersService.samplers as unknown as { + dir: string; + ready: Promise; + }; + const samplersSettingsStore = samplersService.samplersSettings as unknown as { + configPath: string; + ready: Promise; + }; + + prevSamplersDir = samplersStore.dir; + prevSamplersReady = samplersStore.ready; + prevSamplerConfigPath = samplersSettingsStore.configPath; + prevSamplerConfigReady = samplersSettingsStore.ready; + + samplersStore.dir = samplerDir; + samplersStore.ready = fs.mkdir(samplerDir, { recursive: true }).then(() => undefined); + samplersSettingsStore.configPath = samplerConfigPath; + samplersSettingsStore.ready = fs.mkdir(path.dirname(samplerConfigPath), { recursive: true }).then(() => undefined); + }); + + afterEach(async () => { + resetDbForTests(); + const samplersStore = samplersService.samplers as unknown as { + dir: string; + ready: Promise; + }; + const samplersSettingsStore = samplersService.samplersSettings as unknown as { + configPath: string; + ready: Promise; + }; + samplersStore.dir = prevSamplersDir; + samplersStore.ready = prevSamplersReady ?? Promise.resolve(); + samplersSettingsStore.configPath = prevSamplerConfigPath; + samplersSettingsStore.ready = prevSamplerConfigReady ?? Promise.resolve(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + test("exports and imports instruction + operation profile bundle with remapped block refs", async () => { + const instruction = await createInstruction({ + name: "Main instruction", + kind: "basic", + templateText: "{{char.name}}", + }); + const block = await createOperationBlock({ + input: { + name: "Scene block", + enabled: true, + operations: [], + }, + }); + const profile = await createOperationProfile({ + input: { + name: "Main profile", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "2d9f1f5c-6f38-4f94-9caa-0ea4f36f2db8", + blockRefs: [{ blockId: block.blockId, enabled: true, order: 0 }], + }, + }); + + const exported = await exportBundleSelection({ + ownerId: "global", + source: { kind: "instruction", id: instruction.id }, + selections: [ + { kind: "instruction", id: instruction.id }, + { kind: "operation_profile", id: profile.profileId }, + ], + format: "json", + }); + + const imported = await importBundleFile({ + ownerId: "global", + fileName: exported.fileName, + buffer: exported.buffer, + }); + + expect(imported.created.instructions).toHaveLength(1); + expect(imported.created.operationProfiles).toHaveLength(1); + expect(imported.sourceResourceId).toBeTruthy(); + expect(imported.applied.instructionId).toBe(imported.created.instructions[0]!.id); + expect(imported.applied.operationProfileId).toBe(imported.created.operationProfiles[0]!.profileId); + expect(imported.skippedApply).toEqual([]); + + const createdProfile = await getOperationProfileById(imported.created.operationProfiles[0]!.profileId); + const blocks = await listOperationBlocks({ ownerId: "global" }); + + expect(createdProfile?.blockRefs).toHaveLength(1); + expect(createdProfile?.blockRefs[0]?.blockId).not.toBe(block.blockId); + expect(blocks.some((item) => item.blockId === createdProfile?.blockRefs[0]?.blockId)).toBe(true); + }); + + test("imports legacy ui theme export via bundle import endpoint", async () => { + const created = await createUiThemePreset({ + ownerId: "global", + name: "Night", + payload: { + lightTokens: {}, + darkTokens: {}, + typography: { + uiFontFamily: "UI", + chatFontFamily: "Chat", + uiBaseFontSize: "16px", + chatBaseFontSize: "16px", + radiusXs: "4px", + radiusSm: "6px", + radiusMd: "8px", + radiusLg: "10px", + radiusXl: "12px", + }, + markdown: { + fontSize: "16px", + lineHeight: "1.5", + codeFontSize: "14px", + codePadding: "2px 4px", + quoteBorderWidth: "3px", + }, + customCss: "", + }, + }); + + const legacyPayload = JSON.stringify({ + type: "talespinner.uiThemePreset", + version: 1, + preset: { + name: created.name, + description: created.description, + payload: created.payload, + }, + }); + + const imported = await importBundleFile({ + ownerId: "global", + fileName: "ui-theme-night.json", + buffer: Buffer.from(legacyPayload, "utf8"), + }); + + expect(imported.created.uiThemePresets).toHaveLength(1); + expect(imported.applied.uiThemePresetId).toBe(imported.created.uiThemePresets[0]!.presetId); + expect(imported.warnings).toContain("Imported legacy UI theme preset format."); + }); + + test("exports and imports sampler preset bundle with auto-apply", async () => { + await samplersService.samplers.create({ + id: "sampler-1", + name: "Storyteller", + createdAt: "2026-03-13T10:00:00.000Z", + updatedAt: "2026-03-13T10:00:00.000Z", + settings: { + temperature: 0.9, + topP: 0.95, + maxTokens: 4096, + reasoning: { + enabled: true, + effort: "medium", + }, + }, + }); + + const exported = await exportBundleSelection({ + ownerId: "global", + source: { kind: "sampler_preset", id: "sampler-1" }, + selections: [{ kind: "sampler_preset", id: "sampler-1" }], + format: "json", + }); + + const imported = await importBundleFile({ + ownerId: "global", + fileName: exported.fileName, + buffer: exported.buffer, + }); + + expect(imported.created.samplerPresets).toHaveLength(1); + expect(imported.created.samplerPresets[0]?.presetId).not.toBe("sampler-1"); + expect(imported.applied.samplerPresetId).toBe(imported.created.samplerPresets[0]?.presetId ?? null); + expect(imported.skippedApply).toEqual([]); + + const samplers = await samplersService.samplers.getAll(); + expect(samplers).toHaveLength(2); + expect(samplers.some((item) => item.id === imported.created.samplerPresets[0]?.presetId)).toBe(true); + }); + + test("reports ambiguous auto-apply targets when bundle has multiple resources of same kind without source", async () => { + const bundlePayload = JSON.stringify({ + type: "talespinner.bundle", + version: 1, + bundleId: "bundle-ambiguous", + createdAt: "2026-03-13T10:00:00.000Z", + container: "json", + resources: [ + { + resourceId: "instruction:one", + kind: "instruction", + schemaVersion: 1, + role: "related", + title: "One", + payload: { + name: "One", + kind: "basic", + engine: "liquidjs", + templateText: "One", + }, + }, + { + resourceId: "instruction:two", + kind: "instruction", + schemaVersion: 1, + role: "related", + title: "Two", + payload: { + name: "Two", + kind: "basic", + engine: "liquidjs", + templateText: "Two", + }, + }, + ], + }); + + const imported = await importBundleFile({ + ownerId: "global", + fileName: "ambiguous.json", + buffer: Buffer.from(bundlePayload, "utf8"), + }); + + expect(imported.applied.instructionId).toBeNull(); + expect(imported.skippedApply).toContainEqual({ + kind: "instruction", + reason: "ambiguous", + message: "Skipped auto-apply for instruction: ambiguous imported resources.", + }); + }); + + test("reports ambiguous sampler auto-apply targets without source", async () => { + const bundlePayload = JSON.stringify({ + type: "talespinner.bundle", + version: 1, + bundleId: "bundle-sampler-ambiguous", + createdAt: "2026-03-13T10:00:00.000Z", + container: "json", + resources: [ + { + resourceId: "sampler_preset:one", + kind: "sampler_preset", + schemaVersion: 1, + role: "related", + title: "One", + payload: { + name: "One", + settings: { + temperature: 0.7, + }, + }, + }, + { + resourceId: "sampler_preset:two", + kind: "sampler_preset", + schemaVersion: 1, + role: "related", + title: "Two", + payload: { + name: "Two", + settings: { + temperature: 1.1, + }, + }, + }, + ], + }); + + const imported = await importBundleFile({ + ownerId: "global", + fileName: "ambiguous-samplers.json", + buffer: Buffer.from(bundlePayload, "utf8"), + }); + + expect(imported.applied.samplerPresetId).toBeNull(); + expect(imported.skippedApply).toContainEqual({ + kind: "sampler_preset", + reason: "ambiguous", + message: "Skipped auto-apply for sampler preset: ambiguous imported resources.", + }); + }); +}); diff --git a/server/src/services/bundles/bundle-legacy.ts b/server/src/services/bundles/bundle-legacy.ts new file mode 100644 index 00000000..ed474c27 --- /dev/null +++ b/server/src/services/bundles/bundle-legacy.ts @@ -0,0 +1,204 @@ +import { + TALESPINNER_BUNDLE_TYPE, + TALESPINNER_BUNDLE_VERSION, + createBundleResourceId, + parseTaleSpinnerBundle, + type TaleSpinnerBundle, +} from "@shared/types/bundles"; +import { UI_THEME_EXPORT_TYPE, type UiThemeExportV1 } from "@shared/types/ui-theme"; + +import type { InstructionMeta, StBaseConfig } from "@shared/types/instructions"; +import type { OperationProfileExport, OperationProfileLegacyExportV1 } from "@shared/types/operation-profiles"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOperationProfileBundleV2( + input: OperationProfileExport | OperationProfileLegacyExportV1 +): input is OperationProfileExport { + return "type" in input && input.type === "operation_profile_bundle"; +} + +function convertLegacyInstruction(input: Record): TaleSpinnerBundle | null { + if (input.type !== "talespinner.instruction" || !isRecord(input.instruction)) return null; + const kind = input.instruction.kind; + const resourceId = createBundleResourceId("instruction", String(input.instruction.name ?? "instruction")); + + return parseTaleSpinnerBundle({ + type: TALESPINNER_BUNDLE_TYPE, + version: TALESPINNER_BUNDLE_VERSION, + bundleId: `legacy-instruction:${resourceId}`, + createdAt: new Date().toISOString(), + container: "json", + sourceResourceId: resourceId, + resources: [ + { + resourceId, + kind: "instruction", + schemaVersion: 1, + role: "primary", + title: String(input.instruction.name ?? "Instruction"), + payload: + kind === "st_base" + ? { + name: String(input.instruction.name ?? "Instruction"), + kind: "st_base", + engine: "liquidjs", + stBase: input.instruction.stBase as StBaseConfig, + meta: input.instruction.meta as InstructionMeta | undefined, + } + : { + name: String(input.instruction.name ?? "Instruction"), + kind: "basic", + engine: "liquidjs", + templateText: String(input.instruction.templateText ?? ""), + meta: input.instruction.meta as InstructionMeta | undefined, + }, + }, + ], + }); +} + +function toOperationBundleResources(input: OperationProfileExport | OperationProfileLegacyExportV1): TaleSpinnerBundle { + const normalizedProfile = isOperationProfileBundleV2(input) + ? input.profile + : input; + const profileResourceId = createBundleResourceId("operation_profile", normalizedProfile.name); + const hasBlocks = isOperationProfileBundleV2(input); + + const blockResources = hasBlocks + ? input.blocks.map((block) => { + const resourceId = createBundleResourceId("operation_block", block.name); + return { + resourceId, + kind: "operation_block" as const, + schemaVersion: 1 as const, + role: "dependency" as const, + title: block.name, + payload: { + name: block.name, + description: block.description, + enabled: block.enabled, + operations: block.operations, + meta: block.meta, + }, + }; + }) + : [ + { + resourceId: createBundleResourceId("operation_block", `${normalizedProfile.name}-block`), + kind: "operation_block" as const, + schemaVersion: 1 as const, + role: "dependency" as const, + title: `${normalizedProfile.name} block`, + payload: { + name: `${normalizedProfile.name} block`, + description: normalizedProfile.description, + enabled: true, + operations: input.operations, + meta: normalizedProfile.meta, + }, + }, + ]; + + const blockIdMap = new Map(); + if (hasBlocks) { + input.blocks.forEach((block, index) => { + if (block.blockId) { + blockIdMap.set(block.blockId, blockResources[index]!.resourceId); + } + }); + } + + return parseTaleSpinnerBundle({ + type: TALESPINNER_BUNDLE_TYPE, + version: TALESPINNER_BUNDLE_VERSION, + bundleId: `legacy-operation:${profileResourceId}`, + createdAt: new Date().toISOString(), + container: "json", + sourceResourceId: profileResourceId, + resources: [ + ...blockResources, + { + resourceId: profileResourceId, + kind: "operation_profile", + schemaVersion: 1, + role: "primary", + title: normalizedProfile.name, + payload: { + name: normalizedProfile.name, + description: normalizedProfile.description, + enabled: normalizedProfile.enabled, + executionMode: normalizedProfile.executionMode, + operationProfileSessionId: normalizedProfile.operationProfileSessionId, + blockRefs: hasBlocks + ? input.profile.blockRefs.map((ref) => ({ + resourceId: blockIdMap.get(ref.blockId) ?? ref.blockId, + enabled: ref.enabled, + order: ref.order, + })) + : [{ resourceId: blockResources[0]!.resourceId, enabled: true, order: 0 }], + meta: normalizedProfile.meta, + }, + }, + ], + }); +} + +function convertLegacyUiTheme(input: UiThemeExportV1 | UiThemeExportV1[]): TaleSpinnerBundle { + const items = Array.isArray(input) ? input : [input]; + const resources = items.map((item, index) => { + const resourceId = createBundleResourceId("ui_theme_preset", `${item.preset.name}-${index + 1}`); + return { + resourceId, + kind: "ui_theme_preset" as const, + schemaVersion: 1 as const, + role: index === 0 ? ("primary" as const) : ("related" as const), + title: item.preset.name, + payload: { + name: item.preset.name, + description: item.preset.description, + payload: item.preset.payload, + }, + }; + }); + + return parseTaleSpinnerBundle({ + type: TALESPINNER_BUNDLE_TYPE, + version: TALESPINNER_BUNDLE_VERSION, + bundleId: `legacy-ui-theme:${resources[0]!.resourceId}`, + createdAt: new Date().toISOString(), + container: "json", + sourceResourceId: resources[0]!.resourceId, + resources, + }); +} + +export function normalizeLegacyBundleInput(input: unknown): TaleSpinnerBundle | null { + if (isRecord(input)) { + const instructionBundle = convertLegacyInstruction(input); + if (instructionBundle) return instructionBundle; + + if (input.type === UI_THEME_EXPORT_TYPE) { + return convertLegacyUiTheme(input as unknown as UiThemeExportV1); + } + + if ( + input.type === "operation_profile_bundle" || + ("name" in input && + "enabled" in input && + "executionMode" in input && + "operationProfileSessionId" in input && + ("operations" in input || "profile" in input)) + ) { + return toOperationBundleResources(input as OperationProfileExport | OperationProfileLegacyExportV1); + } + } + + if (Array.isArray(input) && input.every((item) => isRecord(item) && item.type === UI_THEME_EXPORT_TYPE)) { + return convertLegacyUiTheme(input as UiThemeExportV1[]); + } + + return null; +} diff --git a/server/src/services/bundles/export-bundle-selection.ts b/server/src/services/bundles/export-bundle-selection.ts new file mode 100644 index 00000000..703733dc --- /dev/null +++ b/server/src/services/bundles/export-bundle-selection.ts @@ -0,0 +1,294 @@ +import { randomUUID } from "node:crypto"; + +import { + TALESPINNER_BUNDLE_ARCHIVE_EXTENSION, + TALESPINNER_BUNDLE_ARCHIVE_MEDIA_TYPE, + createBundleResourceId, + parseTaleSpinnerBundle, + type TaleSpinnerBundle, + type TaleSpinnerBundleResource, + type TaleSpinnerBundleResourceKind, +} from "@shared/types/bundles"; + +import { readEntityProfileAvatarFile } from "../chat-core/entity-profile-media"; +import { getEntityProfileById } from "../chat-core/entity-profiles-repository"; +import { getInstructionById } from "../chat-core/instructions-repository"; +import { getOperationBlockById } from "../operations/operation-blocks-repository"; +import { getOperationProfileById } from "../operations/operation-profiles-repository"; +import { samplersService } from "../samplers.service"; +import { getUiThemePresetById } from "../ui-theme/ui-theme-repository"; +import { getWorldInfoBookById } from "../world-info/world-info-repositories"; + +import { encodeBundleArchive, type BundleArchiveFile } from "./bundle-archive"; + +export type BundleSelectionHandle = { kind: TaleSpinnerBundleResourceKind; id: string }; + +function safeFileBaseName(input: string): string { + const normalized = input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return normalized || "bundle"; +} + +export async function exportBundleSelection(params: { + ownerId: string; + source: BundleSelectionHandle; + selections: BundleSelectionHandle[]; + format?: "json" | "archive" | "auto"; +}): Promise<{ + fileName: string; + contentType: string; + buffer: Buffer; + bundle: TaleSpinnerBundle; +}> { + const resources: TaleSpinnerBundleResource[] = []; + const files: Record = {}; + const included = new Set(); + let sourceResourceId: string | undefined; + + const include = async (handle: BundleSelectionHandle, role: TaleSpinnerBundleResource["role"]) => { + const key = `${handle.kind}:${handle.id}`; + if (included.has(key)) return; + included.add(key); + + if (handle.kind === "instruction") { + const instruction = await getInstructionById(handle.id); + if (!instruction) throw new Error(`Instruction not found: ${handle.id}`); + const resourceId = createBundleResourceId("instruction", `${instruction.name}-${instruction.id}`); + resources.push({ + resourceId, + kind: "instruction", + schemaVersion: 1, + role, + title: instruction.name, + payload: + instruction.kind === "st_base" + ? { + name: instruction.name, + kind: "st_base", + engine: instruction.engine, + stBase: instruction.stBase, + meta: instruction.meta ?? undefined, + } + : { + name: instruction.name, + kind: "basic", + engine: instruction.engine, + templateText: instruction.templateText, + meta: instruction.meta ?? undefined, + }, + }); + if (handle.kind === params.source.kind && handle.id === params.source.id) sourceResourceId = resourceId; + return; + } + + if (handle.kind === "operation_block") { + const block = await getOperationBlockById(handle.id); + if (!block) throw new Error(`Operation block not found: ${handle.id}`); + const resourceId = createBundleResourceId("operation_block", `${block.name}-${block.blockId}`); + resources.push({ + resourceId, + kind: "operation_block", + schemaVersion: 1, + role, + title: block.name, + payload: { + name: block.name, + description: block.description, + enabled: block.enabled, + operations: block.operations, + meta: block.meta ?? undefined, + }, + }); + if (handle.kind === params.source.kind && handle.id === params.source.id) sourceResourceId = resourceId; + return; + } + + if (handle.kind === "operation_profile") { + const profile = await getOperationProfileById(handle.id); + if (!profile) throw new Error(`Operation profile not found: ${handle.id}`); + + const enabledRefs = profile.blockRefs.filter((ref) => ref.enabled); + const exportedBlockResources: Array<{ blockId: string; resourceId: string }> = []; + for (const ref of enabledRefs) { + const block = await getOperationBlockById(ref.blockId); + if (!block || !block.enabled) continue; + const resourceId = createBundleResourceId("operation_block", `${block.name}-${block.blockId}`); + exportedBlockResources.push({ blockId: block.blockId, resourceId }); + resources.push({ + resourceId, + kind: "operation_block", + schemaVersion: 1, + role: "dependency", + title: block.name, + payload: { + name: block.name, + description: block.description, + enabled: block.enabled, + operations: block.operations, + meta: block.meta ?? undefined, + }, + }); + } + + const resourceId = createBundleResourceId("operation_profile", `${profile.name}-${profile.profileId}`); + resources.push({ + resourceId, + kind: "operation_profile", + schemaVersion: 1, + role, + title: profile.name, + payload: { + name: profile.name, + description: profile.description, + enabled: profile.enabled, + executionMode: profile.executionMode, + operationProfileSessionId: profile.operationProfileSessionId, + blockRefs: profile.blockRefs + .filter((ref) => ref.enabled) + .map((ref) => ({ + resourceId: exportedBlockResources.find((item) => item.blockId === ref.blockId)?.resourceId ?? ref.blockId, + enabled: ref.enabled, + order: ref.order, + })) + .filter((ref) => exportedBlockResources.some((item) => item.resourceId === ref.resourceId)), + meta: profile.meta ?? undefined, + }, + }); + if (handle.kind === params.source.kind && handle.id === params.source.id) sourceResourceId = resourceId; + return; + } + + if (handle.kind === "world_info_book") { + const book = await getWorldInfoBookById(handle.id); + if (!book) throw new Error(`World info book not found: ${handle.id}`); + const resourceId = createBundleResourceId("world_info_book", `${book.slug}-${book.id}`); + resources.push({ + resourceId, + kind: "world_info_book", + schemaVersion: 1, + role, + title: book.name, + payload: { + name: book.name, + slug: book.slug, + description: book.description, + data: book.data, + extensions: book.extensions, + source: book.source, + }, + }); + if (handle.kind === params.source.kind && handle.id === params.source.id) sourceResourceId = resourceId; + return; + } + + if (handle.kind === "entity_profile") { + const profile = await getEntityProfileById(handle.id); + if (!profile) throw new Error(`Entity profile not found: ${handle.id}`); + const resourceId = createBundleResourceId("entity_profile", `${profile.name}-${profile.id}`); + const avatar = await readEntityProfileAvatarFile(profile.avatarAssetId); + const avatarPath = avatar ? `files/${safeFileBaseName(profile.name)}/${avatar.fileName}` : undefined; + if (avatar && avatarPath) { + files[avatarPath] = avatar; + } + resources.push({ + resourceId, + kind: "entity_profile", + schemaVersion: 1, + role, + title: profile.name, + payload: { + name: profile.name, + kind: "CharSpec", + spec: profile.spec, + meta: profile.meta ?? undefined, + isFavorite: profile.isFavorite, + ...(avatar && avatarPath + ? { + avatarFile: { + path: avatarPath, + fileName: avatar.fileName, + mediaType: avatar.mediaType, + }, + } + : {}), + }, + }); + if (handle.kind === params.source.kind && handle.id === params.source.id) sourceResourceId = resourceId; + return; + } + + if (handle.kind === "ui_theme_preset") { + const preset = await getUiThemePresetById({ ownerId: params.ownerId, presetId: handle.id }); + if (!preset) throw new Error(`UI theme preset not found: ${handle.id}`); + const resourceId = createBundleResourceId("ui_theme_preset", `${preset.name}-${preset.presetId}`); + resources.push({ + resourceId, + kind: "ui_theme_preset", + schemaVersion: 1, + role, + title: preset.name, + payload: { + name: preset.name, + description: preset.description, + payload: preset.payload, + }, + }); + if (handle.kind === params.source.kind && handle.id === params.source.id) sourceResourceId = resourceId; + return; + } + + if (handle.kind === "sampler_preset") { + const preset = await samplersService.samplers.getById(handle.id); + if (!preset) throw new Error(`Sampler preset not found: ${handle.id}`); + const resourceId = createBundleResourceId("sampler_preset", `${preset.name}-${preset.id}`); + resources.push({ + resourceId, + kind: "sampler_preset", + schemaVersion: 1, + role, + title: preset.name, + payload: { + name: preset.name, + settings: preset.settings, + }, + }); + if (handle.kind === params.source.kind && handle.id === params.source.id) sourceResourceId = resourceId; + } + }; + + await include(params.source, "primary"); + for (const selection of params.selections) { + const role = selection.kind === params.source.kind && selection.id === params.source.id ? "primary" : "related"; + await include(selection, role); + } + + const container = + params.format === "archive" || (params.format !== "json" && Object.keys(files).length > 0) + ? "archive" + : "json"; + const bundle = parseTaleSpinnerBundle({ + type: "talespinner.bundle", + version: 1, + bundleId: randomUUID(), + createdAt: new Date().toISOString(), + container, + sourceResourceId, + resources, + }); + + if (container === "archive") { + const buffer = await encodeBundleArchive({ manifest: bundle, files }); + return { + fileName: `talespinner-bundle-${safeFileBaseName(params.source.kind)}${TALESPINNER_BUNDLE_ARCHIVE_EXTENSION}`, + contentType: TALESPINNER_BUNDLE_ARCHIVE_MEDIA_TYPE, + buffer, + bundle, + }; + } + + return { + fileName: `talespinner-bundle-${safeFileBaseName(params.source.kind)}.json`, + contentType: "application/json; charset=utf-8", + buffer: Buffer.from(JSON.stringify(bundle, null, 2), "utf8"), + bundle, + }; +} diff --git a/server/src/services/bundles/import-bundle-file.ts b/server/src/services/bundles/import-bundle-file.ts new file mode 100644 index 00000000..9c1ca2d6 --- /dev/null +++ b/server/src/services/bundles/import-bundle-file.ts @@ -0,0 +1,412 @@ +import { randomUUID } from "node:crypto"; + +import { + type EntityProfileBundleResource, + type InstructionBundleResource, + type OperationBlockBundleResource, + type OperationProfileBundleResource, + parseTaleSpinnerBundle, + type SamplerPresetBundleResource, + type TaleSpinnerBundle, + type UiThemePresetBundleResource, + validateBundleResourceGraph, + type WorldInfoBookBundleResource, +} from "@shared/types/bundles"; +import { type InstructionMeta, type StBaseConfig } from "@shared/types/instructions"; +import { UI_THEME_EXPORT_TYPE } from "@shared/types/ui-theme"; + +import { saveEntityProfileAvatarPng } from "../chat-core/entity-profile-media"; +import { createEntityProfile } from "../chat-core/entity-profiles-repository"; +import { createInstruction, listInstructions } from "../chat-core/instructions-repository"; +import { createOperationBlock, listOperationBlocks, resolveImportedOperationBlockName } from "../operations/operation-blocks-repository"; +import { createOperationProfile, listOperationProfiles } from "../operations/operation-profiles-repository"; +import { resolveImportedSamplerPresetName, samplersService } from "../samplers.service"; +import { createUiThemePreset, listUiThemePresets, resolveImportedPresetName } from "../ui-theme/ui-theme-repository"; +import { createWorldInfoBook } from "../world-info/world-info-repositories"; + +import { decodeBundleArchive } from "./bundle-archive"; +import { normalizeLegacyBundleInput } from "./bundle-legacy"; + +function resolveImportedName(input: string, existingNames: Set, suffix = "copy"): string { + const base = input.trim() || "Imported item"; + if (!existingNames.has(base)) { + existingNames.add(base); + return base; + } + let index = 2; + while (existingNames.has(`${base} (${suffix} ${index})`)) { + index += 1; + } + const next = `${base} (${suffix} ${index})`; + existingNames.add(next); + return next; +} + +function looksLikeArchive(buffer: Buffer): boolean { + return buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b; +} + +function isInstructionResource(resource: TaleSpinnerBundle["resources"][number]): resource is InstructionBundleResource { + return resource.kind === "instruction"; +} + +function isOperationBlockResource(resource: TaleSpinnerBundle["resources"][number]): resource is OperationBlockBundleResource { + return resource.kind === "operation_block"; +} + +function isOperationProfileResource(resource: TaleSpinnerBundle["resources"][number]): resource is OperationProfileBundleResource { + return resource.kind === "operation_profile"; +} + +function isWorldInfoBookResource(resource: TaleSpinnerBundle["resources"][number]): resource is WorldInfoBookBundleResource { + return resource.kind === "world_info_book"; +} + +function isEntityProfileResource(resource: TaleSpinnerBundle["resources"][number]): resource is EntityProfileBundleResource { + return resource.kind === "entity_profile"; +} + +function isUiThemePresetResource(resource: TaleSpinnerBundle["resources"][number]): resource is UiThemePresetBundleResource { + return resource.kind === "ui_theme_preset"; +} + +function isSamplerPresetResource(resource: TaleSpinnerBundle["resources"][number]): resource is SamplerPresetBundleResource { + return resource.kind === "sampler_preset"; +} + +type CreatedResourceItem = { resourceId: string }; +type AutoApplyKind = + | "instruction" + | "operation_profile" + | "ui_theme_preset" + | "sampler_preset" + | "entity_profile" + | "world_info_book"; + +type BundleAppliedTargets = { + instructionId: string | null; + operationProfileId: string | null; + uiThemePresetId: string | null; + samplerPresetId: string | null; + entityProfileId: string | null; + worldInfoBookId: string | null; +}; + +type BundleSkippedApplyItem = { + kind: AutoApplyKind; + reason: "ambiguous"; + message: string; +}; + +function resolveAppliedTarget( + items: T[], + sourceResourceId: string | undefined, + kind: AutoApplyKind, + label: string, + pickId: (item: T) => TResult +): { value: TResult | null; skipped: BundleSkippedApplyItem | null } { + if (items.length === 0) { + return { value: null, skipped: null }; + } + + if (sourceResourceId) { + const sourceMatch = items.find((item) => item.resourceId === sourceResourceId) ?? null; + if (sourceMatch) { + return { value: pickId(sourceMatch), skipped: null }; + } + } + + if (items.length === 1) { + return { value: pickId(items[0]!), skipped: null }; + } + + return { + value: null, + skipped: { + kind, + reason: "ambiguous", + message: `Skipped auto-apply for ${label}: ambiguous imported resources.`, + }, + }; +} + +export async function importBundleFile(params: { + ownerId: string; + fileName: string; + buffer: Buffer; +}): Promise<{ + sourceResourceId?: string; + created: { + instructions: Array<{ resourceId: string; id: string; name: string }>; + operationBlocks: Array<{ resourceId: string; blockId: string; name: string }>; + operationProfiles: Array<{ resourceId: string; profileId: string; name: string }>; + worldInfoBooks: Array<{ resourceId: string; id: string; name: string }>; + entityProfiles: Array<{ resourceId: string; id: string; name: string }>; + uiThemePresets: Array<{ resourceId: string; presetId: string; name: string }>; + samplerPresets: Array<{ resourceId: string; presetId: string; name: string }>; + }; + applied: BundleAppliedTargets; + skippedApply: BundleSkippedApplyItem[]; + warnings: string[]; +}> { + const files: Record = {}; + let bundle: TaleSpinnerBundle; + + if (looksLikeArchive(params.buffer)) { + const decoded = await decodeBundleArchive(params.buffer); + bundle = decoded.manifest; + Object.assign(files, decoded.files); + } else { + const text = params.buffer.toString("utf8"); + const raw = JSON.parse(text) as unknown; + const legacy = normalizeLegacyBundleInput(raw); + bundle = legacy ?? parseTaleSpinnerBundle(raw); + } + + validateBundleResourceGraph(bundle); + + const [existingInstructions, existingBlocks, existingProfiles, existingThemes] = await Promise.all([ + listInstructions({ ownerId: params.ownerId }), + listOperationBlocks({ ownerId: params.ownerId }), + listOperationProfiles({ ownerId: params.ownerId }), + listUiThemePresets({ ownerId: params.ownerId }), + ]); + const existingSamplerPresets = await samplersService.samplers.getAll(); + + const instructionNames = new Set(existingInstructions.map((item) => item.name)); + const blockNames = new Set(existingBlocks.map((item) => item.name)); + const profileNames = new Set(existingProfiles.map((item) => item.name)); + const themeNames = new Set(existingThemes.map((item) => item.name)); + const samplerPresetNames = new Set(existingSamplerPresets.map((item) => item.name)); + const blockIdMap = new Map(); + + const created = { + instructions: [] as Array<{ resourceId: string; id: string; name: string }>, + operationBlocks: [] as Array<{ resourceId: string; blockId: string; name: string }>, + operationProfiles: [] as Array<{ resourceId: string; profileId: string; name: string }>, + worldInfoBooks: [] as Array<{ resourceId: string; id: string; name: string }>, + entityProfiles: [] as Array<{ resourceId: string; id: string; name: string }>, + uiThemePresets: [] as Array<{ resourceId: string; presetId: string; name: string }>, + samplerPresets: [] as Array<{ resourceId: string; presetId: string; name: string }>, + }; + const warnings: string[] = []; + + for (const resource of bundle.resources.filter(isInstructionResource)) { + const name = resolveImportedName(resource.payload.name, instructionNames, "copy"); + const createdInstruction = + resource.payload.kind === "st_base" + ? await createInstruction({ + ownerId: params.ownerId, + name, + kind: "st_base", + stBase: resource.payload.stBase as StBaseConfig, + meta: resource.payload.meta as InstructionMeta | undefined, + }) + : await createInstruction({ + ownerId: params.ownerId, + name, + kind: "basic", + templateText: resource.payload.templateText ?? "", + meta: resource.payload.meta as InstructionMeta | undefined, + }); + created.instructions.push({ + resourceId: resource.resourceId, + id: createdInstruction.id, + name: createdInstruction.name, + }); + } + + for (const resource of bundle.resources.filter(isOperationBlockResource)) { + const name = resolveImportedOperationBlockName(resource.payload.name, Array.from(blockNames)); + blockNames.add(name); + const block = await createOperationBlock({ + ownerId: params.ownerId, + input: { + name, + description: resource.payload.description, + enabled: resource.payload.enabled, + operations: resource.payload.operations, + meta: resource.payload.meta, + }, + }); + blockIdMap.set(resource.resourceId, block.blockId); + created.operationBlocks.push({ + resourceId: resource.resourceId, + blockId: block.blockId, + name: block.name, + }); + } + + for (const resource of bundle.resources.filter(isOperationProfileResource)) { + const name = resolveImportedName(resource.payload.name, profileNames, "imported"); + const profile = await createOperationProfile({ + ownerId: params.ownerId, + input: { + name, + description: resource.payload.description, + enabled: resource.payload.enabled, + executionMode: resource.payload.executionMode, + operationProfileSessionId: resource.payload.operationProfileSessionId, + blockRefs: resource.payload.blockRefs.map((ref) => ({ + blockId: blockIdMap.get(ref.resourceId) ?? ref.resourceId, + enabled: ref.enabled, + order: ref.order, + })), + meta: resource.payload.meta, + }, + }); + created.operationProfiles.push({ + resourceId: resource.resourceId, + profileId: profile.profileId, + name: profile.name, + }); + } + + for (const resource of bundle.resources.filter(isWorldInfoBookResource)) { + const book = await createWorldInfoBook({ + ownerId: params.ownerId, + name: resource.payload.name, + slug: resource.payload.slug, + description: resource.payload.description ?? null, + data: resource.payload.data, + extensions: resource.payload.extensions, + source: resource.payload.source, + }); + created.worldInfoBooks.push({ + resourceId: resource.resourceId, + id: book.id, + name: book.name, + }); + } + + for (const resource of bundle.resources.filter(isEntityProfileResource)) { + let avatarAssetId: string | undefined; + if (resource.payload.avatarFile) { + const file = files[resource.payload.avatarFile.path]; + if (file) { + avatarAssetId = await saveEntityProfileAvatarPng(file.data); + } else { + warnings.push(`Missing archive file: ${resource.payload.avatarFile.path}`); + } + } + const profile = await createEntityProfile({ + ownerId: params.ownerId, + name: resource.payload.name, + kind: "CharSpec", + spec: resource.payload.spec, + meta: resource.payload.meta, + isFavorite: resource.payload.isFavorite, + avatarAssetId, + }); + created.entityProfiles.push({ + resourceId: resource.resourceId, + id: profile.id, + name: profile.name, + }); + } + + for (const resource of bundle.resources.filter(isUiThemePresetResource)) { + const name = resolveImportedPresetName(resource.payload.name, Array.from(themeNames)); + themeNames.add(name); + const preset = await createUiThemePreset({ + ownerId: params.ownerId, + name, + description: resource.payload.description, + payload: resource.payload.payload, + }); + created.uiThemePresets.push({ + resourceId: resource.resourceId, + presetId: preset.presetId, + name: preset.name, + }); + } + + for (const resource of bundle.resources.filter(isSamplerPresetResource)) { + const presetId = randomUUID(); + const now = new Date().toISOString(); + const name = resolveImportedSamplerPresetName(resource.payload.name, Array.from(samplerPresetNames)); + samplerPresetNames.add(name); + const preset = await samplersService.samplers.create({ + id: presetId, + name, + settings: resource.payload.settings, + createdAt: now, + updatedAt: now, + }); + created.samplerPresets.push({ + resourceId: resource.resourceId, + presetId: preset.id, + name: preset.name, + }); + } + + if (params.fileName.endsWith(".json") && params.buffer.toString("utf8").includes(UI_THEME_EXPORT_TYPE)) { + warnings.push("Imported legacy UI theme preset format."); + } + + const instructionApply = resolveAppliedTarget( + created.instructions, + bundle.sourceResourceId, + "instruction", + "instruction", + (item) => item.id + ); + const operationProfileApply = resolveAppliedTarget( + created.operationProfiles, + bundle.sourceResourceId, + "operation_profile", + "operation profile", + (item) => item.profileId + ); + const uiThemePresetApply = resolveAppliedTarget( + created.uiThemePresets, + bundle.sourceResourceId, + "ui_theme_preset", + "UI theme preset", + (item) => item.presetId + ); + const samplerPresetApply = resolveAppliedTarget( + created.samplerPresets, + bundle.sourceResourceId, + "sampler_preset", + "sampler preset", + (item) => item.presetId + ); + const entityProfileApply = resolveAppliedTarget( + created.entityProfiles, + bundle.sourceResourceId, + "entity_profile", + "entity profile", + (item) => item.id + ); + const worldInfoBookApply = resolveAppliedTarget( + created.worldInfoBooks, + bundle.sourceResourceId, + "world_info_book", + "world info book", + (item) => item.id + ); + const skippedApply = [ + instructionApply.skipped, + operationProfileApply.skipped, + uiThemePresetApply.skipped, + samplerPresetApply.skipped, + entityProfileApply.skipped, + worldInfoBookApply.skipped, + ].filter((item): item is BundleSkippedApplyItem => Boolean(item)); + + return { + sourceResourceId: bundle.sourceResourceId, + created, + applied: { + instructionId: instructionApply.value, + operationProfileId: operationProfileApply.value, + uiThemePresetId: uiThemePresetApply.value, + samplerPresetId: samplerPresetApply.value, + entityProfileId: entityProfileApply.value, + worldInfoBookId: worldInfoBookApply.value, + }, + skippedApply, + warnings, + }; +} diff --git a/server/src/services/chat-core/built-in-sillytavern-preset.ts b/server/src/services/chat-core/built-in-sillytavern-preset.ts new file mode 100644 index 00000000..71f54ee0 --- /dev/null +++ b/server/src/services/chat-core/built-in-sillytavern-preset.ts @@ -0,0 +1,245 @@ +export const BUILT_IN_SILLY_TAVERN_PRESET_FILE_NAME = "Default.json"; + +export const BUILT_IN_SILLY_TAVERN_PRESET = { + "chat_completion_source": "openai", + "openai_model": "gpt-4-turbo", + "claude_model": "claude-sonnet-4-5", + "openrouter_model": "OR_Website", + "openrouter_use_fallback": false, + "openrouter_group_models": false, + "openrouter_sort_models": "alphabetically", + "ai21_model": "jamba-large", + "mistralai_model": "mistral-large-latest", + "chutes_model": "deepseek-ai/DeepSeek-V3-0324", + "chutes_sort_models": "alphabetically", + "electronhub_model": "gpt-4o-mini", + "electronhub_sort_models": "alphabetically", + "electronhub_group_models": false, + "custom_model": "", + "custom_url": "", + "custom_include_body": "", + "custom_exclude_body": "", + "custom_include_headers": "", + "google_model": "gemini-2.5-pro", + "vertexai_model": "gemini-2.5-pro", + "temperature": 1, + "frequency_penalty": 0, + "presence_penalty": 0, + "top_p": 1, + "top_k": 0, + "top_a": 0, + "min_p": 0, + "repetition_penalty": 1, + "openai_max_context": 4095, + "openai_max_tokens": 300, + "names_behavior": 0, + "send_if_empty": "", + "impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]", + "new_chat_prompt": "[Start a new Chat]", + "new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]", + "new_example_chat_prompt": "[Example Chat]", + "continue_nudge_prompt": "[Continue your last message without repeating its original content.]", + "bias_preset_selected": "Default (none)", + "reverse_proxy": "", + "proxy_password": "", + "max_context_unlocked": false, + "wi_format": "{0}", + "scenario_format": "{{scenario}}", + "personality_format": "{{personality}}", + "group_nudge_prompt": "[Write the next reply only as {{char}}.]", + "stream_openai": true, + "prompts": [ + { + "name": "Main Prompt", + "system_prompt": true, + "role": "system", + "content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.", + "identifier": "main" + }, + { + "name": "Auxiliary Prompt", + "system_prompt": true, + "role": "system", + "content": "", + "identifier": "nsfw" + }, + { + "identifier": "dialogueExamples", + "name": "Chat Examples", + "system_prompt": true, + "marker": true + }, + { + "name": "Post-History Instructions", + "system_prompt": true, + "role": "system", + "content": "", + "identifier": "jailbreak" + }, + { + "identifier": "chatHistory", + "name": "Chat History", + "system_prompt": true, + "marker": true + }, + { + "identifier": "worldInfoAfter", + "name": "World Info (after)", + "system_prompt": true, + "marker": true + }, + { + "identifier": "worldInfoBefore", + "name": "World Info (before)", + "system_prompt": true, + "marker": true + }, + { + "identifier": "enhanceDefinitions", + "role": "system", + "name": "Enhance Definitions", + "content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.", + "system_prompt": true, + "marker": false + }, + { + "identifier": "charDescription", + "name": "Char Description", + "system_prompt": true, + "marker": true + }, + { + "identifier": "charPersonality", + "name": "Char Personality", + "system_prompt": true, + "marker": true + }, + { + "identifier": "scenario", + "name": "Scenario", + "system_prompt": true, + "marker": true + }, + { + "identifier": "personaDescription", + "name": "Persona Description", + "system_prompt": true, + "marker": true + } + ], + "prompt_order": [ + { + "character_id": 100000, + "order": [ + { + "identifier": "main", + "enabled": true + }, + { + "identifier": "worldInfoBefore", + "enabled": true + }, + { + "identifier": "charDescription", + "enabled": true + }, + { + "identifier": "charPersonality", + "enabled": true + }, + { + "identifier": "scenario", + "enabled": true + }, + { + "identifier": "enhanceDefinitions", + "enabled": false + }, + { + "identifier": "nsfw", + "enabled": true + }, + { + "identifier": "worldInfoAfter", + "enabled": true + }, + { + "identifier": "dialogueExamples", + "enabled": true + }, + { + "identifier": "chatHistory", + "enabled": true + }, + { + "identifier": "jailbreak", + "enabled": true + } + ] + }, + { + "character_id": 100001, + "order": [ + { + "identifier": "main", + "enabled": true + }, + { + "identifier": "worldInfoBefore", + "enabled": true + }, + { + "identifier": "personaDescription", + "enabled": true + }, + { + "identifier": "charDescription", + "enabled": true + }, + { + "identifier": "charPersonality", + "enabled": true + }, + { + "identifier": "scenario", + "enabled": true + }, + { + "identifier": "enhanceDefinitions", + "enabled": false + }, + { + "identifier": "nsfw", + "enabled": true + }, + { + "identifier": "worldInfoAfter", + "enabled": true + }, + { + "identifier": "dialogueExamples", + "enabled": true + }, + { + "identifier": "chatHistory", + "enabled": true + }, + { + "identifier": "jailbreak", + "enabled": true + } + ] + } + ], + "show_external_models": false, + "assistant_prefill": "", + "assistant_impersonation": "", + "use_sysprompt": false, + "squash_system_messages": false, + "media_inlining": true, + "bypass_status_check": false, + "continue_prefill": false, + "continue_postfix": " ", + "seed": -1, + "n": 1 +} as const; diff --git a/server/src/services/chat-core/chats-repository.ts b/server/src/services/chat-core/chats-repository.ts index 18a1dbc0..5a2b1276 100644 --- a/server/src/services/chat-core/chats-repository.ts +++ b/server/src/services/chat-core/chats-repository.ts @@ -1,6 +1,7 @@ -import { and, desc, eq, lt, ne } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, desc, eq, lt, ne } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; import { diff --git a/server/src/services/chat-core/entity-profile-media.ts b/server/src/services/chat-core/entity-profile-media.ts new file mode 100644 index 00000000..32099691 --- /dev/null +++ b/server/src/services/chat-core/entity-profile-media.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { createDataPath } from "../../utils"; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function getEntityProfileImagesDir(): string { + return createDataPath("media", "images", "entity-profiles"); +} + +export function resolveEntityProfileMediaPath(avatarAssetId: string | null): string | null { + if (!avatarAssetId || !avatarAssetId.startsWith("/media/")) return null; + return createDataPath(avatarAssetId.replace(/^\/media\//, "media/")); +} + +export async function readEntityProfileAvatarFile(avatarAssetId: string | null): Promise<{ + data: Buffer; + fileName: string; + mediaType: "image/png"; +} | null> { + const filePath = resolveEntityProfileMediaPath(avatarAssetId); + if (!filePath) return null; + + try { + const data = await fs.readFile(filePath); + if (!data.subarray(0, 8).equals(PNG_SIGNATURE)) return null; + return { + data, + fileName: path.basename(filePath), + mediaType: "image/png", + }; + } catch { + return null; + } +} + +export async function saveEntityProfileAvatarPng(fileBuffer: Buffer): Promise { + if (!fileBuffer.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new Error("Entity profile avatar must be a PNG image."); + } + + const dir = getEntityProfileImagesDir(); + await fs.mkdir(dir, { recursive: true }); + + const fileName = `${randomUUID()}.png`; + await fs.writeFile(path.join(dir, fileName), fileBuffer); + return `/media/images/entity-profiles/${fileName}`; +} diff --git a/server/src/services/chat-core/entity-profiles-repository.ts b/server/src/services/chat-core/entity-profiles-repository.ts index b754ee9a..74d74dc7 100644 --- a/server/src/services/chat-core/entity-profiles-repository.ts +++ b/server/src/services/chat-core/entity-profiles-repository.ts @@ -1,6 +1,7 @@ -import { asc, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { asc, eq } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; import { entityProfiles } from "../../db/schema"; diff --git a/server/src/services/chat-core/generation-control-service.integration.test.ts b/server/src/services/chat-core/generation-control-service.integration.test.ts index f704a11b..93a16d37 100644 --- a/server/src/services/chat-core/generation-control-service.integration.test.ts +++ b/server/src/services/chat-core/generation-control-service.integration.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { applyMigrations } from "../../db/apply-migrations"; import { initDb, resetDbForTests } from "../../db/client"; @@ -14,9 +14,10 @@ import { entityProfiles, generationRuntimeControl, } from "../../db/schema"; -import { createGeneration } from "./generations-repository"; + import { getGenerationControlByGenerationId, markGenerationAbortRequested } from "./generation-control-repository"; import { GenerationControlService } from "./generation-control-service"; +import { createGeneration } from "./generations-repository"; async function waitFor( predicate: () => boolean, diff --git a/server/src/services/chat-core/generation-control-service.ts b/server/src/services/chat-core/generation-control-service.ts index 55821ad4..3416d04e 100644 --- a/server/src/services/chat-core/generation-control-service.ts +++ b/server/src/services/chat-core/generation-control-service.ts @@ -1,8 +1,5 @@ -import { - abortRegisteredGeneration, - registerGenerationAbortController, - unregisterGenerationAbortController, -} from "./generation-runtime"; +import { structuredLogger } from "../../core/logging/structured-logger"; + import { clearGenerationControlLease, getGenerationControlByGenerationId, @@ -10,7 +7,11 @@ import { markGenerationAbortRequested, upsertGenerationControlLease, } from "./generation-control-repository"; -import { structuredLogger } from "../../core/logging/structured-logger"; +import { + abortRegisteredGeneration, + registerGenerationAbortController, + unregisterGenerationAbortController, +} from "./generation-runtime"; import type { RunResult } from "../chat-generation-v3/contracts"; diff --git a/server/src/services/chat-core/generations-repository.ts b/server/src/services/chat-core/generations-repository.ts index fc1528ab..ff642d78 100644 --- a/server/src/services/chat-core/generations-repository.ts +++ b/server/src/services/chat-core/generations-repository.ts @@ -1,6 +1,7 @@ -import { and, desc, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, desc, eq } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify, diff --git a/server/src/services/chat-core/instruction-st-base.ts b/server/src/services/chat-core/instruction-st-base.ts new file mode 100644 index 00000000..07a19d5e --- /dev/null +++ b/server/src/services/chat-core/instruction-st-base.ts @@ -0,0 +1,166 @@ +import { + isSillyTavernPreset, + getSillyTavernPresetValidationError, +} from "@shared/utils/sillytavern-preset"; +import { + normalizeStPromptOrder, + normalizeStPrompts, +} from "@shared/utils/st-prompts"; + +import { + BUILT_IN_SILLY_TAVERN_PRESET, + BUILT_IN_SILLY_TAVERN_PRESET_FILE_NAME, +} from "./built-in-sillytavern-preset"; + +export { resolveStBaseInstructionRuntime } from "./st-prompt-runtime"; + +import type { + StBaseConfig, + StBaseResponseConfig, +} from "@shared/types/instructions"; + +export const ST_SENSITIVE_FIELDS = [ + "reverse_proxy", + "proxy_password", + "custom_url", + "custom_include_body", + "custom_exclude_body", + "custom_include_headers", + "vertexai_region", + "vertexai_express_project_id", + "azure_base_url", + "azure_deployment_name", +] as const; + +type SensitiveImportMode = "remove" | "keep"; + +type StResponseNumericKey = + | "temperature" + | "top_p" + | "top_k" + | "top_a" + | "min_p" + | "repetition_penalty" + | "frequency_penalty" + | "presence_penalty" + | "openai_max_tokens" + | "seed" + | "n"; + +function toFiniteNumber(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return value; +} + +function toOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function detectStChatCompletionPreset( + input: unknown +): input is Record { + return isSillyTavernPreset(input); +} + +export function stripSensitiveFieldsFromPreset( + preset: Record +): Record { + const cloned = structuredClone(preset); + for (const key of ST_SENSITIVE_FIELDS) { + delete cloned[key]; + } + return cloned; +} + +function normalizeResponseConfig( + preset: Record +): StBaseResponseConfig { + const responseConfig: StBaseResponseConfig = {}; + + const numericKeys: StResponseNumericKey[] = [ + "temperature", + "top_p", + "top_k", + "top_a", + "min_p", + "repetition_penalty", + "frequency_penalty", + "presence_penalty", + "openai_max_tokens", + "seed", + "n", + ]; + + for (const key of numericKeys) { + const value = toFiniteNumber(preset[key]); + if (typeof value === "number") { + responseConfig[key] = value; + } + } + + if (typeof preset.reasoning_effort === "string") { + responseConfig.reasoning_effort = preset.reasoning_effort; + } + if (typeof preset.verbosity === "string") { + responseConfig.verbosity = preset.verbosity; + } + const enableWebSearch = toOptionalBoolean(preset.enable_web_search); + if (typeof enableWebSearch === "boolean") { + responseConfig.enable_web_search = enableWebSearch; + } + const stream = toOptionalBoolean(preset.stream_openai); + if (typeof stream === "boolean") { + responseConfig.stream_openai = stream; + } + + return responseConfig; +} + +export function createStBaseConfigFromPreset(params: { + preset: Record; + fileName: string; + sensitiveImportMode: SensitiveImportMode; +}): StBaseConfig { + const validationError = getSillyTavernPresetValidationError(params.preset); + if (validationError) { + throw new Error(validationError); + } + + const rawPreset = + params.sensitiveImportMode === "remove" + ? stripSensitiveFieldsFromPreset(params.preset) + : structuredClone(params.preset); + const prompts = normalizeStPrompts(rawPreset.prompts); + const promptOrder = normalizeStPromptOrder(rawPreset.prompt_order); + + return { + rawPreset, + prompts, + promptOrder, + responseConfig: normalizeResponseConfig(rawPreset), + importInfo: { + source: "sillytavern", + fileName: params.fileName, + importedAt: new Date().toISOString(), + }, + }; +} + +export async function loadBuiltInSillyTavernPreset(): Promise<{ + fileName: string; + preset: Record; +}> { + const parsed = structuredClone(BUILT_IN_SILLY_TAVERN_PRESET) as unknown; + const validationError = getSillyTavernPresetValidationError(parsed); + + if (validationError || !isSillyTavernPreset(parsed)) { + throw new Error( + `Built-in SillyTavern preset is invalid: ${validationError ?? "Unknown validation error."}` + ); + } + + return { + fileName: BUILT_IN_SILLY_TAVERN_PRESET_FILE_NAME, + preset: parsed, + }; +} diff --git a/server/src/services/chat-core/instruction-st-preset.test.ts b/server/src/services/chat-core/instruction-st-preset.test.ts index fa657ff6..b98334af 100644 --- a/server/src/services/chat-core/instruction-st-preset.test.ts +++ b/server/src/services/chat-core/instruction-st-preset.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "vitest"; import { - createStAdvancedConfigFromPreset, + createStBaseConfigFromPreset, detectStChatCompletionPreset, - resolveStAdvancedInstructionRuntime, + loadBuiltInSillyTavernPreset, + resolveStBaseInstructionRuntime, stripSensitiveFieldsFromPreset, -} from "./instruction-st-preset"; +} from "./instruction-st-base"; -describe("instruction-st-preset", () => { +describe("instruction-st-base", () => { test("parses ST Default.json compatible preset shape", () => { const rawDefault = `{ "chat_completion_source": "openai", @@ -37,7 +38,7 @@ describe("instruction-st-preset", () => { const parsed = JSON.parse(rawDefault) as Record; expect(detectStChatCompletionPreset(parsed)).toBe(true); - const normalized = createStAdvancedConfigFromPreset({ + const normalized = createStBaseConfigFromPreset({ preset: parsed, fileName: "Default.json", sensitiveImportMode: "keep", @@ -45,6 +46,13 @@ describe("instruction-st-preset", () => { expect(normalized.prompts.length).toBe(3); expect(normalized.promptOrder.length).toBe(1); + expect(normalized.prompts[0]).toEqual( + expect.objectContaining({ + injection_position: 0, + injection_depth: 4, + injection_order: 100, + }) + ); expect(normalized.responseConfig).toMatchObject({ temperature: 1, top_p: 1, @@ -61,6 +69,8 @@ describe("instruction-st-preset", () => { detectStChatCompletionPreset({ chat_completion_source: "openai", openai_model: "gpt-4-turbo", + prompts: [{ identifier: "main" }], + prompt_order: [{ character_id: 100001, order: [] }], }) ).toBe(true); @@ -69,6 +79,39 @@ describe("instruction-st-preset", () => { type: "talespinner.instruction", }) ).toBe(false); + + expect( + detectStChatCompletionPreset({ + temperature: 0.8, + }) + ).toBe(false); + + expect( + detectStChatCompletionPreset({ + openai_model: "gpt-4-turbo", + prompts: [{ identifier: "main" }], + }) + ).toBe(false); + }); + + test("loads built-in Default.json preset", async () => { + const builtInPreset = await loadBuiltInSillyTavernPreset(); + + expect(builtInPreset.fileName).toBe("Default.json"); + expect(detectStChatCompletionPreset(builtInPreset.preset)).toBe(true); + + const normalized = createStBaseConfigFromPreset({ + preset: builtInPreset.preset, + fileName: builtInPreset.fileName, + sensitiveImportMode: "keep", + }); + + expect(normalized.prompts.length).toBeGreaterThan(0); + expect(normalized.promptOrder.length).toBeGreaterThan(0); + expect(normalized.rawPreset.prompts).toEqual(builtInPreset.preset.prompts); + expect(normalized.rawPreset.prompt_order).toEqual( + builtInPreset.preset.prompt_order + ); }); test("strips sensitive fields", () => { @@ -92,11 +135,11 @@ describe("instruction-st-preset", () => { openai_max_tokens: 512, openai_model: "gpt-4-turbo", custom_url: "https://proxy.local/v1", - prompts: [], - prompt_order: [], + prompts: [{ identifier: "main", role: "system", content: "Main" }], + prompt_order: [{ character_id: 100001, order: [{ identifier: "main", enabled: true }] }], }; - const removed = createStAdvancedConfigFromPreset({ + const removed = createStBaseConfigFromPreset({ preset, fileName: "Default.json", sensitiveImportMode: "remove", @@ -110,7 +153,7 @@ describe("instruction-st-preset", () => { Object.prototype.hasOwnProperty.call(removed.responseConfig, "openai_model") ).toBe(false); - const kept = createStAdvancedConfigFromPreset({ + const kept = createStBaseConfigFromPreset({ preset, fileName: "Default.json", sensitiveImportMode: "keep", @@ -119,7 +162,7 @@ describe("instruction-st-preset", () => { }); test("uses prompt_order with preferred character id and splits pre/post history prompts", async () => { - const stAdvanced = createStAdvancedConfigFromPreset({ + const stBase = createStBaseConfigFromPreset({ preset: { prompts: [ { @@ -163,8 +206,8 @@ describe("instruction-st-preset", () => { sensitiveImportMode: "keep", }); - const resolved = await resolveStAdvancedInstructionRuntime({ - stAdvanced, + const resolved = await resolveStBaseInstructionRuntime({ + stBase, context: { char: { name: "Lilly" }, user: { name: "Dima" }, @@ -180,10 +223,7 @@ describe("instruction-st-preset", () => { expect(resolved.systemPrompt).toBe("Main Lilly"); expect(resolved.preHistorySystemMessages).toEqual(["WI BEFORE"]); expect(resolved.postHistorySystemMessages).toEqual(["Post Dima"]); - expect(resolved.derivedSettings).toMatchObject({ - temperature: 0.65, - maxTokens: 333, - }); + expect(resolved.derivedSettings).toEqual({}); expect(resolved.usedPromptIdentifiers).toEqual([ "main", "worldInfoBefore", @@ -191,4 +231,167 @@ describe("instruction-st-preset", () => { "jailbreak", ]); }); + + test("shares ST variables across ordered prompt blocks", async () => { + const stBase = createStBaseConfigFromPreset({ + preset: { + chat_completion_source: "openai", + prompts: [ + { + identifier: "roleToggle", + role: "system", + content: "{{setvar::prompt::an excellent protagonist}}{{trim}}", + }, + { + identifier: "role", + role: "system", + content: "You are {{getvar::prompt}}!", + }, + ], + prompt_order: [ + { + character_id: 100001, + order: [ + { identifier: "roleToggle", enabled: true }, + { identifier: "role", enabled: true }, + ], + }, + ], + }, + fileName: "Default.json", + sensitiveImportMode: "keep", + }); + + const resolved = await resolveStBaseInstructionRuntime({ + stBase, + context: { + char: {}, + user: {}, + chat: {}, + messages: [], + rag: {}, + art: {}, + now: new Date("2026-02-13T00:00:00.000Z").toISOString(), + }, + }); + + expect(resolved.systemPrompt).toBe("You are an excellent protagonist!"); + expect(resolved.usedPromptIdentifiers).toEqual(["role"]); + }); + + test("collects in-chat prompts as ordered depth insertions", async () => { + const stBase = createStBaseConfigFromPreset({ + preset: { + chat_completion_source: "openai", + prompts: [ + { + identifier: "main", + role: "system", + content: "Main {{char.name}}", + }, + { + identifier: "authorNote", + role: "user", + content: "Note {{user.name}}", + injection_position: 1, + injection_depth: 2, + injection_order: 80, + }, + ], + prompt_order: [ + { + character_id: 100001, + order: [ + { identifier: "main", enabled: true }, + { identifier: "chatHistory", enabled: true }, + { identifier: "authorNote", enabled: true }, + ], + }, + ], + }, + fileName: "Default.json", + sensitiveImportMode: "keep", + }); + + const resolved = await resolveStBaseInstructionRuntime({ + stBase, + context: { + char: { name: "Lilly" }, + user: { name: "Dima" }, + chat: {}, + messages: [], + rag: {}, + art: {}, + now: new Date("2026-02-13T00:00:00.000Z").toISOString(), + }, + }); + + expect(resolved.systemPrompt).toBe("Main Lilly"); + expect(resolved.depthInsertions).toEqual([ + { depth: 2, role: "user", order: 80, content: "Note Dima" }, + ]); + }); + + test("maps relative prompts around absolute main prompt into the same in-chat bucket", async () => { + const stBase = createStBaseConfigFromPreset({ + preset: { + chat_completion_source: "openai", + prompts: [ + { + identifier: "main", + role: "system", + content: "Main {{char.name}}", + injection_position: 1, + injection_depth: 1, + injection_order: 100, + }, + { + identifier: "nsfw", + role: "system", + content: "Before main", + }, + { + identifier: "jailbreak", + role: "assistant", + content: "After main", + }, + ], + prompt_order: [ + { + character_id: 100001, + order: [ + { identifier: "nsfw", enabled: true }, + { identifier: "main", enabled: true }, + { identifier: "chatHistory", enabled: true }, + { identifier: "jailbreak", enabled: true }, + ], + }, + ], + }, + fileName: "Default.json", + sensitiveImportMode: "keep", + }); + + const resolved = await resolveStBaseInstructionRuntime({ + stBase, + context: { + char: { name: "Lilly" }, + user: { name: "Dima" }, + chat: {}, + messages: [], + rag: {}, + art: {}, + now: new Date("2026-02-13T00:00:00.000Z").toISOString(), + }, + }); + + expect(resolved.systemPrompt).toBe(""); + expect(resolved.preHistorySystemMessages).toEqual([]); + expect(resolved.postHistorySystemMessages).toEqual([]); + expect(resolved.depthInsertions).toEqual([ + { depth: 1, role: "system", order: 100, content: "Before main" }, + { depth: 1, role: "system", order: 100, content: "Main Lilly" }, + { depth: 1, role: "assistant", order: 100, content: "After main" }, + ]); + }); }); diff --git a/server/src/services/chat-core/instruction-st-preset.ts b/server/src/services/chat-core/instruction-st-preset.ts deleted file mode 100644 index 1d908ba3..00000000 --- a/server/src/services/chat-core/instruction-st-preset.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { renderLiquidTemplate } from "./prompt-template-renderer"; - -import type { InstructionRenderContext } from "./prompt-template-renderer"; -import type { - InstructionMeta, - StAdvancedConfig, - StAdvancedResponseConfig, - StPrompt, - StPromptOrder, - TsInstructionMetaV1, -} from "@shared/types/instructions"; - -const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."; -const PROMPT_ORDER_PREFERRED_CHARACTER_ID = 100001; - -const ST_PRESET_DETECT_KEYS = new Set([ - "chat_completion_source", - "prompts", - "prompt_order", - "openai_max_tokens", - "openai_model", - "temperature", -]); - -export const ST_SENSITIVE_FIELDS = [ - "reverse_proxy", - "proxy_password", - "custom_url", - "custom_include_body", - "custom_exclude_body", - "custom_include_headers", - "vertexai_region", - "vertexai_express_project_id", - "azure_base_url", - "azure_deployment_name", -] as const; - -const ST_SUPPORTED_PROMPT_IDENTIFIERS = new Set([ - "main", - "nsfw", - "jailbreak", - "worldInfoBefore", - "worldInfoAfter", - "charDescription", - "charPersonality", - "scenario", - "personaDescription", - "chatHistory", - "dialogueExamples", -]); - -type SensitiveImportMode = "remove" | "keep"; - -type ResolvedAdvancedInstruction = { - systemPrompt: string; - preHistorySystemMessages: string[]; - postHistorySystemMessages: string[]; - derivedSettings: Record; - usedPromptIdentifiers: string[]; -}; - -type StResponseNumericKey = - | "temperature" - | "top_p" - | "top_k" - | "top_a" - | "min_p" - | "repetition_penalty" - | "frequency_penalty" - | "presence_penalty" - | "openai_max_tokens" - | "seed" - | "n"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function asString(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function toFiniteNumber(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) return undefined; - return value; -} - -function toOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function normalizeStPromptRole( - value: unknown -): StPrompt["role"] | undefined { - if (value === "system" || value === "user" || value === "assistant") { - return value; - } - return undefined; -} - -function normalizePrompt(prompt: unknown): StPrompt | null { - if (!isRecord(prompt)) return null; - const identifier = asString(prompt.identifier); - if (!identifier) return null; - - const item: StPrompt = { identifier }; - const name = asString(prompt.name); - if (name) item.name = name; - - const role = normalizeStPromptRole(prompt.role); - if (role) item.role = role; - - if (typeof prompt.content === "string") item.content = prompt.content; - if (typeof prompt.system_prompt === "boolean") { - item.system_prompt = prompt.system_prompt; - } - return item; -} - -function normalizePromptOrderEntry( - value: unknown -): { identifier: string; enabled: boolean } | null { - if (!isRecord(value)) return null; - const identifier = asString(value.identifier); - if (!identifier) return null; - return { - identifier, - enabled: typeof value.enabled === "boolean" ? value.enabled : true, - }; -} - -function normalizePromptOrderItem(value: unknown): StPromptOrder | null { - if (!isRecord(value)) return null; - const rawCharacterId = value.character_id; - const characterId = - typeof rawCharacterId === "number" && Number.isFinite(rawCharacterId) - ? Math.floor(rawCharacterId) - : null; - if (characterId === null) return null; - - const rawOrder = Array.isArray(value.order) ? value.order : []; - const order = rawOrder - .map(normalizePromptOrderEntry) - .filter((entry): entry is { identifier: string; enabled: boolean } => - Boolean(entry) - ); - return { - character_id: characterId, - order, - }; -} - -export function detectStChatCompletionPreset( - input: unknown -): input is Record { - if (!isRecord(input)) return false; - if (input.type === "talespinner.instruction") return false; - return Object.keys(input).some((key) => ST_PRESET_DETECT_KEYS.has(key)); -} - -export function stripSensitiveFieldsFromPreset( - preset: Record -): Record { - const cloned = structuredClone(preset); - for (const key of ST_SENSITIVE_FIELDS) { - delete cloned[key]; - } - return cloned; -} - -export function normalizeStPrompts(input: unknown): StPrompt[] { - if (!Array.isArray(input)) return []; - return input.map(normalizePrompt).filter((item): item is StPrompt => Boolean(item)); -} - -export function normalizeStPromptOrder(input: unknown): StPromptOrder[] { - if (!Array.isArray(input)) return []; - return input - .map(normalizePromptOrderItem) - .filter((item): item is StPromptOrder => Boolean(item)); -} - -function normalizeResponseConfig( - preset: Record -): StAdvancedResponseConfig { - const responseConfig: StAdvancedResponseConfig = {}; - - const numericKeys: StResponseNumericKey[] = [ - "temperature", - "top_p", - "top_k", - "top_a", - "min_p", - "repetition_penalty", - "frequency_penalty", - "presence_penalty", - "openai_max_tokens", - "seed", - "n", - ]; - - for (const key of numericKeys) { - const value = toFiniteNumber(preset[key]); - if (typeof value === "number") { - responseConfig[key] = value; - } - } - - if (typeof preset.reasoning_effort === "string") { - responseConfig.reasoning_effort = preset.reasoning_effort; - } - if (typeof preset.verbosity === "string") { - responseConfig.verbosity = preset.verbosity; - } - const enableWebSearch = toOptionalBoolean(preset.enable_web_search); - if (typeof enableWebSearch === "boolean") { - responseConfig.enable_web_search = enableWebSearch; - } - const stream = toOptionalBoolean(preset.stream_openai); - if (typeof stream === "boolean") { - responseConfig.stream_openai = stream; - } - - return responseConfig; -} - -function resolvePreferredPromptOrderEntries( - promptOrder: StPromptOrder[] -): Array<{ identifier: string; enabled: boolean }> { - if (promptOrder.length === 0) return []; - const preferred = - promptOrder.find( - (item) => item.character_id === PROMPT_ORDER_PREFERRED_CHARACTER_ID - ) ?? promptOrder[0]; - return preferred.order; -} - -function resolveDynamicPromptContent(params: { - identifier: string; - context: InstructionRenderContext; -}): string { - const context = params.context; - const firstMessageExample = - typeof context.mesExamples === "string" - ? context.mesExamples - : typeof context.mesExamplesRaw === "string" - ? context.mesExamplesRaw - : ""; - - switch (params.identifier) { - case "worldInfoBefore": - return ( - context.wiBefore ?? - context.loreBefore ?? - context.anchorBefore ?? - "" - ); - case "worldInfoAfter": - return ( - context.wiAfter ?? - context.loreAfter ?? - context.anchorAfter ?? - "" - ); - case "charDescription": - return context.description ?? ""; - case "charPersonality": - return context.personality ?? ""; - case "scenario": - return context.scenario ?? ""; - case "personaDescription": - return context.persona ?? ""; - case "dialogueExamples": - return firstMessageExample; - default: - return ""; - } -} - -function toGatewaySettingsFromResponseConfig( - config: StAdvancedResponseConfig -): Record { - const settings: Record = {}; - - if (typeof config.temperature === "number") settings.temperature = config.temperature; - if (typeof config.top_p === "number") settings.top_p = config.top_p; - if (typeof config.top_k === "number") settings.top_k = config.top_k; - if (typeof config.top_a === "number") settings.top_a = config.top_a; - if (typeof config.min_p === "number") settings.min_p = config.min_p; - if (typeof config.repetition_penalty === "number") { - settings.repetition_penalty = config.repetition_penalty; - } - if (typeof config.frequency_penalty === "number") { - settings.frequency_penalty = config.frequency_penalty; - } - if (typeof config.presence_penalty === "number") { - settings.presence_penalty = config.presence_penalty; - } - if (typeof config.openai_max_tokens === "number") { - settings.maxTokens = config.openai_max_tokens; - } - if (typeof config.seed === "number") settings.seed = config.seed; - if (typeof config.n === "number") settings.n = config.n; - if (typeof config.reasoning_effort === "string") { - settings.reasoning_effort = config.reasoning_effort; - } - if (typeof config.verbosity === "string") settings.verbosity = config.verbosity; - if (typeof config.enable_web_search === "boolean") { - settings.enable_web_search = config.enable_web_search; - } - if (typeof config.stream_openai === "boolean") { - settings.stream = config.stream_openai; - } - - return settings; -} - -function normalizeInstructionMeta(meta: unknown): InstructionMeta { - if (!isRecord(meta)) return {}; - return { ...meta }; -} - -export function getTsInstructionMeta(meta: unknown): TsInstructionMetaV1 | null { - if (!isRecord(meta) || !isRecord(meta.tsInstruction)) return null; - const tsInstruction = meta.tsInstruction; - if (tsInstruction.version !== 1) return null; - if ( - tsInstruction.mode !== "basic" && - tsInstruction.mode !== "st_advanced" - ) { - return null; - } - return tsInstruction as TsInstructionMetaV1; -} - -export function withTsInstructionMeta(params: { - meta: unknown; - tsInstruction: TsInstructionMetaV1; -}): InstructionMeta { - const normalized = normalizeInstructionMeta(params.meta); - return { - ...normalized, - tsInstruction: params.tsInstruction, - }; -} - -export function createStAdvancedConfigFromPreset(params: { - preset: Record; - fileName: string; - sensitiveImportMode: SensitiveImportMode; -}): StAdvancedConfig { - const rawPreset = - params.sensitiveImportMode === "remove" - ? stripSensitiveFieldsFromPreset(params.preset) - : structuredClone(params.preset); - const prompts = normalizeStPrompts(rawPreset.prompts); - const promptOrder = normalizeStPromptOrder(rawPreset.prompt_order); - - return { - rawPreset, - prompts, - promptOrder, - responseConfig: normalizeResponseConfig(rawPreset), - importInfo: { - source: "sillytavern", - fileName: params.fileName, - importedAt: new Date().toISOString(), - }, - }; -} - -export async function resolveStAdvancedInstructionRuntime(params: { - stAdvanced: StAdvancedConfig; - context: InstructionRenderContext; -}): Promise { - const promptsByIdentifier = new Map( - params.stAdvanced.prompts.map((item) => [item.identifier, item] as const) - ); - const selectedOrder = resolvePreferredPromptOrderEntries( - params.stAdvanced.promptOrder - ); - const fallbackOrder = - selectedOrder.length > 0 - ? selectedOrder - : params.stAdvanced.prompts.map((item) => ({ - identifier: item.identifier, - enabled: true, - })); - - const preHistory: string[] = []; - const postHistory: string[] = []; - const usedPromptIdentifiers: string[] = []; - let afterHistory = false; - - for (const orderEntry of fallbackOrder) { - if (!orderEntry.enabled) continue; - const identifier = orderEntry.identifier; - if (!ST_SUPPORTED_PROMPT_IDENTIFIERS.has(identifier)) continue; - - if (identifier === "chatHistory") { - afterHistory = true; - usedPromptIdentifiers.push(identifier); - continue; - } - - const prompt = promptsByIdentifier.get(identifier) ?? null; - const rawContent = - typeof prompt?.content === "string" && prompt.content.trim().length > 0 - ? prompt.content - : resolveDynamicPromptContent({ - identifier, - context: params.context, - }); - if (!rawContent.trim()) continue; - - const rendered = await renderLiquidTemplate({ - templateText: rawContent, - context: params.context, - }); - if (!rendered.trim()) continue; - - if (afterHistory) postHistory.push(rendered); - else preHistory.push(rendered); - usedPromptIdentifiers.push(identifier); - } - - let systemPrompt = DEFAULT_SYSTEM_PROMPT; - let preHistorySystemMessages: string[] = []; - let postHistorySystemMessages = [...postHistory]; - - if (preHistory.length > 0) { - systemPrompt = preHistory[0]; - preHistorySystemMessages = preHistory.slice(1); - } else if (postHistory.length > 0) { - systemPrompt = postHistory[0]; - postHistorySystemMessages = postHistory.slice(1); - } - - return { - systemPrompt, - preHistorySystemMessages, - postHistorySystemMessages, - derivedSettings: toGatewaySettingsFromResponseConfig( - params.stAdvanced.responseConfig - ), - usedPromptIdentifiers, - }; -} diff --git a/server/src/services/chat-core/instructions-repository.test.ts b/server/src/services/chat-core/instructions-repository.test.ts new file mode 100644 index 00000000..271fa7ce --- /dev/null +++ b/server/src/services/chat-core/instructions-repository.test.ts @@ -0,0 +1,122 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { initDb, resetDbForTests } from "../../db/client"; +import { ensureInstructionsSchema } from "../../db/ensure-instructions-schema"; + +import { + createInstruction, + getInstructionById, +} from "./instructions-repository"; + +describe("instructions-repository", () => { + let tempDir = ""; + let dbPath = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-instructions-")); + dbPath = path.join(tempDir, "db.sqlite"); + }); + + afterEach(async () => { + resetDbForTests(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + test("round-trips both basic and st_base instructions", async () => { + const db = await initDb({ dbPath }); + await db.run(sql.raw("CREATE TABLE `chats` (`id` text PRIMARY KEY, `instruction_id` text);")); + await ensureInstructionsSchema(); + + const basic = await createInstruction({ + name: "Basic", + kind: "basic", + templateText: "{{char.name}}", + meta: { scope: "test" }, + }); + const stBase = await createInstruction({ + name: "ST", + kind: "st_base", + stBase: { + rawPreset: {}, + prompts: [{ identifier: "main", content: "Hello" }], + promptOrder: [ + { + character_id: 100001, + order: [{ identifier: "main", enabled: true }], + }, + ], + responseConfig: { temperature: 0.7 }, + importInfo: { + source: "sillytavern", + fileName: "Default.json", + importedAt: "2026-03-10T00:00:00.000Z", + }, + }, + meta: { scope: "test" }, + }); + + const loadedBasic = await getInstructionById(basic.id); + const loadedStBase = await getInstructionById(stBase.id); + + expect(loadedBasic).toMatchObject({ + kind: "basic", + templateText: "{{char.name}}", + meta: { scope: "test" }, + }); + expect(loadedStBase).toMatchObject({ + kind: "st_base", + stBase: expect.objectContaining({ + responseConfig: { temperature: 0.7 }, + }), + meta: { scope: "test" }, + }); + }); + + test("migrates legacy st_advanced rows to st_base storage", async () => { + const db = await initDb({ dbPath }); + await db.run( + sql.raw( + "CREATE TABLE `instructions` (" + + "`id` text PRIMARY KEY NOT NULL," + + "`owner_id` text DEFAULT 'global' NOT NULL," + + "`name` text NOT NULL," + + "`engine` text DEFAULT 'liquidjs' NOT NULL," + + "`template_text` text NOT NULL," + + "`meta_json` text," + + "`created_at` integer NOT NULL," + + "`updated_at` integer NOT NULL" + + ");" + ) + ); + await db.run(sql.raw("CREATE TABLE `chats` (`id` text PRIMARY KEY, `prompt_template_id` text);")); + await db.run( + sql.raw( + "INSERT INTO `instructions` (`id`, `owner_id`, `name`, `engine`, `template_text`, `meta_json`, `created_at`, `updated_at`) VALUES (" + + "'legacy-st', 'global', 'Legacy ST', 'liquidjs', 'ignored', " + + `json('{"tsInstruction":{"version":1,"mode":"st_advanced","stAdvanced":{"rawPreset":{},"prompts":[{"identifier":"main","content":"Hello"}],"promptOrder":[{"character_id":100001,"order":[{"identifier":"main","enabled":true}]}],"responseConfig":{"temperature":0.5},"importInfo":{"source":"sillytavern","fileName":"Default.json","importedAt":"2026-03-10T00:00:00.000Z"}}},"legacy":true}')` + + ", 1, 1);" + ) + ); + + await ensureInstructionsSchema(); + + const loaded = await getInstructionById("legacy-st"); + + expect(loaded).toMatchObject({ + id: "legacy-st", + kind: "st_base", + stBase: expect.objectContaining({ + responseConfig: { temperature: 0.5 }, + }), + meta: { legacy: true }, + }); + }); +}); diff --git a/server/src/services/chat-core/instructions-repository.ts b/server/src/services/chat-core/instructions-repository.ts index badc54c8..df13275a 100644 --- a/server/src/services/chat-core/instructions-repository.ts +++ b/server/src/services/chat-core/instructions-repository.ts @@ -1,31 +1,77 @@ -import { and, desc, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, desc, eq } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; import { chats, instructions } from "../../db/schema"; -import type { InstructionMeta } from "@shared/types/instructions"; +import type { + InstructionKind, + InstructionMeta, + StBaseConfig, +} from "@shared/types/instructions"; -export type InstructionDto = { +type InstructionDtoBase = { id: string; ownerId: string; name: string; engine: "liquidjs"; - templateText: string; meta: InstructionMeta | null; createdAt: Date; updatedAt: Date; }; +export type BasicInstructionDto = InstructionDtoBase & { + kind: "basic"; + templateText: string; +}; + +export type StBaseInstructionDto = InstructionDtoBase & { + kind: "st_base"; + stBase: StBaseConfig; +}; + +export type InstructionDto = BasicInstructionDto | StBaseInstructionDto; + +function parseInstructionMeta(value: string | null): InstructionMeta | null { + return safeJsonParse(value, null); +} + +function parseStBaseConfig(row: typeof instructions.$inferSelect): StBaseConfig { + const parsed = safeJsonParse(row.stBaseJson, null); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Instruction ${row.id} is missing st_base payload`); + } + return parsed as StBaseConfig; +} + function rowToDto(row: typeof instructions.$inferSelect): InstructionDto { + const meta = parseInstructionMeta(row.metaJson); + const kind = row.kind as InstructionKind; + + if (kind === "st_base") { + return { + id: row.id, + ownerId: row.ownerId, + name: row.name, + kind: "st_base", + engine: row.engine as "liquidjs", + stBase: parseStBaseConfig(row), + meta, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + return { id: row.id, ownerId: row.ownerId, name: row.name, + kind: "basic", engine: row.engine as "liquidjs", templateText: row.templateText, - meta: safeJsonParse(row.metaJson, null), + meta, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -60,8 +106,17 @@ export async function createInstruction(params: { ownerId?: string; name: string; engine?: "liquidjs"; + kind: "basic"; templateText: string; meta?: InstructionMeta; +} | { + id?: string; + ownerId?: string; + name: string; + engine?: "liquidjs"; + kind: "st_base"; + stBase: StBaseConfig; + meta?: InstructionMeta; }): Promise { const db = await initDb(); const ts = new Date(); @@ -71,8 +126,11 @@ export async function createInstruction(params: { id, ownerId: params.ownerId ?? "global", name: params.name, + kind: params.kind, engine: params.engine ?? "liquidjs", - templateText: params.templateText, + templateText: params.kind === "basic" ? params.templateText : "", + stBaseJson: + params.kind === "st_base" ? safeJsonStringify(params.stBase) : null, metaJson: typeof params.meta === "undefined" ? null @@ -83,12 +141,27 @@ export async function createInstruction(params: { const created = await getInstructionById(id); if (!created) { + if (params.kind === "basic") { + return { + id, + ownerId: params.ownerId ?? "global", + name: params.name, + kind: "basic", + engine: params.engine ?? "liquidjs", + templateText: params.templateText, + meta: params.meta ?? null, + createdAt: ts, + updatedAt: ts, + }; + } + return { id, ownerId: params.ownerId ?? "global", name: params.name, + kind: "st_base", engine: params.engine ?? "liquidjs", - templateText: params.templateText, + stBase: params.stBase, meta: params.meta ?? null, createdAt: ts, updatedAt: ts, @@ -99,21 +172,33 @@ export async function createInstruction(params: { export async function updateInstruction(params: { id: string; + kind: InstructionKind; name?: string; engine?: "liquidjs"; templateText?: string; + stBase?: StBaseConfig; meta?: InstructionMeta; }): Promise { const db = await initDb(); const ts = new Date(); - if (!(await getInstructionById(params.id))) return null; + const current = await getInstructionById(params.id); + if (!current) return null; + if (current.kind !== params.kind) { + throw new Error( + `Instruction ${params.id} kind mismatch: expected ${current.kind}, got ${params.kind}` + ); + } const set: Partial = { updatedAt: ts }; if (typeof params.name === "string") set.name = params.name; if (typeof params.engine === "string") set.engine = params.engine; - if (typeof params.templateText === "string") + if (params.kind === "basic" && typeof params.templateText === "string") { set.templateText = params.templateText; + } + if (params.kind === "st_base" && typeof params.stBase !== "undefined") { + set.stBaseJson = safeJsonStringify(params.stBase); + } if (typeof params.meta !== "undefined") { set.metaJson = safeJsonStringify(params.meta); diff --git a/server/src/services/chat-core/prompt-draft-builder.test.ts b/server/src/services/chat-core/prompt-draft-builder.test.ts index ca3b3141..7fc765fc 100644 --- a/server/src/services/chat-core/prompt-draft-builder.test.ts +++ b/server/src/services/chat-core/prompt-draft-builder.test.ts @@ -81,8 +81,8 @@ describe("prompt-draft-builder depth insertions", () => { branchId: "branch", systemPrompt: "SYS", depthInsertions: [ - { depth: 1, role: "system", content: "S1" }, - { depth: 2, role: "assistant", content: "A2" }, + { depth: 1, role: "system", order: 100, content: "S1" }, + { depth: 2, role: "assistant", order: 100, content: "A2" }, ], }); @@ -95,4 +95,36 @@ describe("prompt-draft-builder depth insertions", () => { { role: "user", content: "U2" }, ]); }); + + test("orders same-depth insertions by order then role", async () => { + mocks.listProjectedPromptMessages.mockResolvedValue({ + currentTurn: 2, + entryCount: 2, + messages: [ + { role: "user", content: "U1" }, + { role: "assistant", content: "A1" }, + ], + }); + + const out = await buildPromptDraft({ + chatId: "chat", + branchId: "branch", + systemPrompt: "", + depthInsertions: [ + { depth: 1, role: "system", order: 100, content: "S100" }, + { depth: 1, role: "assistant", order: 100, content: "A100" }, + { depth: 1, role: "user", order: 100, content: "U100" }, + { depth: 1, role: "assistant", order: 10, content: "A010" }, + ], + }); + + expect(out.draft.messages).toEqual([ + { role: "user", content: "U1" }, + { role: "assistant", content: "A010" }, + { role: "assistant", content: "A100" }, + { role: "user", content: "U100" }, + { role: "system", content: "S100" }, + { role: "assistant", content: "A1" }, + ]); + }); }); diff --git a/server/src/services/chat-core/prompt-draft-builder.ts b/server/src/services/chat-core/prompt-draft-builder.ts index 114ae0ba..52d7e228 100644 --- a/server/src/services/chat-core/prompt-draft-builder.ts +++ b/server/src/services/chat-core/prompt-draft-builder.ts @@ -130,7 +130,7 @@ export async function buildPromptDraft(params: { ownerId?: string; chatId: string; branchId: string; - systemPrompt: string; + systemPrompt?: string; historyLimit?: number; excludeMessageIds?: string[]; excludeEntryIds?: string[]; @@ -139,6 +139,7 @@ export async function buildPromptDraft(params: { depthInsertions?: Array<{ depth: number; role: "system" | "user" | "assistant"; + order?: number; content: string; }>; worldInfoMeta?: PromptSnapshotV1["meta"]["worldInfo"]; @@ -165,7 +166,21 @@ export async function buildPromptDraft(params: { // Pipelines/artifacts were removed. Keep prompt drafting minimal and deterministic. const systemPrompt = params.systemPrompt ?? ""; const historyWithDepthInsertions = history.map((m) => ({ role: m.role, content: m.content })); - for (const insertion of params.depthInsertions ?? []) { + const sortedDepthInsertions = (params.depthInsertions ?? []) + .map((insertion, index) => ({ ...insertion, order: insertion.order ?? 100, index })) + .sort((left, right) => { + if (left.depth !== right.depth) return left.depth - right.depth; + if (left.order !== right.order) return left.order - right.order; + + const rolePriority = { assistant: 0, user: 1, system: 2 } as const; + if (rolePriority[left.role] !== rolePriority[right.role]) { + return rolePriority[left.role] - rolePriority[right.role]; + } + + return left.index - right.index; + }); + + for (const insertion of sortedDepthInsertions) { const normalizedDepth = Number.isFinite(insertion.depth) && insertion.depth > 0 ? Math.floor(insertion.depth) @@ -179,7 +194,9 @@ export async function buildPromptDraft(params: { const draft: PromptDraft = { messages: [ - { role: "system", content: systemPrompt }, + ...(systemPrompt.trim() + ? [{ role: "system" as const, content: systemPrompt }] + : []), ...(params.preHistorySystemMessages ?? []).map((content) => ({ role: "system" as const, content, diff --git a/server/src/services/chat-core/prompt-template-context.ts b/server/src/services/chat-core/prompt-template-context.ts index f30fff9b..44026801 100644 --- a/server/src/services/chat-core/prompt-template-context.ts +++ b/server/src/services/chat-core/prompt-template-context.ts @@ -142,6 +142,13 @@ function cloneTemplateContextForWorldInfoRender( anBottom: context.anBottom ? [...context.anBottom] : [], emTop: context.emTop ? [...context.emTop] : [], emBottom: context.emBottom ? [...context.emBottom] : [], + worldInfo: context.worldInfo + ? { + activatedCount: context.worldInfo.activatedCount, + activatedEntries: context.worldInfo.activatedEntries.map((entry) => ({ ...entry })), + warnings: [...context.worldInfo.warnings], + } + : undefined, }; applyWorldInfoToTemplateContext(cloned, worldInfo); return cloned; @@ -429,6 +436,15 @@ export async function resolveAndApplyWorldInfoToTemplateContext(params: { context: params.context, }); applyWorldInfoToTemplateContext(params.context, resolved); + params.context.worldInfo = { + activatedCount: resolved.activatedCount, + activatedEntries: resolved.activatedEntries.map((entry) => ({ + ...entry, + matchedKeys: [...entry.matchedKeys], + reasons: [...entry.reasons], + })), + warnings: [...resolved.warnings], + }; return resolved; } @@ -485,6 +501,11 @@ export async function buildInstructionRenderContext(params: { chat: {}, messages: [], rag: {}, + worldInfo: { + activatedCount: 0, + activatedEntries: [], + warnings: [], + }, art: {}, now: new Date().toISOString(), }; @@ -539,6 +560,11 @@ export async function buildInstructionRenderContext(params: { }, messages: history.map((m) => ({ role: m.role, content: m.content })), rag: {}, + worldInfo: { + activatedCount: 0, + activatedEntries: [], + warnings: [], + }, art: {}, now: new Date().toISOString(), }, params.worldInfo); diff --git a/server/src/services/chat-core/prompt-template-renderer.test.ts b/server/src/services/chat-core/prompt-template-renderer.test.ts index 150244d3..32232658 100644 --- a/server/src/services/chat-core/prompt-template-renderer.test.ts +++ b/server/src/services/chat-core/prompt-template-renderer.test.ts @@ -22,6 +22,10 @@ describe("prompt-template-renderer", () => { expect(() => validateLiquidTemplate("Hello {{ user.name }}")).not.toThrow(); expect(() => validateLiquidTemplate("{{outlet::default}}")).not.toThrow(); expect(() => validateLiquidTemplate("{{random::A::B}}")).not.toThrow(); + expect(() => validateLiquidTemplate("{{// Marinara's Spaghetti Recipe }}")).not.toThrow(); + expect(() => validateLiquidTemplate("{{setvar::prompt::an excellent protagonist}}")).not.toThrow(); + expect(() => validateLiquidTemplate("You are {{getvar::prompt}}!")).not.toThrow(); + expect(() => validateLiquidTemplate("{{ recentMessages(2) | size }}")).not.toThrow(); expect(() => validateLiquidTemplate("{{ broken ")).toThrow(); }); @@ -83,6 +87,25 @@ describe("prompt-template-renderer", () => { expect(rendered).toBe("X=OUTLET_TEXT"); }); + test("removes ST line comment macros before Liquid parsing", async () => { + const rendered = await renderLiquidTemplate({ + templateText: "A{{// Marinara's Spaghetti Recipe }}B", + context: makeContext(), + }); + + expect(rendered).toBe("AB"); + }); + + test("supports ST setvar and getvar macros during one render", async () => { + const rendered = await renderLiquidTemplate({ + templateText: + "{{setvar::prompt::an excellent protagonist}}{{trim}}You are {{getvar::prompt}}!", + context: makeContext(), + }); + + expect(rendered).toBe("You are an excellent protagonist!"); + }); + test("trim macro removes surrounding blank lines between WI blocks", async () => { const rendered = await renderLiquidTemplate({ templateText: "Start\n\n{{ wiBefore }}\n{{ trim }}\n\n{{ wiAfter }}\n\nEnd", @@ -164,4 +187,87 @@ describe("prompt-template-renderer", () => { expect(rendered).toBe("Bad={{random:: }}"); }); + + test("renders recentMessages(count) after preprocessing and keeps chronological order", async () => { + const rendered = await renderLiquidTemplate({ + templateText: + "{% assign msgs = recentMessages(3) %}{% for m in msgs %}[{{m.role}}={{m.content}}]{% endfor %}", + context: { + ...makeContext(), + messages: [ + { role: "system", content: "S0" }, + { role: "assistant", content: "A1" }, + { role: "user", content: "U1" }, + { role: "assistant", content: "A2" }, + { role: "user", content: "U2" }, + ], + }, + }); + + expect(rendered).toBe("[user=U1][assistant=A2][user=U2]"); + }); + + test("recentMessagesText(count) formats conversational messages as role-prefixed lines", async () => { + const rendered = await renderLiquidTemplate({ + templateText: "{{ recentMessagesText(2) }}", + context: { + ...makeContext(), + messages: [ + { role: "assistant", content: "A1" }, + { role: "system", content: "S0" }, + { role: "user", content: "U1" }, + { role: "assistant", content: "A2" }, + ], + }, + }); + + expect(rendered).toBe("user: U1\nassistant: A2"); + }); + + test("recentMessagesByContextTokens(tokenLimit) uses chars-per-4 heuristic and rounds upward", async () => { + const rendered = await renderLiquidTemplate({ + templateText: + "{% assign msgs = recentMessagesByContextTokens(4) %}{% for m in msgs %}[{{m.role}}={{m.content}}]{% endfor %}", + context: { + ...makeContext(), + messages: [ + { role: "user", content: "12345678" }, + { role: "assistant", content: "123456789012" }, + { role: "user", content: "1234" }, + ], + }, + }); + + expect(rendered).toBe("[assistant=123456789012][user=1234]"); + }); + + test("recentMessagesByContextTokensText(tokenLimit) includes the overflowing message and excludes system", async () => { + const rendered = await renderLiquidTemplate({ + templateText: "{{ recentMessagesByContextTokensText(3) }}", + context: { + ...makeContext(), + messages: [ + { role: "user", content: "12345678901234567890" }, + { role: "system", content: "ignored" }, + { role: "assistant", content: "12345678" }, + { role: "user", content: "1234" }, + ], + }, + }); + + expect(rendered).toBe("assistant: 12345678\nuser: 1234"); + }); + + test("recent message helpers return empty values for invalid or non-positive args", async () => { + const rendered = await renderLiquidTemplate({ + templateText: + "A={{ recentMessages(0) | size }}|B={{ recentMessagesText(-1) }}|C={{ recentMessagesByContextTokens() | size }}|D={{ recentMessagesByContextTokensText('bad') }}", + context: { + ...makeContext(), + messages: [{ role: "user", content: "U1" }], + }, + }); + + expect(rendered).toBe("A=0|B=|C=0|D="); + }); }); diff --git a/server/src/services/chat-core/prompt-template-renderer.ts b/server/src/services/chat-core/prompt-template-renderer.ts index 76c0385e..797169ba 100644 --- a/server/src/services/chat-core/prompt-template-renderer.ts +++ b/server/src/services/chat-core/prompt-template-renderer.ts @@ -1,14 +1,36 @@ import { Liquid } from "liquidjs"; +import { + INTERNAL_MESSAGE_HELPER_FILTER, + preprocessSillyTavernTemplateSyntax, + type SillyTavernTemplateVariables, + stripSillyTavernTrimSentinel, +} from "./sillytavern-template-syntax"; + export interface InstructionRenderContext { char: unknown; user: unknown; chat: unknown; messages: Array<{ role: string; content: string }>; rag: unknown; - // Persisted pipeline artifacts materialized as `art..value/history`. - // v1: chat-scoped session only. + worldInfo?: { + activatedCount: number; + activatedEntries: Array<{ + hash: string; + bookId: string; + bookName: string; + uid: number; + comment: string; + content: string; + matchedKeys: string[]; + reasons: string[]; + }>; + warnings: string[]; + }; + // Artifacts materialized as `art..value/history`. + // `artByOpId` is a convenience alias for cross-operation references from templates. art?: Record; + artByOpId?: Record; now: string; // --- SillyTavern-like convenience variables (compat layer). @@ -37,124 +59,133 @@ export interface InstructionRenderContext { lastAssistantMessage?: string; } -const engine = new Liquid({ - cache: true, - strictFilters: false, - strictVariables: false, -}); +type RenderableMessage = { role: string; content: string }; const DEFAULT_MAX_PASSES = 5; const DEFAULT_MAX_OUTPUT_CHARS = 200_000; -const TRIM_SENTINEL = "__TS_LIQUID_TRIM_SENTINEL__"; -const MACRO_TAG_RE = /{{\s*([^{}]*?)\s*}}/g; -function sanitizeOutletKey(value: string): string { - // Keep keys printable and stable for object lookup. - return value.trim().replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +function sanitizeRenderableMessages(messages: unknown): RenderableMessage[] { + return Array.isArray(messages) + ? messages.filter( + (item): item is RenderableMessage => + typeof item?.role === "string" && typeof item?.content === "string" + ) + : []; } -function clampRngValue(value: number): number { - if (!Number.isFinite(value)) return 0; - if (value <= 0) return 0; - if (value >= 1) return 0.999_999_999_999; - return value; +function isConversationRole(role: string): role is "user" | "assistant" { + return role === "user" || role === "assistant"; } -function pickRandomOption(options: string[], rng: () => number): string { - const idx = Math.floor(clampRngValue(rng()) * options.length); - return options[idx] ?? options[0] ?? ""; +function getConversationalMessages(messages: RenderableMessage[]): RenderableMessage[] { + return messages.filter((message) => isConversationRole(message.role)); } -function resolveRandomMacro(rawMacroBody: string, rng: () => number): string | null { - const prefix = "random::"; - if (!rawMacroBody.startsWith(prefix)) return null; - const tail = rawMacroBody.slice(prefix.length); - if (!tail) return null; - - const options = tail - .split("::") - .map((item) => item.trim()) - .filter((item) => item.length > 0); - if (options.length === 0) return null; - - return pickRandomOption(options, rng); +function approxTokensByChars(chars: number): number { + if (!Number.isFinite(chars)) return 0; + const normalized = Math.max(0, Math.floor(chars)); + if (normalized === 0) return 0; + return Math.ceil(normalized / 4); } -function preprocessSillyTavernTemplateSyntax( - templateText: string, - options?: { rng?: () => number } -): { - text: string; - hasTrimSentinel: boolean; -} { - const rng = options?.rng ?? Math.random; - let hasTrimSentinel = false; - const text = templateText.replace(MACRO_TAG_RE, (full: string, rawMacroBody: string) => { - const macroBody = rawMacroBody.trim(); - if (!macroBody) return full; - - if (macroBody === "trim") { - hasTrimSentinel = true; - return TRIM_SENTINEL; - } - - if (macroBody.startsWith("outlet::")) { - const rawKey = macroBody.slice("outlet::".length); - if (!rawKey.trim()) return full; - const key = sanitizeOutletKey(rawKey); - return `{{ outlet['${key}'] }}`; - } - - if (macroBody.startsWith("random::")) { - const selected = resolveRandomMacro(macroBody, rng); - if (selected !== null) return selected; - // Keep malformed random macro literal in output. - return `{% raw %}${full}{% endraw %}`; - } - - return full; - }); +function normalizePositiveIntegerArg(value: unknown): number | null { + const raw = + typeof value === "string" && value.trim().length > 0 ? Number(value.trim()) : value; + if (typeof raw !== "number" || !Number.isFinite(raw)) return null; + const normalized = Math.floor(raw); + return normalized > 0 ? normalized : null; +} - return { text, hasTrimSentinel }; +function formatMessagesAsText(messages: RenderableMessage[]): string { + return messages.map((message) => `${message.role}: ${message.content}`).join("\n"); } -function isBlankLine(line: string): boolean { - return line.trim().length === 0; +function selectRecentMessages( + messages: RenderableMessage[], + count: number +): RenderableMessage[] { + if (count <= 0) return []; + return messages.slice(-count); } -function stripTrimSentinel(text: string): string { - if (!text.includes(TRIM_SENTINEL)) return text; +function selectRecentMessagesByTokenLimit( + messages: RenderableMessage[], + tokenLimit: number +): RenderableMessage[] { + if (tokenLimit <= 0) return []; + const selected: RenderableMessage[] = []; + let accumulatedTokens = 0; - const normalized = text.replace(/\r\n/g, "\n"); - const lines = normalized.split("\n"); - const out: string[] = []; - let skipLeadingBlankLines = false; + for (let idx = messages.length - 1; idx >= 0; idx -= 1) { + const message = messages[idx]; + if (!message) continue; + selected.push(message); + accumulatedTokens += approxTokensByChars(message.content.length); + if (accumulatedTokens >= tokenLimit) break; + } - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === TRIM_SENTINEL) { - while (out.length > 0 && isBlankLine(out[out.length - 1] ?? "")) out.pop(); - skipLeadingBlankLines = true; - continue; - } + selected.reverse(); + return selected; +} - if (line.includes(TRIM_SENTINEL)) { - const replaced = line.split(TRIM_SENTINEL).join(""); - if (!(skipLeadingBlankLines && isBlankLine(replaced))) { - out.push(replaced); - } - if (!isBlankLine(replaced)) skipLeadingBlankLines = false; - continue; - } +function resolveRecentMessagesHelper( + helperName: unknown, + rawArg: unknown, + messages: RenderableMessage[] +): RenderableMessage[] | string { + const normalizedHelperName = typeof helperName === "string" ? helperName.trim() : ""; + const normalizedArg = normalizePositiveIntegerArg(rawArg); + const emptyArrayResult: RenderableMessage[] = []; + const emptyTextResult = ""; + const shouldReturnText = normalizedHelperName.endsWith("Text"); + if (!normalizedArg) { + return shouldReturnText ? emptyTextResult : emptyArrayResult; + } - if (skipLeadingBlankLines && isBlankLine(line)) continue; - out.push(line); - if (!isBlankLine(line)) skipLeadingBlankLines = false; + const conversationalMessages = getConversationalMessages(messages); + const selectedMessages = + normalizedHelperName === "recentMessages" + ? selectRecentMessages(conversationalMessages, normalizedArg) + : normalizedHelperName === "recentMessagesText" + ? selectRecentMessages(conversationalMessages, normalizedArg) + : normalizedHelperName === "recentMessagesByContextTokens" + ? selectRecentMessagesByTokenLimit(conversationalMessages, normalizedArg) + : normalizedHelperName === "recentMessagesByContextTokensText" + ? selectRecentMessagesByTokenLimit(conversationalMessages, normalizedArg) + : null; + + if (!selectedMessages) { + return shouldReturnText ? emptyTextResult : emptyArrayResult; } - return out.join("\n"); + return shouldReturnText ? formatMessagesAsText(selectedMessages) : selectedMessages; +} + +function registerInternalFilters(liquid: Liquid): Liquid { + liquid.registerFilter( + INTERNAL_MESSAGE_HELPER_FILTER, + function (_input: unknown, helperName: unknown, rawArg: unknown) { + const filterContext = this as { + context?: { environments?: { messages?: unknown } }; + }; + const messages = sanitizeRenderableMessages( + filterContext.context?.environments?.messages + ); + return resolveRecentMessagesHelper(helperName, rawArg, messages); + } + ); + liquid.registerFilter("json", (input: unknown) => JSON.stringify(input)); + return liquid; } +const engine = registerInternalFilters( + new Liquid({ + cache: true, + strictFilters: false, + strictVariables: false, + }) +); + function normalizeToString(value: unknown): string { return typeof value === "string" ? value : String(value); } @@ -174,12 +205,7 @@ function findLastMessageByRole( function withDerivedMessageAliases( context: InstructionRenderContext ): InstructionRenderContext { - const messages = Array.isArray(context.messages) - ? context.messages.filter( - (item): item is { role: string; content: string } => - typeof item?.role === "string" && typeof item?.content === "string" - ) - : []; + const messages = sanitizeRenderableMessages(context.messages); // These aliases are derived from effective prompt-visible history. // before_main_llm: lastAssistantMessage is the latest assistant message before current user turn. @@ -227,10 +253,13 @@ export async function renderLiquidTemplate(params: { * Useful for deterministic tests. */ rng?: () => number; + stVariables?: SillyTavernTemplateVariables; }; }): Promise { const renderEngine = params.options?.strictVariables - ? new Liquid({ cache: true, strictFilters: false, strictVariables: true }) + ? registerInternalFilters( + new Liquid({ cache: true, strictFilters: false, strictVariables: true }) + ) : engine; const renderContext = withDerivedMessageAliases(params.context); const maxPasses = params.options?.maxPasses ?? DEFAULT_MAX_PASSES; @@ -239,6 +268,7 @@ export async function renderLiquidTemplate(params: { const firstPassPreprocessed = preprocessSillyTavernTemplateSyntax(params.templateText, { rng: params.options?.rng, + variables: params.options?.stVariables, }); sawTrimSentinel = sawTrimSentinel || firstPassPreprocessed.hasTrimSentinel; @@ -249,7 +279,7 @@ export async function renderLiquidTemplate(params: { // Additional passes: render again only if the output still looks like a template. // Important: if the output contains `{{` for non-template reasons (e.g. documentation/code), - // the next parse may throw — in that case we stop and return the previous output. + // the next parse may throw — in that case we stop and return the previous output. for (let pass = 2; pass <= maxPasses; pass++) { if (!mightContainLiquidSyntax(current)) break; if (current.length > maxOutputChars) break; @@ -257,6 +287,7 @@ export async function renderLiquidTemplate(params: { try { const passPreprocessed = preprocessSillyTavernTemplateSyntax(current, { rng: params.options?.rng, + variables: params.options?.stVariables, }); sawTrimSentinel = sawTrimSentinel || passPreprocessed.hasTrimSentinel; const next = normalizeToString( @@ -270,8 +301,9 @@ export async function renderLiquidTemplate(params: { } if (sawTrimSentinel) { - return stripTrimSentinel(current); + return stripSillyTavernTrimSentinel(current); } return current; } + diff --git a/server/src/services/chat-core/sillytavern-template-syntax.ts b/server/src/services/chat-core/sillytavern-template-syntax.ts new file mode 100644 index 00000000..6e8e4f04 --- /dev/null +++ b/server/src/services/chat-core/sillytavern-template-syntax.ts @@ -0,0 +1,170 @@ +export const INTERNAL_MESSAGE_HELPER_FILTER = "__tsMessageHelper"; + +const RECENT_MESSAGE_HELPER_RE = + /\b(recentMessages(?:Text|ByContextTokens(?:Text)?)?)\s*\(\s*([^()]*)\s*\)/g; +const LIQUID_SEGMENT_RE = /({{[\s\S]*?}}|{%[\s\S]*?%})/g; +const MACRO_TAG_RE = /{{\s*([^{}]*?)\s*}}/g; +const TRIM_SENTINEL = "__TS_LIQUID_TRIM_SENTINEL__"; +const SETVAR_PREFIX = "setvar::"; +const GETVAR_PREFIX = "getvar::"; + +export type SillyTavernTemplateVariables = Record; + +function sanitizeOutletKey(value: string): string { + return value.trim().replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + +function clampRngValue(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value <= 0) return 0; + if (value >= 1) return 0.999_999_999_999; + return value; +} + +function pickRandomOption(options: string[], rng: () => number): string { + const idx = Math.floor(clampRngValue(rng()) * options.length); + return options[idx] ?? options[0] ?? ""; +} + +function resolveRandomMacro(rawMacroBody: string, rng: () => number): string | null { + const prefix = "random::"; + if (!rawMacroBody.startsWith(prefix)) return null; + const tail = rawMacroBody.slice(prefix.length); + if (!tail) return null; + + const options = tail + .split("::") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + if (options.length === 0) return null; + + return pickRandomOption(options, rng); +} + +function rewriteRecentMessageHelperCalls(templateText: string): string { + return templateText.replace(LIQUID_SEGMENT_RE, (segment: string) => { + const isOutputSegment = segment.startsWith("{{") && segment.endsWith("}}"); + const isTagSegment = segment.startsWith("{%") && segment.endsWith("%}"); + if (!isOutputSegment && !isTagSegment) return segment; + + const prefix = segment.slice(0, 2); + const suffix = segment.slice(-2); + const inner = segment.slice(2, -2); + const rewrittenInner = inner.replace( + RECENT_MESSAGE_HELPER_RE, + (_match: string, helperName: string, rawArg: string) => { + const normalizedArg = rawArg.trim().length > 0 ? rawArg.trim() : "nil"; + return `'' | ${INTERNAL_MESSAGE_HELPER_FILTER}: '${helperName}', ${normalizedArg}`; + } + ); + return `${prefix}${rewrittenInner}${suffix}`; + }); +} + +function parseSetVarMacro( + macroBody: string +): { key: string; value: string } | null { + if (!macroBody.startsWith(SETVAR_PREFIX)) return null; + const tail = macroBody.slice(SETVAR_PREFIX.length); + const separatorIndex = tail.indexOf("::"); + if (separatorIndex < 0) return null; + + const key = tail.slice(0, separatorIndex).trim(); + if (!key) return null; + + return { + key, + value: tail.slice(separatorIndex + 2), + }; +} + +function parseGetVarMacro(macroBody: string): string | null { + if (!macroBody.startsWith(GETVAR_PREFIX)) return null; + const key = macroBody.slice(GETVAR_PREFIX.length).trim(); + return key.length > 0 ? key : null; +} + +export function preprocessSillyTavernTemplateSyntax( + templateText: string, + options?: { rng?: () => number; variables?: SillyTavernTemplateVariables } +): { + text: string; + hasTrimSentinel: boolean; +} { + const rng = options?.rng ?? Math.random; + const variables = options?.variables ?? {}; + let hasTrimSentinel = false; + const text = rewriteRecentMessageHelperCalls(templateText).replace( + MACRO_TAG_RE, + (full: string, rawMacroBody: string) => { + const macroBody = rawMacroBody.trim(); + if (!macroBody) return full; + + if (macroBody === "trim") { + hasTrimSentinel = true; + return TRIM_SENTINEL; + } + + if (macroBody.startsWith("//")) return ""; + + const setVar = parseSetVarMacro(macroBody); + if (setVar) { + variables[setVar.key] = setVar.value; + return ""; + } + + const getVarKey = parseGetVarMacro(macroBody); + if (getVarKey) return variables[getVarKey] ?? ""; + + if (macroBody.startsWith("outlet::")) { + const rawKey = macroBody.slice("outlet::".length); + if (!rawKey.trim()) return full; + return `{{ outlet['${sanitizeOutletKey(rawKey)}'] }}`; + } + + if (macroBody.startsWith("random::")) { + const selected = resolveRandomMacro(macroBody, rng); + return selected !== null ? selected : `{% raw %}${full}{% endraw %}`; + } + + return full; + } + ); + + return { text, hasTrimSentinel }; +} + +function isBlankLine(line: string): boolean { + return line.trim().length === 0; +} + +export function stripSillyTavernTrimSentinel(text: string): string { + if (!text.includes(TRIM_SENTINEL)) return text; + + const normalized = text.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + const out: string[] = []; + let skipLeadingBlankLines = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === TRIM_SENTINEL) { + while (out.length > 0 && isBlankLine(out[out.length - 1] ?? "")) out.pop(); + skipLeadingBlankLines = true; + continue; + } + + if (line.includes(TRIM_SENTINEL)) { + const replaced = line.split(TRIM_SENTINEL).join(""); + if (!(skipLeadingBlankLines && isBlankLine(replaced))) out.push(replaced); + if (!isBlankLine(replaced)) skipLeadingBlankLines = false; + continue; + } + + if (skipLeadingBlankLines && isBlankLine(line)) continue; + out.push(line); + if (!isBlankLine(line)) skipLeadingBlankLines = false; + } + + return out.join("\n"); +} diff --git a/server/src/services/chat-core/st-prompt-runtime.ts b/server/src/services/chat-core/st-prompt-runtime.ts new file mode 100644 index 00000000..9dffe084 --- /dev/null +++ b/server/src/services/chat-core/st-prompt-runtime.ts @@ -0,0 +1,226 @@ +import { + ST_PROMPT_DEFAULT_DEPTH, + ST_PROMPT_DEFAULT_ORDER, + ST_PROMPT_INJECTION_POSITION, +} from "@shared/types/instructions"; +import { SILLY_TAVERN_PREFERRED_CHARACTER_ID } from "@shared/utils/sillytavern-preset"; +import { cloneStPromptWithDefaults } from "@shared/utils/st-prompts"; + +import { renderLiquidTemplate } from "./prompt-template-renderer"; + +import type { InstructionRenderContext } from "./prompt-template-renderer"; +import type { SillyTavernTemplateVariables } from "./sillytavern-template-syntax"; +import type { + StBaseConfig, + StBasePromptOrder, +} from "@shared/types/instructions"; + +type ResolvedDepthInsertion = { + depth: number; + role: "system" | "user" | "assistant"; + order: number; + content: string; +}; + +export type ResolvedStBaseInstruction = { + systemPrompt: string; + preHistorySystemMessages: string[]; + postHistorySystemMessages: string[]; + depthInsertions: ResolvedDepthInsertion[]; + derivedSettings: Record; + usedPromptIdentifiers: string[]; +}; + +function resolvePreferredPromptOrderEntries( + promptOrder: StBasePromptOrder[] +): Array<{ identifier: string; enabled: boolean }> { + if (promptOrder.length === 0) return []; + const preferred = + promptOrder.find( + (item) => item.character_id === SILLY_TAVERN_PREFERRED_CHARACTER_ID + ) ?? promptOrder[0]; + return preferred.order; +} + +function resolveDynamicPromptContent(params: { + identifier: string; + context: InstructionRenderContext; +}): string { + const context = params.context; + const firstMessageExample = + typeof context.mesExamples === "string" + ? context.mesExamples + : typeof context.mesExamplesRaw === "string" + ? context.mesExamplesRaw + : ""; + + switch (params.identifier) { + case "worldInfoBefore": + return context.wiBefore ?? context.loreBefore ?? context.anchorBefore ?? ""; + case "worldInfoAfter": + return context.wiAfter ?? context.loreAfter ?? context.anchorAfter ?? ""; + case "charDescription": + return context.description ?? ""; + case "charPersonality": + return context.personality ?? ""; + case "scenario": + return context.scenario ?? ""; + case "personaDescription": + return context.persona ?? ""; + case "dialogueExamples": + return firstMessageExample; + default: + return ""; + } +} + +async function renderPromptContent(params: { + identifier: string; + stBase: StBaseConfig; + context: InstructionRenderContext; + stVariables: SillyTavernTemplateVariables; +}): Promise<{ + content: string; + role: "system" | "user" | "assistant"; + absolute: boolean; + depth: number; + order: number; +} | null> { + const prompt = cloneStPromptWithDefaults( + params.stBase.prompts.find((item) => item.identifier === params.identifier) ?? { + identifier: params.identifier, + } + ); + + const rawContent = + typeof prompt.content === "string" && prompt.content.trim().length > 0 + ? prompt.content + : resolveDynamicPromptContent({ + identifier: params.identifier, + context: params.context, + }); + + if (!rawContent.trim()) return null; + + const rendered = await renderLiquidTemplate({ + templateText: rawContent, + context: params.context, + options: { + stVariables: params.stVariables, + }, + }); + const content = rendered.trim(); + if (!content) return null; + + return { + content, + role: prompt.role ?? "system", + absolute: + prompt.injection_position === ST_PROMPT_INJECTION_POSITION.IN_CHAT, + depth: prompt.injection_depth ?? ST_PROMPT_DEFAULT_DEPTH, + order: prompt.injection_order ?? ST_PROMPT_DEFAULT_ORDER, + }; +} + +export async function resolveStBaseInstructionRuntime(params: { + stBase: StBaseConfig; + context: InstructionRenderContext; +}): Promise { + const selectedOrder = resolvePreferredPromptOrderEntries( + params.stBase.promptOrder + ); + const fallbackOrder = + selectedOrder.length > 0 + ? selectedOrder + : params.stBase.prompts.map((item) => ({ + identifier: item.identifier, + enabled: true, + })); + + const mainOrderIndex = fallbackOrder.findIndex( + (item) => item.enabled && item.identifier === "main" + ); + const mainPrompt = + mainOrderIndex >= 0 + ? cloneStPromptWithDefaults( + params.stBase.prompts.find((item) => item.identifier === "main") ?? { + identifier: "main", + } + ) + : null; + const mainIsAbsolute = + mainPrompt?.injection_position === ST_PROMPT_INJECTION_POSITION.IN_CHAT; + + const preHistory: string[] = []; + const postHistory: string[] = []; + const depthInsertions: ResolvedDepthInsertion[] = []; + const usedPromptIdentifiers: string[] = []; + let afterHistory = false; + const stVariables: SillyTavernTemplateVariables = {}; + + for (const orderEntry of fallbackOrder) { + if (!orderEntry.enabled) continue; + const identifier = orderEntry.identifier; + + if (identifier === "chatHistory") { + afterHistory = true; + usedPromptIdentifiers.push(identifier); + continue; + } + + const rendered = await renderPromptContent({ + identifier, + stBase: params.stBase, + context: params.context, + stVariables, + }); + if (!rendered) continue; + usedPromptIdentifiers.push(identifier); + + if (mainIsAbsolute && !rendered.absolute && mainPrompt) { + depthInsertions.push({ + depth: mainPrompt.injection_depth ?? ST_PROMPT_DEFAULT_DEPTH, + role: rendered.role, + order: mainPrompt.injection_order ?? ST_PROMPT_DEFAULT_ORDER, + content: rendered.content, + }); + continue; + } + + if (rendered.absolute) { + depthInsertions.push({ + depth: rendered.depth, + role: rendered.role, + order: rendered.order, + content: rendered.content, + }); + continue; + } + + if (afterHistory) postHistory.push(rendered.content); + else preHistory.push(rendered.content); + } + + let systemPrompt = ""; + let preHistorySystemMessages: string[] = []; + let postHistorySystemMessages = [...postHistory]; + + if (!mainIsAbsolute) { + if (preHistory.length > 0) { + systemPrompt = preHistory[0]; + preHistorySystemMessages = preHistory.slice(1); + } else if (postHistory.length > 0) { + systemPrompt = postHistory[0]; + postHistorySystemMessages = postHistory.slice(1); + } + } + + return { + systemPrompt, + preHistorySystemMessages, + postHistorySystemMessages, + depthInsertions, + derivedSettings: {}, + usedPromptIdentifiers, + }; +} diff --git a/server/src/services/chat-core/user-persons-repository.ts b/server/src/services/chat-core/user-persons-repository.ts index d9b7a1ed..4f8d4eaf 100644 --- a/server/src/services/chat-core/user-persons-repository.ts +++ b/server/src/services/chat-core/user-persons-repository.ts @@ -1,6 +1,7 @@ -import { asc, desc, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { asc, desc, eq } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; import { userPersons, userPersonsSettings } from "../../db/schema"; diff --git a/server/src/services/chat-entry-parts/entries-repository.ts b/server/src/services/chat-entry-parts/entries-repository.ts index cc386752..8e6c66f5 100644 --- a/server/src/services/chat-entry-parts/entries-repository.ts +++ b/server/src/services/chat-entry-parts/entries-repository.ts @@ -1,6 +1,7 @@ -import { and, desc, eq, inArray, lt, or } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { type DbExecutor, initDb } from "../../db/client"; import { chatEntries, entryVariants } from "../../db/schema"; @@ -335,6 +336,34 @@ export async function listEntriesWithActiveVariantsPage(params: { }; } +export async function getLatestSelectedPersonaIdForChatBranch(params: { + chatId: string; + branchId: string; +}): Promise { + const db = await initDb(); + const rows = await db + .select({ + personaId: sql`json_extract(${chatEntries.metaJson}, '$.personaSnapshot.id')`, + }) + .from(chatEntries) + .where( + and( + eq(chatEntries.chatId, params.chatId), + eq(chatEntries.branchId, params.branchId), + eq(chatEntries.role, "user"), + eq(chatEntries.softDeleted, false), + sql`json_type(${chatEntries.metaJson}, '$.personaSnapshot.id') = 'text'` + ) + ) + .orderBy(desc(chatEntries.createdAt), desc(chatEntries.entryId)) + .limit(1); + + const personaId = rows[0]?.personaId; + if (typeof personaId !== "string") return null; + const trimmedPersonaId = personaId.trim(); + return trimmedPersonaId.length > 0 ? trimmedPersonaId : null; +} + export async function softDeleteEntry(params: { entryId: string; by: "user" | "agent" }): Promise { const db = await initDb(); await db diff --git a/server/src/services/chat-entry-parts/parts-repository.ts b/server/src/services/chat-entry-parts/parts-repository.ts index a537b964..a925c167 100644 --- a/server/src/services/chat-entry-parts/parts-repository.ts +++ b/server/src/services/chat-entry-parts/parts-repository.ts @@ -1,6 +1,7 @@ -import { and, eq, inArray } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { type DbExecutor, initDb } from "../../db/client"; import { entryVariants, variantParts } from "../../db/schema"; @@ -80,12 +81,12 @@ export async function listPartsForVariants(params: { return map; } -type CreatePartParams = { +export type CreatePartParams = { ownerId?: string; variantId: string; channel: PartChannel; order: number; - payload: string | object; + payload: string | object | number | boolean | null; payloadFormat: PartPayloadFormat; schemaId?: string; label?: string; @@ -98,7 +99,7 @@ type CreatePartParams = { agentId?: string; model?: string; requestId?: string; - replacesPartId?: string; + replacesPartId?: string | null; tags?: string[]; executor?: DbExecutor; }; @@ -166,7 +167,7 @@ export function createPart(params: CreatePartParams): Promise | Part { agentId: params.agentId, model: params.model, requestId: params.requestId, - replacesPartId: params.replacesPartId, + replacesPartId: params.replacesPartId ?? undefined, softDeleted: false, tags: params.tags, }; @@ -385,13 +386,31 @@ export type PartMutableBatchPatch = { softDeletedBy?: "user" | "agent" | null; }; +export type PartMutableBatchCreate = Omit & { + clientPartId: string; +}; + +export type PartMutableBatchApplyResult = { + createdParts: Array<{ clientPartId: string; partId: string }>; +}; + export async function applyPartMutableBatchPatches(params: { variantId: string; patches: PartMutableBatchPatch[]; -}): Promise { + creates?: PartMutableBatchCreate[]; + deletePartIds?: string[]; +}): Promise { const db = await initDb(); + const createdParts: Array<{ clientPartId: string; partId: string }> = []; await db.transaction((tx) => { + for (const partId of params.deletePartIds ?? []) { + tx + .delete(variantParts) + .where(and(eq(variantParts.partId, partId), eq(variantParts.variantId, params.variantId))) + .run(); + } + for (const patch of params.patches) { tx .update(variantParts) @@ -421,6 +440,17 @@ export async function applyPartMutableBatchPatches(params: { ) .run(); } + + for (const create of params.creates ?? []) { + const created = createPart({ + ...create, + variantId: params.variantId, + executor: tx, + }); + createdParts.push({ clientPartId: create.clientPartId, partId: created.partId }); + } }); + + return { createdParts }; } diff --git a/server/src/services/chat-entry-parts/variants-repository.ts b/server/src/services/chat-entry-parts/variants-repository.ts index ab0a2d65..8c614dad 100644 --- a/server/src/services/chat-entry-parts/variants-repository.ts +++ b/server/src/services/chat-entry-parts/variants-repository.ts @@ -1,6 +1,7 @@ -import { and, eq, inArray } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { type DbExecutor, initDb } from "../../db/client"; import { chatEntries, entryVariants } from "../../db/schema"; diff --git a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts index 44177777..2ae7561d 100644 --- a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts +++ b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts @@ -1,25 +1,25 @@ -import { and, eq, inArray } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../../chat-core/json"; import { initDb } from "../../../db/client"; import { operationProfileSessionArtifacts } from "../../../db/schema"; import type { ArtifactValue } from "../contracts"; +import type { OperationActivationState } from "../operations/operation-activation-intervals"; import type { + ArtifactFormat, ArtifactSemantics, - ArtifactUsage, + ArtifactWriteMode, OperationProfile, } from "@shared/types/operation-profiles"; -import type { OperationActivationState } from "../operations/operation-activation-intervals"; - -const MAX_HISTORY_ITEMS = 20; const INTERNAL_OPERATION_ACTIVATION_TAG_PREFIX = "__sys_op_activation__:"; -function normalizeHistory(input: unknown, nextValue: string): string[] { - const parsed = Array.isArray(input) ? input.filter((v): v is string => typeof v === "string") : []; - return [...parsed, nextValue].slice(-MAX_HISTORY_ITEMS); +function normalizeHistory(input: unknown, nextValue: unknown, maxItems: number): unknown[] { + const parsed = Array.isArray(input) ? input : []; + return [...parsed, nextValue].slice(-maxItems); } function normalizeActivationState(input: unknown): OperationActivationState { @@ -58,16 +58,17 @@ export class ProfileSessionArtifactStore { const out: Record = {}; for (const row of rows) { if (row.tag.startsWith(INTERNAL_OPERATION_ACTIVATION_TAG_PREFIX)) continue; - const usage = (row.usage ?? "internal") as ArtifactUsage; + const format = ((row.usage ?? "markdown") as ArtifactFormat) || "markdown"; const semantics = (row.semantics ?? "intermediate") as ArtifactSemantics; - const value = safeJsonParse(row.valueJson, ""); - const history = safeJsonParse(row.historyJson, []); + const value = safeJsonParse(row.valueJson, ""); + const history = safeJsonParse(row.historyJson, []); out[row.tag] = { - usage, + format, semantics, persistence: "persisted", + writeMode: "replace", value, - history: Array.isArray(history) ? history.filter((v) => typeof v === "string") : [], + history: Array.isArray(history) ? history : [], }; } return out; @@ -110,9 +111,14 @@ export class ProfileSessionArtifactStore { branchId: string; profile: OperationProfile | null; tag: string; - usage: ArtifactUsage; + format: ArtifactFormat; semantics: ArtifactSemantics; - value: string; + writeMode: ArtifactWriteMode; + history: { + enabled: boolean; + maxItems: number; + }; + value: unknown; }): Promise { const db = await initDb(); const existingRows = await db @@ -128,15 +134,21 @@ export class ProfileSessionArtifactStore { const now = new Date(); const existing = existingRows[0]; - const history = normalizeHistory(existing ? safeJsonParse(existing.historyJson, []) : [], params.value); + const history = params.history.enabled + ? normalizeHistory( + existing ? safeJsonParse(existing.historyJson, []) : [], + params.value, + params.history.maxItems + ) + : []; if (existing) { await db .update(operationProfileSessionArtifacts) .set({ - usage: params.usage, + usage: params.format, semantics: params.semantics, - valueJson: safeJsonStringify(params.value, "\"\""), + valueJson: safeJsonStringify(params.value, "null"), historyJson: safeJsonStringify(history, "[]"), updatedAt: now, }) @@ -152,18 +164,19 @@ export class ProfileSessionArtifactStore { profileVersion: params.profile?.version ?? null, operationProfileSessionId: params.profile?.operationProfileSessionId ?? null, tag: params.tag, - usage: params.usage, + usage: params.format, semantics: params.semantics, - valueJson: safeJsonStringify(params.value, "\"\""), + valueJson: safeJsonStringify(params.value, "null"), historyJson: safeJsonStringify(history, "[]"), updatedAt: now, }); } return { - usage: params.usage, + format: params.format, semantics: params.semantics, persistence: "persisted", + writeMode: params.writeMode, value: params.value, history, }; @@ -213,7 +226,7 @@ export class ProfileSessionArtifactStore { profileVersion: params.profile?.version ?? null, operationProfileSessionId: params.profile?.operationProfileSessionId ?? null, tag, - usage: "internal", + usage: "json", semantics: "state", valueJson: safeJsonStringify(params.state, "{}"), historyJson: "[]", diff --git a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts index a3009ded..67045e93 100644 --- a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts +++ b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts @@ -12,16 +12,19 @@ describe("RunArtifactStore", () => { const store = new RunArtifactStore(); const artifact = store.upsert({ - tag: "world_state", - usage: "internal", + artifactId: "world_state", + format: "markdown", semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, value: "v1", }); expect(artifact).toEqual({ - usage: "internal", + format: "markdown", semantics: "intermediate", persistence: "run_only", + writeMode: "replace", value: "v1", history: ["v1"], }); @@ -32,22 +35,27 @@ describe("RunArtifactStore", () => { const store = new RunArtifactStore(); store.upsert({ - tag: "memory", - usage: "internal", + artifactId: "memory", + format: "markdown", semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, value: "first", }); const second = store.upsert({ - tag: "memory", - usage: "prompt_only", + artifactId: "memory", + format: "json", semantics: "state", + writeMode: "append", + history: { enabled: true, maxItems: 20 }, value: "second", }); expect(second).toEqual({ - usage: "prompt_only", + format: "json", semantics: "state", persistence: "run_only", + writeMode: "append", value: "second", history: ["first", "second"], }); @@ -56,30 +64,36 @@ describe("RunArtifactStore", () => { test("snapshot returns record with all tags", () => { const store = new RunArtifactStore(); store.upsert({ - tag: "a", - usage: "internal", + artifactId: "a", + format: "markdown", semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, value: "1", }); store.upsert({ - tag: "b", - usage: "ui_only", + artifactId: "b", + format: "text", semantics: "log/feed", + writeMode: "append", + history: { enabled: true, maxItems: 20 }, value: "2", }); expect(store.snapshot()).toEqual({ a: { - usage: "internal", + format: "markdown", semantics: "intermediate", persistence: "run_only", + writeMode: "replace", value: "1", history: ["1"], }, b: { - usage: "ui_only", + format: "text", semantics: "log/feed", persistence: "run_only", + writeMode: "append", value: "2", history: ["2"], }, diff --git a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts index a508561e..203cb241 100644 --- a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts +++ b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts @@ -1,5 +1,9 @@ import type { ArtifactValue } from "../contracts"; -import type { ArtifactUsage, ArtifactSemantics } from "@shared/types/operation-profiles"; +import type { + ArtifactFormat, + ArtifactSemantics, + ArtifactWriteMode, +} from "@shared/types/operation-profiles"; export class RunArtifactStore { @@ -16,32 +20,46 @@ export class RunArtifactStore { } upsert(params: { - tag: string; - usage: ArtifactUsage; + artifactId: string; + format: ArtifactFormat; semantics: ArtifactSemantics; - value: string; + writeMode: ArtifactWriteMode; + history: { + enabled: boolean; + maxItems: number; + }; + value: unknown; }): ArtifactValue { - const existing = this.byTag.get(params.tag); + const existing = this.byTag.get(params.artifactId); + const nextHistory = existing + ? [...existing.history, params.value] + : [params.value]; + const history = params.history.enabled + ? nextHistory.slice(-params.history.maxItems) + : []; + if (existing) { const next: ArtifactValue = { ...existing, - usage: params.usage, + format: params.format, semantics: params.semantics, + writeMode: params.writeMode, value: params.value, - history: [...existing.history, params.value], + history, }; - this.byTag.set(params.tag, next); + this.byTag.set(params.artifactId, next); return next; } const created: ArtifactValue = { - usage: params.usage, + format: params.format, semantics: params.semantics, persistence: "run_only", + writeMode: params.writeMode, value: params.value, - history: [params.value], + history, }; - this.byTag.set(params.tag, created); + this.byTag.set(params.artifactId, created); return created; } } diff --git a/server/src/services/chat-generation-v3/contracts.test.ts b/server/src/services/chat-generation-v3/contracts.test.ts index b24c68cf..aae6b44c 100644 --- a/server/src/services/chat-generation-v3/contracts.test.ts +++ b/server/src/services/chat-generation-v3/contracts.test.ts @@ -1,74 +1,92 @@ import { describe, expect, test } from "vitest"; -import { mapOperationOutputToEffectType } from "./contracts"; +import { getArtifactPrimaryEffectType, mapArtifactExposureToEffectTypes } from "./contracts"; -describe("mapOperationOutputToEffectType", () => { - test("maps artifacts output", () => { +describe("artifact exposure mapping", () => { + test("maps artifact-only config", () => { expect( - mapOperationOutputToEffectType({ - type: "artifacts", - writeArtifact: { - tag: "x", - persistence: "run_only", - usage: "internal", - semantics: "intermediate", - }, + getArtifactPrimaryEffectType({ + artifactId: "artifact:x", + tag: "x", + title: "X", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + exposures: [], }) ).toBe("artifact.upsert"); }); - test("maps turn canonicalization output for both targets", () => { + test("maps turn rewrite exposures for both targets", () => { expect( - mapOperationOutputToEffectType({ - type: "turn_canonicalization", - canonicalization: { - kind: "replace_text", - target: "assistant", - }, + mapArtifactExposureToEffectTypes({ + artifactId: "artifact:x", + tag: "x", + title: "X", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + exposures: [{ type: "turn_rewrite", target: "assistant_output_main", mode: "replace" }], }) - ).toBe("turn.assistant.replace_text"); + ).toEqual(["artifact.upsert", "turn.assistant.replace_text"]); expect( - mapOperationOutputToEffectType({ - type: "turn_canonicalization", - canonicalization: { - kind: "replace_text", - target: "user", - }, + mapArtifactExposureToEffectTypes({ + artifactId: "artifact:x", + tag: "x", + title: "X", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + exposures: [{ type: "turn_rewrite", target: "current_user_main", mode: "replace" }], }) - ).toBe("turn.user.replace_text"); + ).toEqual(["artifact.upsert", "turn.user.replace_text"]); }); - test("maps prompt_time output kinds", () => { + test("maps prompt and ui exposure kinds", () => { expect( - mapOperationOutputToEffectType({ - type: "prompt_time", - promptTime: { - kind: "system_update", - mode: "append", - }, + mapArtifactExposureToEffectTypes({ + artifactId: "artifact:x", + tag: "x", + title: "X", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + exposures: [{ type: "prompt_part", target: "system", mode: "append" }], }) - ).toBe("prompt.system_update"); + ).toEqual(["artifact.upsert", "prompt.system_update"]); expect( - mapOperationOutputToEffectType({ - type: "prompt_time", - promptTime: { - kind: "append_after_last_user", - role: "system", - }, + mapArtifactExposureToEffectTypes({ + artifactId: "artifact:x", + tag: "x", + title: "X", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + exposures: [{ type: "prompt_message", role: "system", anchor: "after_last_user" }], }) - ).toBe("prompt.append_after_last_user"); + ).toEqual(["artifact.upsert", "prompt.append_after_last_user"]); expect( - mapOperationOutputToEffectType({ - type: "prompt_time", - promptTime: { - kind: "insert_at_depth", - depthFromEnd: 1, - role: "assistant", - }, + mapArtifactExposureToEffectTypes({ + artifactId: "artifact:x", + tag: "x", + title: "X", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + exposures: [ + { type: "prompt_message", role: "assistant", anchor: "depth_from_end", depthFromEnd: 1 }, + { type: "ui_inline", role: "assistant", anchor: "after_last_user" }, + ], }) - ).toBe("prompt.insert_at_depth"); + ).toEqual(["artifact.upsert", "prompt.insert_at_depth", "ui.inline"]); }); }); diff --git a/server/src/services/chat-generation-v3/contracts.ts b/server/src/services/chat-generation-v3/contracts.ts index 2cc35709..3e2610e0 100644 --- a/server/src/services/chat-generation-v3/contracts.ts +++ b/server/src/services/chat-generation-v3/contracts.ts @@ -1,11 +1,12 @@ import type { GenerateMessage } from "@shared/types/generate"; import type { + ArtifactExposure, + ArtifactFormat, ArtifactPersistence, ArtifactSemantics, - ArtifactUsage, + OperationArtifactConfig, OperationHook, OperationInProfile, - OperationOutput, OperationProfile, OperationTrigger, } from "@shared/types/operation-profiles"; @@ -33,20 +34,18 @@ export type PromptSnapshotV1 = { }; }; -export type RunPersistenceTarget = - { - mode: "entry_parts"; - assistantEntryId: string; - assistantMainPartId: string; - assistantReasoningPartId?: string; - }; +export type RunPersistenceTarget = { + mode: "entry_parts"; + assistantEntryId: string; + assistantMainPartId: string; + assistantReasoningPartId?: string; +}; -export type UserTurnTarget = - { - mode: "entry_parts"; - userEntryId: string; - userMainPartId: string; - }; +export type UserTurnTarget = { + mode: "entry_parts"; + userEntryId: string; + userMainPartId: string; +}; export type RunRequest = { requestId?: string; @@ -99,11 +98,12 @@ export type RunContext = { }; export type ArtifactValue = { - usage: ArtifactUsage; + format: ArtifactFormat; semantics: ArtifactSemantics; persistence: ArtifactPersistence; - value: string; - history: string[]; + writeMode: "replace" | "append"; + value: unknown; + history: unknown[]; }; export type PromptBuildOutput = { @@ -141,11 +141,16 @@ export type RuntimeEffect = | { type: "artifact.upsert"; opId: string; - tag: string; + artifactId: string; + format: ArtifactFormat; persistence: ArtifactPersistence; - usage: ArtifactUsage; + writeMode: "replace" | "append"; + history: { + enabled: boolean; + maxItems: number; + }; semantics: ArtifactSemantics; - value: string; + value: unknown; } | { type: "turn.user.replace_text"; @@ -156,6 +161,16 @@ export type RuntimeEffect = type: "turn.assistant.replace_text"; opId: string; text: string; + } + | { + type: "ui.inline"; + opId: string; + role: PromptDraftRole; + anchor: "after_last_user" | "depth_from_end"; + depthFromEnd?: number; + payload: unknown; + format: ArtifactFormat; + source?: string; }; export type OperationExecutionStatus = "done" | "skipped" | "error" | "aborted"; @@ -164,6 +179,7 @@ export type OperationSkipReason = | "activation_not_reached" | "dependency_not_done" | "dependency_missing" + | "guard_not_matched" | "unsupported_kind" | "orchestrator_aborted" | "filtered_out" @@ -178,6 +194,12 @@ export type OperationSkipDetails = { }; blockedByOpIds?: string[]; blockedByReason?: "activation_not_reached"; + guard?: { + sourceOpId: string; + outputKey: string; + operator: "is_true" | "is_false"; + actual: boolean | null; + }; }; export type OperationExecutionResult = { @@ -446,19 +468,102 @@ export type RunEvent = now: string; promptSystem: string; messages: Array<{ role: PromptDraftRole; content: string }>; - art: Record; + art: Record; + artByOpId?: Record; }; }; }; -export function mapOperationOutputToEffectType(output: OperationOutput): RuntimeEffect["type"] { - if (output.type === "artifacts") return "artifact.upsert"; - if (output.type === "turn_canonicalization") { - return output.canonicalization.target === "assistant" - ? "turn.assistant.replace_text" - : "turn.user.replace_text"; +function valueToText(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +export function mapArtifactExposureToEffectTypes( + artifact: OperationArtifactConfig +): RuntimeEffect["type"][] { + const effectTypes: RuntimeEffect["type"][] = ["artifact.upsert"]; + for (const exposure of artifact.exposures) { + if (exposure.type === "prompt_part") { + effectTypes.push("prompt.system_update"); + continue; + } + if (exposure.type === "prompt_message") { + effectTypes.push( + exposure.anchor === "after_last_user" + ? "prompt.append_after_last_user" + : "prompt.insert_at_depth" + ); + continue; + } + if (exposure.type === "turn_rewrite") { + effectTypes.push( + exposure.target === "assistant_output_main" + ? "turn.assistant.replace_text" + : "turn.user.replace_text" + ); + continue; + } + effectTypes.push("ui.inline"); + } + return effectTypes; +} + +export function getArtifactPrimaryEffectType( + artifact: OperationArtifactConfig +): RuntimeEffect["type"] { + return mapArtifactExposureToEffectTypes(artifact)[0] ?? "artifact.upsert"; +} + +export function compileArtifactExposureEffect(params: { + opId: string; + artifact: OperationArtifactConfig; + exposure: ArtifactExposure; + value: unknown; +}): RuntimeEffect { + const { opId, artifact, exposure, value } = params; + if (exposure.type === "prompt_part") { + return { + type: "prompt.system_update", + opId, + mode: exposure.mode, + payload: valueToText(value), + source: exposure.source, + }; + } + if (exposure.type === "prompt_message") { + if (exposure.anchor === "after_last_user") { + return { + type: "prompt.append_after_last_user", + opId, + role: exposure.role, + payload: valueToText(value), + source: exposure.source, + }; + } + return { + type: "prompt.insert_at_depth", + opId, + role: exposure.role, + depthFromEnd: exposure.depthFromEnd, + payload: valueToText(value), + source: exposure.source, + }; + } + if (exposure.type === "turn_rewrite") { + const text = valueToText(value); + return exposure.target === "assistant_output_main" + ? { type: "turn.assistant.replace_text", opId, text } + : { type: "turn.user.replace_text", opId, text }; } - if (output.promptTime.kind === "system_update") return "prompt.system_update"; - if (output.promptTime.kind === "append_after_last_user") return "prompt.append_after_last_user"; - return "prompt.insert_at_depth"; + return { + type: "ui.inline", + opId, + role: exposure.role, + anchor: exposure.anchor, + depthFromEnd: exposure.anchor === "depth_from_end" ? exposure.depthFromEnd : undefined, + payload: value, + format: artifact.format, + source: exposure.source, + }; } + diff --git a/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts b/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts index db14017f..77e56c61 100644 --- a/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts +++ b/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts @@ -509,9 +509,11 @@ describe("commit effects phase", () => { { type: "artifact.upsert", opId: "artifact-1", - tag: "memory", + artifactId: "memory", + format: "markdown", persistence: "run_only", - usage: "internal", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "intermediate", value: "v1", }, @@ -526,9 +528,11 @@ describe("commit effects phase", () => { { type: "artifact.upsert", opId: "artifact-2", - tag: "memory", + artifactId: "memory", + format: "markdown", persistence: "run_only", - usage: "internal", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "intermediate", value: "v2", }, @@ -568,9 +572,11 @@ describe("commit effects phase", () => { { type: "artifact.upsert", opId: "persisted", - tag: "state", + artifactId: "state", + format: "markdown", persistence: "persisted", - usage: "prompt+ui", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "state", value: "v", }, @@ -632,9 +638,11 @@ describe("commit effects phase", () => { { type: "artifact.upsert", opId: "skip", - tag: "x", + artifactId: "x", + format: "markdown", persistence: "run_only", - usage: "internal", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "intermediate", value: "v", }, @@ -719,9 +727,11 @@ describe("commit effects phase", () => { { type: "artifact.upsert", opId: "c", - tag: "x", + artifactId: "x", + format: "markdown", persistence: "persisted", - usage: "internal", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "intermediate", value: "v", }, diff --git a/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts b/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts index 4b4e0118..d763833f 100644 --- a/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts +++ b/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts @@ -4,12 +4,14 @@ import { type RunArtifactStore } from "../artifacts/run-artifact-store"; import { applyArtifactEffect } from "./effect-handlers/artifact-effects"; import { applyPromptEffect } from "./effect-handlers/prompt-effects"; import { persistUserTurnText } from "./effect-handlers/turn-effects"; +import { persistUiInlineEffect } from "./effect-handlers/ui-effects"; import { validateEffectForHook } from "./effect-policy"; import type { CommitPhaseReport, OperationExecutionResult, RuntimeEffect, + RunPersistenceTarget, RunState, TurnUserCanonicalizationRecord, UserTurnTarget, @@ -104,6 +106,7 @@ export async function commitEffectsPhase(params: { sessionKey: string | null; runState: RunState; runArtifactStore: RunArtifactStore; + persistenceTarget?: RunPersistenceTarget; userTurnTarget?: UserTurnTarget; onUserTurnCanonicalized?: (payload: TurnUserCanonicalizationRecord) => void; onCommitEvent?: (event: { @@ -174,7 +177,7 @@ export async function commitEffectsPhase(params: { effect, }); if (applied.persistence === "persisted") { - params.runState.persistedArtifactsSnapshot[effect.tag] = applied; + params.runState.persistedArtifactsSnapshot[effect.artifactId] = applied; } effectsReport.push({ opId: opResult.opId, @@ -188,6 +191,31 @@ export async function commitEffectsPhase(params: { continue; } + if (effect.type === "ui.inline") { + if (!params.persistenceTarget) { + throw new Error("UI inline effect requires persistence target"); + } + await persistUiInlineEffect({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + hook: params.hook, + effect, + persistenceTarget: params.persistenceTarget, + userTurnTarget: currentUserTurnTarget, + }); + effectsReport.push({ + opId: opResult.opId, + effectType: effect.type, + status: "applied", + }); + params.onCommitEvent?.({ + type: "commit.effect_applied", + data: { hook: params.hook, opId: opResult.opId, effectType: effect.type }, + }); + continue; + } + if (effect.type === "turn.user.replace_text") { const persisted = await persistUserTurnText({ target: currentUserTurnTarget, diff --git a/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.test.ts b/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.test.ts index f13c3289..2ecf8c1e 100644 --- a/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.test.ts +++ b/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.test.ts @@ -17,18 +17,21 @@ describe("applyArtifactEffect", () => { profile: null, runStore: store, effect: { - tag: "tmp", + artifactId: "tmp", + format: "markdown", persistence: "run_only", - usage: "internal", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "intermediate", value: "v1", }, }); expect(out).toEqual({ - usage: "internal", + format: "markdown", semantics: "intermediate", persistence: "run_only", + writeMode: "replace", value: "v1", history: ["v1"], }); @@ -45,9 +48,11 @@ describe("applyArtifactEffect", () => { profile: null, runStore: new RunArtifactStore(), effect: { - tag: "state", + artifactId: "state", + format: "markdown", persistence: "persisted", - usage: "prompt+ui", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "state", value: "v", }, @@ -57,9 +62,10 @@ describe("applyArtifactEffect", () => { test("delegates persisted write to ProfileSessionArtifactStore.upsert", async () => { const upsertSpy = vi.spyOn(ProfileSessionArtifactStore, "upsert").mockResolvedValue({ - usage: "prompt+ui", + format: "json", semantics: "state", persistence: "persisted", + writeMode: "append", value: "persisted-v", history: ["persisted-v"], }); @@ -85,9 +91,11 @@ describe("applyArtifactEffect", () => { }, runStore: new RunArtifactStore(), effect: { - tag: "world_state", + artifactId: "world_state", + format: "json", persistence: "persisted", - usage: "prompt+ui", + writeMode: "append", + history: { enabled: true, maxItems: 20 }, semantics: "state", value: "persisted-v", }, @@ -98,15 +106,17 @@ describe("applyArtifactEffect", () => { ownerId: "global", sessionKey: "sess-1", tag: "world_state", - usage: "prompt+ui", + format: "json", semantics: "state", + writeMode: "append", value: "persisted-v", }) ); expect(out).toEqual({ - usage: "prompt+ui", + format: "json", semantics: "state", persistence: "persisted", + writeMode: "append", value: "persisted-v", history: ["persisted-v"], }); diff --git a/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.ts b/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.ts index a60622ff..6e7c31a8 100644 --- a/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.ts +++ b/server/src/services/chat-generation-v3/operations/effect-handlers/artifact-effects.ts @@ -13,18 +13,25 @@ export async function applyArtifactEffect(params: { profile: OperationProfile | null; runStore: RunArtifactStore; effect: { - tag: string; + artifactId: string; + format: "text" | "markdown" | "json"; persistence: "persisted" | "run_only"; - usage: string; + writeMode: "replace" | "append"; + history: { + enabled: boolean; + maxItems: number; + }; semantics: string; - value: string; + value: unknown; }; }): Promise { if (params.effect.persistence === "run_only") { return params.runStore.upsert({ - tag: params.effect.tag, - usage: params.effect.usage as any, + artifactId: params.effect.artifactId, + format: params.effect.format as any, semantics: params.effect.semantics as any, + writeMode: params.effect.writeMode as any, + history: params.effect.history, value: params.effect.value, }); } @@ -39,9 +46,11 @@ export async function applyArtifactEffect(params: { chatId: params.chatId, branchId: params.branchId, profile: params.profile, - tag: params.effect.tag, - usage: params.effect.usage as any, + tag: params.effect.artifactId, + format: params.effect.format as any, semantics: params.effect.semantics as any, + writeMode: params.effect.writeMode as any, + history: params.effect.history, value: params.effect.value, }); return persisted; diff --git a/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts b/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts index 651f7998..f81fa20a 100644 --- a/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts +++ b/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts @@ -1,8 +1,8 @@ +import { safeJsonStringify } from "../../../../chat-core/json"; import { createPart, getPartWithVariantContextById, } from "../../../chat-entry-parts/parts-repository"; -import { safeJsonStringify } from "../../../../chat-core/json"; import type { UserTurnTarget } from "../../contracts"; diff --git a/server/src/services/chat-generation-v3/operations/effect-handlers/ui-effects.test.ts b/server/src/services/chat-generation-v3/operations/effect-handlers/ui-effects.test.ts new file mode 100644 index 00000000..d12e2c67 --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/effect-handlers/ui-effects.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getBranchCurrentTurn: vi.fn(), + listEntriesWithActiveVariants: vi.fn(), + getEntryById: vi.fn(), + getActiveVariantWithParts: vi.fn(), + getPartWithVariantContextById: vi.fn(), + createPart: vi.fn(), +})); + +vi.mock("../../../chat-entry-parts/branch-turn-repository", () => ({ + getBranchCurrentTurn: mocks.getBranchCurrentTurn, +})); + +vi.mock("../../../chat-entry-parts/entries-repository", () => ({ + listEntriesWithActiveVariants: mocks.listEntriesWithActiveVariants, + getEntryById: mocks.getEntryById, + getActiveVariantWithParts: mocks.getActiveVariantWithParts, +})); + +vi.mock("../../../chat-entry-parts/parts-repository", () => ({ + getPartWithVariantContextById: mocks.getPartWithVariantContextById, + createPart: mocks.createPart, +})); + +import { persistUiInlineEffect } from "./ui-effects"; + +describe("persistUiInlineEffect", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getBranchCurrentTurn.mockResolvedValue(7); + mocks.listEntriesWithActiveVariants.mockResolvedValue([]); + mocks.getEntryById.mockResolvedValue({ + entryId: "user-entry-current", + chatId: "chat-1", + branchId: "branch-1", + role: "user", + createdAt: 100, + activeVariantId: "variant-current", + }); + mocks.getActiveVariantWithParts.mockResolvedValue({ + variantId: "variant-current", + entryId: "user-entry-current", + kind: "generation", + createdAt: 100, + parts: [ + { + partId: "user-main-current", + channel: "main", + order: 10, + payload: "hello", + payloadFormat: "markdown", + visibility: { ui: "always", prompt: true }, + prompt: { serializerId: "asText" }, + ui: { rendererId: "markdown" }, + lifespan: "infinite", + createdTurn: 1, + source: "user", + }, + ], + }); + mocks.getPartWithVariantContextById.mockResolvedValue({ + entryId: "user-entry-current", + variantId: "variant-current", + part: { + partId: "user-main-current", + channel: "main", + order: 10, + payload: "hello", + payloadFormat: "markdown", + visibility: { ui: "always", prompt: true }, + prompt: { serializerId: "asText" }, + ui: { rendererId: "markdown" }, + lifespan: "infinite", + createdTurn: 1, + source: "user", + }, + }); + mocks.createPart.mockResolvedValue({ + partId: "aux-inline", + variantId: "variant-current", + }); + }); + + test("attaches inline UI part to current user target for after_last_user anchor", async () => { + await persistUiInlineEffect({ + ownerId: "global", + chatId: "chat-1", + branchId: "branch-1", + hook: "before_main_llm", + effect: { + type: "ui.inline", + opId: "op-inline", + role: "user", + anchor: "after_last_user", + payload: { summary: "note" }, + format: "json", + }, + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main", + }, + userTurnTarget: { + mode: "entry_parts", + userEntryId: "user-entry-current", + userMainPartId: "user-main-current", + }, + }); + + expect(mocks.createPart).toHaveBeenCalledWith( + expect.objectContaining({ + ownerId: "global", + variantId: "variant-current", + channel: "aux", + order: 20, + payload: { summary: "note" }, + payloadFormat: "json", + createdTurn: 7, + visibility: { ui: "always", prompt: false }, + tags: ["artifact_exposure", "ui_inline"], + }) + ); + }); + + test("uses depth_from_end over matching role entries when current target is not applicable", async () => { + mocks.listEntriesWithActiveVariants.mockResolvedValue([ + { + entry: { + entryId: "assistant-1", + chatId: "chat-1", + branchId: "branch-1", + role: "assistant", + createdAt: 10, + activeVariantId: "variant-a1", + }, + variant: { + variantId: "variant-a1", + entryId: "assistant-1", + kind: "generation", + createdAt: 10, + parts: [ + { + partId: "assistant-1-main", + channel: "main", + order: 10, + payload: "a1", + payloadFormat: "markdown", + visibility: { ui: "always", prompt: true }, + prompt: { serializerId: "asText" }, + ui: { rendererId: "markdown" }, + lifespan: "infinite", + createdTurn: 1, + source: "llm", + }, + ], + }, + }, + { + entry: { + entryId: "assistant-2", + chatId: "chat-1", + branchId: "branch-1", + role: "assistant", + createdAt: 20, + activeVariantId: "variant-a2", + }, + variant: { + variantId: "variant-a2", + entryId: "assistant-2", + kind: "generation", + createdAt: 20, + parts: [ + { + partId: "assistant-2-main", + channel: "main", + order: 10, + payload: "a2", + payloadFormat: "markdown", + visibility: { ui: "always", prompt: true }, + prompt: { serializerId: "asText" }, + ui: { rendererId: "markdown" }, + lifespan: "infinite", + createdTurn: 2, + source: "llm", + }, + ], + }, + }, + ]); + + await persistUiInlineEffect({ + ownerId: "global", + chatId: "chat-1", + branchId: "branch-1", + hook: "before_main_llm", + effect: { + type: "ui.inline", + opId: "op-inline", + role: "assistant", + anchor: "depth_from_end", + depthFromEnd: 1, + payload: "older-target", + format: "markdown", + }, + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main", + }, + }); + + expect(mocks.createPart).toHaveBeenCalledWith( + expect.objectContaining({ + variantId: "variant-a1", + payload: "older-target", + payloadFormat: "markdown", + }) + ); + }); +}); diff --git a/server/src/services/chat-generation-v3/operations/effect-handlers/ui-effects.ts b/server/src/services/chat-generation-v3/operations/effect-handlers/ui-effects.ts new file mode 100644 index 00000000..9fd43fab --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/effect-handlers/ui-effects.ts @@ -0,0 +1,142 @@ +import { getBranchCurrentTurn } from "../../../chat-entry-parts/branch-turn-repository"; +import { getEntryById, getActiveVariantWithParts, listEntriesWithActiveVariants } from "../../../chat-entry-parts/entries-repository"; +import { createPart, getPartWithVariantContextById } from "../../../chat-entry-parts/parts-repository"; + +import type { RunPersistenceTarget, RuntimeEffect, UserTurnTarget } from "../../contracts"; +import type { Part } from "@shared/types/chat-entry-parts"; + +type ResolvedTarget = { + entryId: string; + variantId: string; + parts: Part[]; +}; + +function compareByTimeAndId(a: { entryId: string; createdAt: number }, b: { entryId: string; createdAt: number }): number { + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt; + return a.entryId.localeCompare(b.entryId); +} + +async function resolveCurrentEntryTarget(partId: string): Promise { + const context = await getPartWithVariantContextById({ partId }); + if (!context) return null; + const entry = await getEntryById({ entryId: context.entryId }); + if (!entry) return null; + const variant = await getActiveVariantWithParts({ entry }); + return { + entryId: context.entryId, + variantId: context.variantId, + parts: variant?.parts ?? [context.part], + }; +} + +async function resolveUiTarget(params: { + chatId: string; + branchId: string; + hook: "before_main_llm" | "after_main_llm"; + effect: Extract; + persistenceTarget: RunPersistenceTarget; + userTurnTarget?: UserTurnTarget; +}): Promise { + const entries = await listEntriesWithActiveVariants({ + chatId: params.chatId, + branchId: params.branchId, + limit: 200, + }); + + const resolvedCandidates = entries + .filter((item) => item.entry.role === params.effect.role && item.variant) + .map((item) => ({ + entryId: item.entry.entryId, + createdAt: item.entry.createdAt, + variantId: item.variant!.variantId, + parts: item.variant!.parts, + })) + .sort(compareByTimeAndId); + + const maybeCurrentUser = + params.effect.role === "user" && params.userTurnTarget + ? await resolveCurrentEntryTarget(params.userTurnTarget.userMainPartId) + : null; + const maybeCurrentAssistant = + params.effect.role === "assistant" && params.hook === "after_main_llm" + ? await resolveCurrentEntryTarget(params.persistenceTarget.assistantMainPartId) + : null; + + const withCurrent = [...resolvedCandidates]; + if (maybeCurrentUser && !withCurrent.some((item) => item.entryId === maybeCurrentUser.entryId)) { + withCurrent.push({ ...maybeCurrentUser, createdAt: Number.MAX_SAFE_INTEGER }); + } + if (maybeCurrentAssistant && !withCurrent.some((item) => item.entryId === maybeCurrentAssistant.entryId)) { + withCurrent.push({ ...maybeCurrentAssistant, createdAt: Number.MAX_SAFE_INTEGER }); + } + + const ordered = withCurrent.sort(compareByTimeAndId); + if (ordered.length === 0) return null; + + if (params.effect.anchor === "after_last_user") { + if (params.effect.role === "user" && maybeCurrentUser) return maybeCurrentUser; + if (params.effect.role === "assistant" && maybeCurrentAssistant) return maybeCurrentAssistant; + const last = ordered[ordered.length - 1]; + return last + ? { + entryId: last.entryId, + variantId: last.variantId, + parts: last.parts, + } + : null; + } + + const depth = Math.max(0, Math.floor(params.effect.depthFromEnd ?? 0)); + const target = ordered[ordered.length - 1 - depth]; + if (!target) return null; + return { + entryId: target.entryId, + variantId: target.variantId, + parts: target.parts, + }; +} + +function resolveNextOrder(parts: Part[]): number { + const maxOrder = parts.reduce((max, part) => Math.max(max, part.order), 0); + return maxOrder + 10; +} + +function normalizePartPayload(value: unknown): string | number | boolean | object | null { + if (value === null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value === "object") return value; + return String(value ?? ""); +} + +export async function persistUiInlineEffect(params: { + ownerId: string; + chatId: string; + branchId: string; + hook: "before_main_llm" | "after_main_llm"; + effect: Extract; + persistenceTarget: RunPersistenceTarget; + userTurnTarget?: UserTurnTarget; +}): Promise { + const target = await resolveUiTarget(params); + if (!target) return; + + const currentTurn = await getBranchCurrentTurn({ branchId: params.branchId }); + await createPart({ + ownerId: params.ownerId, + variantId: target.variantId, + channel: "aux", + order: resolveNextOrder(target.parts), + payload: normalizePartPayload(params.effect.payload), + payloadFormat: params.effect.format, + visibility: { + ui: "always", + prompt: false, + }, + lifespan: "infinite", + source: "agent", + createdTurn: currentTurn, + tags: ["artifact_exposure", "ui_inline"], + }); +} diff --git a/server/src/services/chat-generation-v3/operations/effect-policy.test.ts b/server/src/services/chat-generation-v3/operations/effect-policy.test.ts index f5605a76..785a3f87 100644 --- a/server/src/services/chat-generation-v3/operations/effect-policy.test.ts +++ b/server/src/services/chat-generation-v3/operations/effect-policy.test.ts @@ -34,9 +34,11 @@ describe("effect policy", () => { effect: { type: "artifact.upsert", opId: "op", - tag: "x", + artifactId: "artifact:x", + format: "markdown", persistence: "run_only", - usage: "internal", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "intermediate", value: "v", }, @@ -46,9 +48,11 @@ describe("effect policy", () => { effect: { type: "artifact.upsert", opId: "op", - tag: "x", + artifactId: "artifact:x", + format: "markdown", persistence: "run_only", - usage: "internal", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, semantics: "intermediate", value: "v", }, diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts index 607663e6..d957e680 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts @@ -1,9 +1,13 @@ +import { + normalizeOperationArtifactConfig, + type LegacyOperationOutput, + type OperationInProfile, +} from "@shared/types/operation-profiles"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { executeOperationsPhase } from "./execute-operations-phase"; import type { InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; -import type { OperationInProfile, OperationOutput } from "@shared/types/operation-profiles"; const mocks = vi.hoisted(() => ({ @@ -30,6 +34,25 @@ vi.mock("../../llm/llm-gateway-adapter", () => ({ type TemplateOp = Extract; type LlmOp = Extract; +type GuardOp = Extract; + +function toArtifact(params: { + opId: string; + kind: "template" | "llm" | "compute"; + title: string; + output: LegacyOperationOutput; + llmOutputMode?: "text" | "json"; +}) { + return normalizeOperationArtifactConfig({ + opId: params.opId, + kind: params.kind, + title: params.title, + rawParams: { + output: params.output, + ...(params.llmOutputMode ? { params: { outputMode: params.llmOutputMode } } : {}), + }, + }); +} function streamOf( events: Array< @@ -62,7 +85,7 @@ function makeLlmOp(params: { opId: string; order: number; prompt: string; - output: OperationOutput; + output: LegacyOperationOutput; hooks?: LlmOp["config"]["hooks"]; dependsOn?: string[]; outputMode?: "text" | "json"; @@ -99,13 +122,19 @@ function makeLlmOp(params: { timeoutMs: params.timeoutMs, retry: params.retry, }, - output: params.output, + artifact: toArtifact({ + opId: params.opId, + kind: "llm", + title: params.opId, + output: params.output, + llmOutputMode: params.outputMode, + }), }, }, }; } -function artifactOutput(tag: string): OperationOutput { +function artifactOutput(tag: string): LegacyOperationOutput { return { type: "artifacts", writeArtifact: { @@ -121,7 +150,7 @@ function makeTemplateOp(params: { opId: string; order: number; template: string; - output: OperationOutput; + output: LegacyOperationOutput; hooks?: TemplateOp["config"]["hooks"]; dependsOn?: string[]; required?: boolean; @@ -142,7 +171,12 @@ function makeTemplateOp(params: { params: { template: params.template, strictVariables: params.strictVariables, - output: params.output, + artifact: toArtifact({ + opId: params.opId, + kind: "template", + title: params.opId, + output: params.output, + }), }, }, }; @@ -164,13 +198,82 @@ function makeComputeOp(params: { triggers: ["generate", "regenerate"], order: params.order, params: { - params: { noop: true }, - output: artifactOutput(`compute_${params.opId}`), + params: { noop: true } as Record, + artifact: toArtifact({ + opId: params.opId, + kind: "compute", + title: params.opId, + output: artifactOutput(`compute_${params.opId}`), + }), }, }, }; } +function makeGuardOp(params: { + opId: string; + order: number; + engine: "liquid" | "aux_llm"; + outputContract: Array<{ key: string; title: string }>; + template?: string; + system?: string; + prompt?: string; + dependsOn?: string[]; +}): GuardOp { + const artifact = normalizeOperationArtifactConfig({ + opId: params.opId, + kind: "guard", + title: params.opId, + rawParams: { + artifact: { + artifactId: `artifact:${params.opId}`, + tag: `${params.opId.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_state`, + title: `${params.opId} state`, + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }); + const guardParams = + params.engine === "liquid" + ? { + engine: "liquid" as const, + outputContract: params.outputContract, + template: params.template ?? "{\"matched\": true}", + artifact, + } + : { + engine: "aux_llm" as const, + outputContract: params.outputContract, + providerId: "openrouter" as const, + credentialRef: "token-1", + system: params.system, + prompt: params.prompt ?? "Return guard JSON", + artifact, + }; + + return { + opId: params.opId, + name: params.opId, + kind: "guard", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + triggers: ["generate", "regenerate"], + order: params.order, + dependsOn: params.dependsOn, + params: guardParams, + }, + }; +} + function makeBaseMessages() { return [ { role: "system" as const, content: "sys" }, @@ -293,7 +396,7 @@ describe("executeOperationsPhase", () => { expect(out[0]?.effects[0]).toMatchObject({ type: "artifact.upsert", opId: "a", - tag: "greeting", + artifactId: "greeting", value: "hello", }); }); @@ -335,7 +438,7 @@ describe("executeOperationsPhase", () => { expect(finished?.status).toBe("done"); expect(finished?.result?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "greeting", + artifactId: "greeting", value: "hello", }); expect(finished?.result?.debugSummary).toBe("artifact.upsert:5"); @@ -373,7 +476,7 @@ describe("executeOperationsPhase", () => { expect(byId.get("b")?.status).toBe("done"); expect(byId.get("b")?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "seen", + artifactId: "seen", value: "seen=alpha", }); }); @@ -423,7 +526,7 @@ describe("executeOperationsPhase", () => { expect(joined?.status).toBe("done"); expect(joined?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "joined", + artifactId: "joined", value: "root-L|root-R", }); }); @@ -458,7 +561,7 @@ describe("executeOperationsPhase", () => { expect(b?.status).toBe("done"); expect(b?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "b", + artifactId: "b", value: "none", }); }); @@ -526,6 +629,260 @@ describe("executeOperationsPhase", () => { }); }); + test("executes liquid guard and stores json artifact", async () => { + const out = await executeOperationsPhase({ + runId: "run-guard-liquid", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeGuardOp({ + opId: "guard-liquid", + order: 10, + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "{\"isBattle\": true}", + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]).toMatchObject({ + opId: "guard-liquid", + status: "done", + }); + expect(out[0]?.effects[0]).toMatchObject({ + type: "artifact.upsert", + format: "json", + value: { + isBattle: true, + }, + }); + }); + + test("fails liquid guard when rendered output is not valid json", async () => { + const out = await executeOperationsPhase({ + runId: "run-guard-liquid-error", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeGuardOp({ + opId: "guard-liquid", + order: 10, + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "not-json", + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]?.status).toBe("error"); + expect(out[0]?.error?.code).toBe("GUARD_OUTPUT_PARSE_ERROR"); + }); + + test("executes aux llm guard and validates json output", async () => { + mocks.llmGatewayStream.mockImplementation(() => + streamOf([ + { type: "delta", text: "{\"isBattle\":true}" }, + { type: "done", status: "done" }, + ]) + ); + + const out = await executeOperationsPhase({ + runId: "run-guard-aux", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeGuardOp({ + opId: "guard-aux", + order: 10, + engine: "aux_llm", + outputContract: [{ key: "isBattle", title: "Battle" }], + prompt: "Return guard JSON", + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]?.status).toBe("done"); + expect(out[0]?.effects[0]).toMatchObject({ + type: "artifact.upsert", + format: "json", + value: { + isBattle: true, + }, + }); + }); + + test("fails aux llm guard when schema does not match output contract", async () => { + mocks.llmGatewayStream.mockImplementation(() => + streamOf([ + { type: "delta", text: "{\"isBattle\":\"yes\"}" }, + { type: "done", status: "done" }, + ]) + ); + + const out = await executeOperationsPhase({ + runId: "run-guard-aux-invalid", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeGuardOp({ + opId: "guard-aux", + order: 10, + engine: "aux_llm", + outputContract: [{ key: "isBattle", title: "Battle" }], + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]?.status).toBe("error"); + expect(out[0]?.error?.code).toBe("GUARD_OUTPUT_VALIDATION_ERROR"); + }); + + test("skips downstream operation when guard condition does not match", async () => { + const out = await executeOperationsPhase({ + runId: "run-guard-skip", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeGuardOp({ + opId: "guard-liquid", + order: 10, + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "{\"isBattle\": false}", + }), + { + ...makeTemplateOp({ + opId: "consumer", + order: 20, + dependsOn: ["guard-liquid"], + template: "combat", + output: artifactOutput("consumer_out"), + }), + config: { + ...makeTemplateOp({ + opId: "consumer", + order: 20, + dependsOn: ["guard-liquid"], + template: "combat", + output: artifactOutput("consumer_out"), + }).config, + runConditions: [ + { + type: "guard_output", + sourceOpId: "guard-liquid", + outputKey: "isBattle", + operator: "is_true", + }, + ], + }, + }, + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + const byId = new Map(out.map((item) => [item.opId, item] as const)); + expect(byId.get("guard-liquid")?.status).toBe("done"); + expect(byId.get("consumer")).toMatchObject({ + status: "skipped", + skipReason: "guard_not_matched", + skipDetails: { + guard: { + sourceOpId: "guard-liquid", + outputKey: "isBattle", + operator: "is_true", + actual: false, + }, + }, + }); + }); + + test("runs downstream operation only when all guard conditions match", async () => { + const consumerBase = makeTemplateOp({ + opId: "consumer", + order: 30, + dependsOn: ["guard-a", "guard-b"], + template: "combat-night", + output: artifactOutput("consumer_out"), + }); + const out = await executeOperationsPhase({ + runId: "run-guard-and", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeGuardOp({ + opId: "guard-a", + order: 10, + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "{\"isBattle\": true}", + }), + makeGuardOp({ + opId: "guard-b", + order: 20, + engine: "liquid", + outputContract: [{ key: "isNight", title: "Night" }], + template: "{\"isNight\": true}", + }), + { + ...consumerBase, + config: { + ...consumerBase.config, + runConditions: [ + { + type: "guard_output", + sourceOpId: "guard-a", + outputKey: "isBattle", + operator: "is_true", + }, + { + type: "guard_output", + sourceOpId: "guard-b", + outputKey: "isNight", + operator: "is_true", + }, + ], + }, + }, + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + const byId = new Map(out.map((item) => [item.opId, item] as const)); + expect(byId.get("consumer")?.status).toBe("done"); + expect(byId.get("consumer")?.effects[0]).toMatchObject({ + type: "artifact.upsert", + value: "combat-night", + }); + }); + test("normalizes dependency_missing to dependency_not_done when dependency is activation-skipped", async () => { const finishedEvents = collectEvents<{ opId: string; @@ -644,7 +1001,7 @@ describe("executeOperationsPhase", () => { templateContext: makeTemplateContext(), }); - const effectTypes = out.map((r) => r.effects[0]?.type); + const effectTypes = out.map((r) => r.effects[1]?.type); expect(effectTypes).toEqual([ "prompt.system_update", "prompt.append_after_last_user", @@ -659,6 +1016,7 @@ describe("executeOperationsPhase", () => { promptSystem: string; messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; art: Record; + artByOpId?: Record; }; rendered: string; }>(); @@ -708,7 +1066,7 @@ describe("executeOperationsPhase", () => { const bResult = out.find((r) => r.opId === "b"); expect(bResult?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "seen", + artifactId: "seen", value: "PROMPT|NOTE", }); @@ -717,6 +1075,7 @@ describe("executeOperationsPhase", () => { expect(bDebug?.liquidContext.promptSystem).toBe("sys\n\nPROMPT"); expect(bDebug?.liquidContext.messages[2]).toEqual({ role: "system", content: "PROMPT" }); expect(bDebug?.liquidContext.art.note?.value).toBe("NOTE"); + expect(bDebug?.liquidContext.artByOpId?.c?.value).toBe("NOTE"); }); test("exposes promptSystem to template operations with dependency replay", async () => { @@ -752,7 +1111,7 @@ describe("executeOperationsPhase", () => { const byId = new Map(out.map((item) => [item.opId, item] as const)); expect(byId.get("read-system")?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "seen", + artifactId: "seen", value: "NEW_SYS", }); }); @@ -823,7 +1182,7 @@ describe("executeOperationsPhase", () => { }); expect(out[0]?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "llm_tag", + artifactId: "llm_tag", value: "LLM RESULT", }); }); @@ -859,7 +1218,7 @@ describe("executeOperationsPhase", () => { expect(out[0]?.status).toBe("done"); expect(out[0]?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "llm_json", + artifactId: "llm_json", value: '{"alpha":1,"beta":[2,3]}', }); }); @@ -1156,7 +1515,7 @@ describe("executeOperationsPhase", () => { expect(out[0]?.status).toBe("done"); expect(out[0]?.effects[0]).toMatchObject({ type: "artifact.upsert", - tag: "llm_json_schema", + artifactId: "llm_json_schema", }); }); @@ -1391,3 +1750,4 @@ describe("executeOperationsPhase", () => { } }); }); + diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts index 2687bd6a..2d7140d0 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts @@ -1,9 +1,9 @@ -import { runOrchestrator } from "@core/operation-orchestrator"; - +import { createTaskSkip, runOrchestrator } from "@core/operation-orchestrator"; import { renderLiquidTemplate } from "../../chat-core/prompt-template-renderer"; import { - mapOperationOutputToEffectType, + compileArtifactExposureEffect, + getArtifactPrimaryEffectType, type OperationFinishedEventData, type OperationExecutionResult, type OperationSkipDetails, @@ -13,6 +13,9 @@ import { } from "../contracts"; import { applyPromptEffect } from "./effect-handlers/prompt-effects"; +import { executeGuardOperation } from "./guard-operation-executor"; +import { evaluateGuardRunConditions } from "./guard-run-conditions"; +import { executeKnowledgeOperation } from "./knowledge-operation-executor"; import { executeLlmOperation } from "./llm-operation-executor"; import type { TaskResult } from "../../../core/operation-orchestrator/types"; @@ -21,10 +24,28 @@ import type { OperationHook, OperationInProfile, OperationTrigger } from "@share type PreviewState = { messages: PromptDraftMessage[]; - artifacts: Record; + artifacts: Record; assistantText: string; }; +function getRuntimeArtifactKey(params: { + artifactId: string; + tag: string; +}): string { + return params.tag; +} + +function readArtifactValue(params: { + artifacts: Record; + artifactId: string; + tag: string; +}): { value: unknown; history: unknown[] } | undefined { + return ( + params.artifacts[getRuntimeArtifactKey(params)] ?? + params.artifacts[params.artifactId] + ); +} + function resolvePromptSystem(messages: PromptDraftMessage[]): string { return messages .filter((m) => m.role === "system") @@ -37,17 +58,6 @@ function normalizeText(value: unknown): string { return typeof value === "string" ? value : String(value ?? ""); } -function normalizePromptTimeRole(value: unknown): PromptDraftMessage["role"] { - if (value === "assistant" || value === "user" || value === "system") return value; - if (value === "developer") return "system"; - return "system"; -} - -function normalizeDepthFromEnd(value: unknown): number { - const n = typeof value === "number" && Number.isFinite(value) ? value : 0; - return Math.abs(Math.floor(n)); -} - function clonePreview(state: PreviewState): PreviewState { return { messages: state.messages.map((m) => ({ ...m })), @@ -61,60 +71,6 @@ function clonePreview(state: PreviewState): PreviewState { }; } -function toRuntimeEffect(params: { - opId: string; - output: OperationInProfile["config"]["params"]["output"]; - rendered: string; -}): RuntimeEffect { - const { opId, output, rendered } = params; - if (output.type === "artifacts") { - return { - type: "artifact.upsert", - opId, - tag: output.writeArtifact.tag, - persistence: output.writeArtifact.persistence, - usage: output.writeArtifact.usage, - semantics: output.writeArtifact.semantics, - value: rendered, - }; - } - - if (output.type === "turn_canonicalization") { - return output.canonicalization.target === "assistant" - ? { type: "turn.assistant.replace_text", opId, text: rendered } - : { type: "turn.user.replace_text", opId, text: rendered }; - } - - if (output.promptTime.kind === "system_update") { - return { - type: "prompt.system_update", - opId, - mode: output.promptTime.mode, - payload: rendered, - source: output.promptTime.source, - }; - } - - if (output.promptTime.kind === "append_after_last_user") { - return { - type: "prompt.append_after_last_user", - opId, - role: normalizePromptTimeRole(output.promptTime.role), - payload: rendered, - source: output.promptTime.source, - }; - } - - return { - type: "prompt.insert_at_depth", - opId, - role: normalizePromptTimeRole(output.promptTime.role), - depthFromEnd: normalizeDepthFromEnd(output.promptTime.depthFromEnd), - payload: rendered, - source: output.promptTime.source, - }; -} - function applyEffectToPreview(state: PreviewState, effect: RuntimeEffect): PreviewState { if ( effect.type === "prompt.system_update" || @@ -125,13 +81,16 @@ function applyEffectToPreview(state: PreviewState, effect: RuntimeEffect): Previ } if (effect.type === "artifact.upsert") { - const existing = state.artifacts[effect.tag]; - const history = [...(existing?.history ?? []), effect.value]; + const existing = state.artifacts[effect.artifactId]; + const nextHistory = [...(existing?.history ?? []), effect.value]; + const history = effect.history.enabled + ? nextHistory.slice(-effect.history.maxItems) + : []; return { ...state, artifacts: { ...state.artifacts, - [effect.tag]: { + [effect.artifactId]: { value: effect.value, history, }, @@ -148,6 +107,7 @@ function applyEffectToPreview(state: PreviewState, effect: RuntimeEffect): Previ const lastUserIdx = state.messages.map((m) => m.role).lastIndexOf("user"); if (lastUserIdx < 0) return state; + if (effect.type === "ui.inline") return state; const nextMessages = state.messages.map((m, idx) => idx === lastUserIdx ? { role: "user" as const, content: effect.text } : { ...m } ); @@ -169,15 +129,70 @@ function replayDependencyEffects( return state; } -function buildTemplateContext(base: InstructionRenderContext, state: PreviewState): InstructionRenderContext { +function mapArtifactsByOpId( + operations: OperationInProfile[], + artifacts: Record +): Record { + const mapped: Record = {}; + for (const op of operations) { + const artifact = readArtifactValue({ + artifacts, + artifactId: op.config.params.artifact.artifactId, + tag: op.config.params.artifact.tag, + }); + if (!artifact) continue; + mapped[op.opId] = { value: artifact.value, history: [...artifact.history] }; + } + return mapped; +} + +function mapArtifactsForTemplate( + operations: OperationInProfile[], + artifacts: Record +): Record { + const mapped: Record = {}; + for (const op of operations) { + const artifact = readArtifactValue({ + artifacts, + artifactId: op.config.params.artifact.artifactId, + tag: op.config.params.artifact.tag, + }); + if (!artifact) continue; + const snapshot = { value: artifact.value, history: [...artifact.history] }; + mapped[op.config.params.artifact.tag] = snapshot; + mapped[op.config.params.artifact.artifactId] = snapshot; + } + return mapped; +} + +function mapKnownArtifacts( + artifacts: Record +): Record { + return Object.fromEntries( + Object.entries(artifacts).map(([key, value]) => [ + key, + { value: value.value, history: [...value.history] }, + ]) + ); +} + +function buildTemplateContext( + base: InstructionRenderContext, + state: PreviewState, + operations: OperationInProfile[] +): InstructionRenderContext { + const art = { + ...(base.art ?? {}), + ...mapKnownArtifacts(state.artifacts), + ...mapArtifactsForTemplate(operations, state.artifacts), + }; return { ...base, promptSystem: resolvePromptSystem(state.messages), - art: { - ...(base.art ?? {}), - ...Object.fromEntries( - Object.entries(state.artifacts).map(([tag, value]) => [tag, { value: value.value, history: value.history }]) - ), + art, + artByOpId: { + ...(base.artByOpId ?? {}), + ...mapArtifactsByOpId(operations, state.artifacts), }, messages: state.messages.map((m) => ({ role: m.role, content: m.content })), }; @@ -186,7 +201,8 @@ function buildTemplateContext(base: InstructionRenderContext, state: PreviewStat function buildLiquidContextSnapshot(params: { base: InstructionRenderContext; state: PreviewState; -}): { + operations: OperationInProfile[]; + }): { char: unknown; user: unknown; chat: unknown; @@ -194,7 +210,8 @@ function buildLiquidContextSnapshot(params: { now: string; promptSystem: string; messages: Array<{ role: PromptDraftMessage["role"]; content: string }>; - art: Record; + art: Record; + artByOpId?: Record; } { return { char: params.base.char ?? {}, @@ -204,15 +221,13 @@ function buildLiquidContextSnapshot(params: { now: params.base.now, promptSystem: resolvePromptSystem(params.state.messages), messages: params.state.messages.map((m) => ({ role: m.role, content: m.content })), - art: Object.fromEntries( - Object.entries(params.state.artifacts).map(([tag, value]) => [ - tag, - { value: value.value, history: [...value.history] }, - ]) - ), + art: { + ...mapKnownArtifacts(params.state.artifacts), + ...mapArtifactsForTemplate(params.operations, params.state.artifacts), + }, + artByOpId: params.base.artByOpId as Record | undefined, }; } - function normalizeBlockedByOpIds(input: string[] | undefined): string[] { if (!input || input.length === 0) return []; return Array.from(new Set(input)).sort((a, b) => a.localeCompare(b)); @@ -224,6 +239,11 @@ function resolveSkipReasonAndDetails(params: { blockedByOpIds?: string[]; activationSkippedByOpId: ReadonlyMap>; }): { skipReason: OperationSkipReason; skipDetails?: OperationSkipDetails } { + if (params.reason === "guard_not_matched") { + return { + skipReason: "guard_not_matched", + }; + } const blockedByOpIds = normalizeBlockedByOpIds( params.blockedByOpIds?.length ? params.blockedByOpIds @@ -268,6 +288,10 @@ function mapTaskResult(params: { task: TaskResult; op: OperationInProfile; activationSkippedByOpId: ReadonlyMap>; + runtimeSkippedMetaByOpId: ReadonlyMap< + string, + { skipReason: OperationSkipReason; skipDetails?: OperationSkipDetails } + >; }): OperationExecutionResult { const { task, op } = params; if (task.status === "done") { @@ -331,6 +355,22 @@ function mapTaskResult(params: { }; } + const runtimeSkipped = params.runtimeSkippedMetaByOpId.get(op.opId); + if (runtimeSkipped) { + return { + opId: op.opId, + name: op.name, + required: op.config.required, + hook: params.hook, + status: "skipped", + order: op.config.order, + dependsOn: op.config.dependsOn ?? [], + effects: [], + skipReason: runtimeSkipped.skipReason, + skipDetails: runtimeSkipped.skipDetails, + }; + } + const normalized = resolveSkipReasonAndDetails({ reason: task.reason as OperationSkipReason, dependsOn: op.config.dependsOn ?? [], @@ -362,9 +402,14 @@ export async function executeOperationsPhase(params: { >; executionMode: "concurrent" | "sequential"; baseMessages: PromptDraftMessage[]; - baseArtifacts: Record; + baseArtifacts: Record; assistantText: string; templateContext: InstructionRenderContext; + knowledgeContext?: { + ownerId: string; + chatId: string; + branchId: string | null; + }; abortSignal?: AbortSignal; onOperationStarted?: (data: { hook: OperationHook; opId: string; name: string }) => void; onOperationFinished?: (data: OperationFinishedEventData) => void; @@ -383,7 +428,8 @@ export async function executeOperationsPhase(params: { now: string; promptSystem: string; messages: Array<{ role: PromptDraftMessage["role"]; content: string }>; - art: Record; + art: Record; + artByOpId?: Record; }; }) => void; }): Promise { @@ -435,14 +481,36 @@ export async function executeOperationsPhase(params: { } const executableOps = runnableOperations.filter( - (op): op is Extract => - op.kind === "template" || op.kind === "llm" + ( + op + ): op is Extract< + OperationInProfile, + { kind: "template" | "llm" | "guard" | "knowledge_search" | "knowledge_reveal" } + > => + op.kind === "template" || + op.kind === "llm" || + op.kind === "guard" || + op.kind === "knowledge_search" || + op.kind === "knowledge_reveal" ); const executableOpsById = new Map(executableOps.map((op) => [op.opId, op])); + const operationsById = new Map(params.operations.map((op) => [op.opId, op] as const)); const executableTaskIdSet = new Set(executableOps.map((op) => op.opId)); const unsupportedOps = runnableOperations.filter( - (op): op is Exclude> => - op.kind !== "template" && op.kind !== "llm" + ( + op + ): op is Exclude< + OperationInProfile, + Extract< + OperationInProfile, + { kind: "template" | "llm" | "guard" | "knowledge_search" | "knowledge_reveal" } + > + > => + op.kind !== "template" && + op.kind !== "llm" && + op.kind !== "guard" && + op.kind !== "knowledge_search" && + op.kind !== "knowledge_reveal" ); const effectsByOpId = new Map(); @@ -459,6 +527,10 @@ export async function executeOperationsPhase(params: { string, { skipReason: OperationSkipReason; skipDetails?: OperationSkipDetails } >(); + const runtimeSkippedMetaByTaskId = new Map< + string, + { skipReason: OperationSkipReason; skipDetails?: OperationSkipDetails } + >(); const executableResults: OperationExecutionResult[] = []; if (executableOps.length > 0) { const orchestration = await runOrchestrator( @@ -477,8 +549,22 @@ export async function executeOperationsPhase(params: { dependsOn: op.config.dependsOn, run: async () => { const depPreview = replayDependencyEffects(baseState, op.config.dependsOn ?? [], effectsByOpId); - const liquidContext = buildTemplateContext(params.templateContext, depPreview); - let resolvedRendered = ""; + const guardConditionResult = evaluateGuardRunConditions({ + op, + operationsById, + artifacts: depPreview.artifacts, + }); + if (!guardConditionResult.matched) { + runtimeSkippedMetaByTaskId.set(op.opId, { + skipReason: "guard_not_matched", + skipDetails: { + guard: guardConditionResult.details, + }, + }); + throw createTaskSkip("runtime_condition", "guard_not_matched"); + } + const liquidContext = buildTemplateContext(params.templateContext, depPreview, params.operations); + let resolvedRendered: unknown = ""; let debugSummary: string | undefined; if (op.kind === "template") { @@ -501,38 +587,91 @@ export async function executeOperationsPhase(params: { debugSummary = llmResult.debugSummary; } - const effect = toRuntimeEffect({ - opId: op.opId, - output: op.config.params.output, - rendered: resolvedRendered, - }); + if (op.kind === "guard") { + const guardResult = await executeGuardOperation({ + op, + liquidContext, + abortSignal: params.abortSignal, + }); + resolvedRendered = guardResult.value; + debugSummary = guardResult.debugSummary; + } + + if (op.kind === "knowledge_search" || op.kind === "knowledge_reveal") { + if (!params.knowledgeContext) { + throw new Error("knowledgeContext is required for knowledge operations"); + } + const knowledgeResult = await executeKnowledgeOperation({ + op, + liquidContext, + artifacts: depPreview.artifacts, + knowledgeContext: params.knowledgeContext, + }); + effectsByOpId.set(op.opId, knowledgeResult.effects); + taskResultByOpId.set(op.opId, { + effects: knowledgeResult.effects, + debugSummary: knowledgeResult.debugSummary, + }); + return knowledgeResult; + } + + const artifact = op.config.params.artifact; + const effects: RuntimeEffect[] = [ + { + type: "artifact.upsert", + opId: op.opId, + artifactId: getRuntimeArtifactKey(artifact), + format: artifact.format, + persistence: artifact.persistence, + writeMode: artifact.writeMode, + history: artifact.history, + semantics: artifact.semantics ?? "intermediate", + value: resolvedRendered, + }, + ...artifact.exposures.map((exposure) => + compileArtifactExposureEffect({ + opId: op.opId, + artifact, + exposure, + value: resolvedRendered, + }) + ), + ]; + const effect = effects[0]; if (op.kind === "template") { params.onTemplateDebug?.({ hook: params.hook, opId: op.opId, name: op.name, template: op.config.params.template, - rendered: resolvedRendered, + rendered: + typeof resolvedRendered === "string" + ? resolvedRendered + : JSON.stringify(resolvedRendered), effect, liquidContext: buildLiquidContextSnapshot({ base: liquidContext, state: depPreview, + operations: params.operations, }), }); } - const effects: RuntimeEffect[] = [effect]; effectsByOpId.set(op.opId, effects); taskResultByOpId.set(op.opId, { effects, debugSummary: debugSummary ?? - `${mapOperationOutputToEffectType(op.config.params.output)}:${resolvedRendered.length}`, + `${getArtifactPrimaryEffectType(op.config.params.artifact)}:${normalizeText( + typeof resolvedRendered === "string" + ? resolvedRendered + : JSON.stringify(resolvedRendered) + ).length}`, }); return { effects, debugSummary: debugSummary ?? - `${mapOperationOutputToEffectType(op.config.params.output)}:${resolvedRendered.length}`, + `${getArtifactPrimaryEffectType(op.config.params.artifact)}:${normalizeText(resolvedRendered).length}`, }; }, })), @@ -548,6 +687,13 @@ export async function executeOperationsPhase(params: { if (evt.type === "orch.task.skipped") { const op = executableOpsById.get(evt.data.taskId); if (!op) return; + if (evt.data.reason === "runtime_condition") { + const runtimeMeta = runtimeSkippedMetaByTaskId.get(op.opId); + if (runtimeMeta) { + skippedEventMetaByTaskId.set(op.opId, runtimeMeta); + } + return; + } const missingDeps = evt.data.reason === "dependency_missing" ? (op.config.dependsOn ?? []).filter((depId) => !executableTaskIdSet.has(depId)) @@ -608,6 +754,7 @@ export async function executeOperationsPhase(params: { op, task, activationSkippedByOpId, + runtimeSkippedMetaByOpId: runtimeSkippedMetaByTaskId, }); }) .filter((item): item is OperationExecutionResult => Boolean(item)) @@ -643,3 +790,5 @@ export async function executeOperationsPhase(params: { return all; } + + diff --git a/server/src/services/chat-generation-v3/operations/guard-operation-executor.ts b/server/src/services/chat-generation-v3/operations/guard-operation-executor.ts new file mode 100644 index 00000000..3d82b101 --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/guard-operation-executor.ts @@ -0,0 +1,148 @@ +import { renderLiquidTemplate, type InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; +import { compileGuardOutputSchema, buildGuardOutputJsonSchemaSpec } from "../../operations/guard-output-contract"; + +import { executeLlmOperation } from "./llm-operation-executor"; + +import type { OperationInProfile } from "@shared/types/operation-profiles"; + +type GuardOperation = Extract; +type LlmLikeOperation = Extract; + +type CodedError = Error & { code: string }; + +function createCodedError(code: string, message: string): CodedError { + const error = new Error(message) as CodedError; + error.code = code; + return error; +} + +function mapGuardError(error: unknown): never { + if (error instanceof Error) { + const code = (error as Error & { code?: string }).code; + if (code === "LLM_TEMPLATE_RENDER_ERROR") { + throw createCodedError("GUARD_TEMPLATE_RENDER_ERROR", error.message); + } + if (code === "LLM_OUTPUT_PARSE_ERROR" || code === "LLM_OUTPUT_EXTRACT_ERROR") { + throw createCodedError("GUARD_OUTPUT_PARSE_ERROR", error.message); + } + if (code === "LLM_OUTPUT_SCHEMA_ERROR") { + throw createCodedError("GUARD_OUTPUT_VALIDATION_ERROR", error.message); + } + if (code === "LLM_TIMEOUT") { + throw createCodedError("GUARD_TIMEOUT", error.message); + } + if (code?.startsWith("LLM_")) { + throw createCodedError("GUARD_PROVIDER_ERROR", error.message); + } + } + throw createCodedError( + "GUARD_PROVIDER_ERROR", + error instanceof Error ? error.message : String(error) + ); +} + +function parseGuardJsonOutput(params: { + value: string; + op: GuardOperation; +}): Record { + let parsed: unknown; + try { + parsed = JSON.parse(params.value); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw createCodedError("GUARD_OUTPUT_PARSE_ERROR", message); + } + + const schema = compileGuardOutputSchema(params.op.config.params.outputContract); + const result = schema.safeParse(parsed); + if (!result.success) { + const issue = result.error.issues[0]; + const path = issue?.path?.length ? issue.path.join(".") : "$"; + throw createCodedError( + "GUARD_OUTPUT_VALIDATION_ERROR", + `Guard output validation failed at ${path}: ${issue?.message ?? "invalid output"}` + ); + } + return result.data; +} + +function buildLlmLikeGuardOperation(op: GuardOperation): LlmLikeOperation { + if (op.config.params.engine !== "aux_llm") { + throw createCodedError("GUARD_INVALID_PARAMS", "Expected aux_llm guard params"); + } + + return { + opId: op.opId, + name: op.name, + kind: "llm", + config: { + enabled: op.config.enabled, + required: op.config.required, + hooks: op.config.hooks, + triggers: op.config.triggers, + activation: op.config.activation, + order: op.config.order, + dependsOn: op.config.dependsOn, + runConditions: op.config.runConditions, + params: { + params: { + providerId: op.config.params.providerId, + credentialRef: op.config.params.credentialRef, + model: op.config.params.model, + system: op.config.params.system, + prompt: op.config.params.prompt, + strictVariables: op.config.params.strictVariables, + outputMode: "json", + jsonSchema: buildGuardOutputJsonSchemaSpec(op.config.params.outputContract), + strictSchemaValidation: true, + jsonParseMode: "raw", + samplers: op.config.params.samplers, + timeoutMs: op.config.params.timeoutMs, + retry: op.config.params.retry, + }, + artifact: op.config.params.artifact, + }, + }, + }; +} + +export async function executeGuardOperation(params: { + op: GuardOperation; + liquidContext: InstructionRenderContext; + abortSignal?: AbortSignal; +}): Promise<{ value: Record; debugSummary: string }> { + if (params.op.config.params.engine === "liquid") { + let rendered = ""; + try { + rendered = String( + await renderLiquidTemplate({ + templateText: params.op.config.params.template, + context: params.liquidContext, + options: { strictVariables: Boolean(params.op.config.params.strictVariables) }, + }) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw createCodedError("GUARD_TEMPLATE_RENDER_ERROR", message); + } + + return { + value: parseGuardJsonOutput({ value: rendered, op: params.op }), + debugSummary: `guard:liquid:${params.op.config.params.outputContract.length}`, + }; + } + + try { + const llmResult = await executeLlmOperation({ + op: buildLlmLikeGuardOperation(params.op), + liquidContext: params.liquidContext, + abortSignal: params.abortSignal, + }); + return { + value: parseGuardJsonOutput({ value: llmResult.rendered, op: params.op }), + debugSummary: `guard:aux_llm:${params.op.config.params.outputContract.length}:${llmResult.debugSummary}`, + }; + } catch (error) { + mapGuardError(error); + } +} diff --git a/server/src/services/chat-generation-v3/operations/guard-run-conditions.ts b/server/src/services/chat-generation-v3/operations/guard-run-conditions.ts new file mode 100644 index 00000000..029883ea --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/guard-run-conditions.ts @@ -0,0 +1,66 @@ +import type { OperationSkipDetails } from "../contracts"; +import type { OperationInProfile } from "@shared/types/operation-profiles"; + +type ArtifactSnapshot = { value: unknown; history: unknown[] }; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readGuardOutputValue(params: { + sourceOp: Extract; + artifacts: Record; + outputKey: string; +}): boolean | null { + const artifact = + params.artifacts[params.sourceOp.config.params.artifact.tag] ?? + params.artifacts[params.sourceOp.config.params.artifact.artifactId]; + if (!artifact || !isRecord(artifact.value)) return null; + const value = artifact.value[params.outputKey]; + return typeof value === "boolean" ? value : null; +} + +export function evaluateGuardRunConditions(params: { + op: OperationInProfile; + operationsById: ReadonlyMap; + artifacts: Record; +}): + | { matched: true } + | { matched: false; details: NonNullable } { + for (const condition of params.op.config.runConditions ?? []) { + if (condition.type !== "guard_output") continue; + const sourceOp = params.operationsById.get(condition.sourceOpId); + if (!sourceOp || sourceOp.kind !== "guard") { + return { + matched: false, + details: { + sourceOpId: condition.sourceOpId, + outputKey: condition.outputKey, + operator: condition.operator, + actual: null, + }, + }; + } + + const actual = readGuardOutputValue({ + sourceOp, + artifacts: params.artifacts, + outputKey: condition.outputKey, + }); + const matched = + condition.operator === "is_true" ? actual === true : actual === false; + if (!matched) { + return { + matched: false, + details: { + sourceOpId: condition.sourceOpId, + outputKey: condition.outputKey, + operator: condition.operator, + actual, + }, + }; + } + } + + return { matched: true }; +} diff --git a/server/src/services/chat-generation-v3/operations/knowledge-operation-executor.ts b/server/src/services/chat-generation-v3/operations/knowledge-operation-executor.ts new file mode 100644 index 00000000..4bdbc846 --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/knowledge-operation-executor.ts @@ -0,0 +1,135 @@ +import { renderLiquidTemplate } from "../../chat-core/prompt-template-renderer"; +import { revealKnowledgeRecords } from "../../chat-knowledge/knowledge-reveal-service"; +import { + searchKnowledgeRecords, +} from "../../chat-knowledge/knowledge-search-service"; +import { + parseKnowledgeRevealOperationParams, + parseKnowledgeSearchOperationParams, +} from "../../operations/knowledge-operation-params"; +import { + compileArtifactExposureEffect, + getArtifactPrimaryEffectType, + type RuntimeEffect, +} from "../contracts"; + +import type { InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; +import type { + KnowledgeRevealRequest, + KnowledgeSearchRequest, +} from "@shared/types/chat-knowledge"; +import type { OperationInProfile } from "@shared/types/operation-profiles"; + +type KnowledgeOperation = Extract< + OperationInProfile, + { kind: "knowledge_search" | "knowledge_reveal" } +>; + +type ArtifactSnapshot = Record; + +type KnowledgeContext = { + ownerId: string; + chatId: string; + branchId: string | null; +}; + +function parseJsonPayload(value: unknown, label: string): T { + if (typeof value === "string") { + try { + return JSON.parse(value) as T; + } catch (error) { + throw new Error( + `${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + if (value && typeof value === "object") { + return value as T; + } + throw new Error(`${label} must resolve to a JSON object`); +} + +async function resolveRequestValue(params: { + op: KnowledgeOperation; + liquidContext: InstructionRenderContext; + artifacts: ArtifactSnapshot; +}): Promise { + const parsed = + params.op.kind === "knowledge_search" + ? parseKnowledgeSearchOperationParams(params.op.config.params.params) + : parseKnowledgeRevealOperationParams(params.op.config.params.params); + + if (parsed.source.mode === "artifact") { + const snapshot = params.artifacts[parsed.source.artifactTag]; + if (!snapshot) { + throw new Error(`Artifact with tag ${parsed.source.artifactTag} not found`); + } + return parseJsonPayload(snapshot.value, `${params.op.kind} artifact request`); + } + + const rendered = await renderLiquidTemplate({ + templateText: parsed.source.requestTemplate, + context: params.liquidContext, + options: { + strictVariables: parsed.source.strictVariables, + }, + }); + return parseJsonPayload(rendered, `${params.op.kind} inline request`); +} + +export async function executeKnowledgeOperation(params: { + op: KnowledgeOperation; + liquidContext: InstructionRenderContext; + artifacts: ArtifactSnapshot; + knowledgeContext: KnowledgeContext; +}): Promise<{ effects: RuntimeEffect[]; debugSummary: string }> { + const request = await resolveRequestValue(params); + const value = + params.op.kind === "knowledge_search" + ? await searchKnowledgeRecords({ + ownerId: params.knowledgeContext.ownerId, + chatId: params.knowledgeContext.chatId, + branchId: params.knowledgeContext.branchId, + request: request as KnowledgeSearchRequest, + }) + : await revealKnowledgeRecords({ + ownerId: params.knowledgeContext.ownerId, + chatId: params.knowledgeContext.chatId, + branchId: params.knowledgeContext.branchId, + request: request as KnowledgeRevealRequest, + }); + + const artifact = params.op.config.params.artifact; + const effects: RuntimeEffect[] = [ + { + type: "artifact.upsert", + opId: params.op.opId, + artifactId: artifact.tag, + format: artifact.format, + persistence: artifact.persistence, + writeMode: artifact.writeMode, + history: artifact.history, + semantics: artifact.semantics ?? "intermediate", + value, + }, + ...artifact.exposures.map((exposure) => + compileArtifactExposureEffect({ + opId: params.op.opId, + artifact, + exposure, + value, + }) + ), + ]; + + const count = Array.isArray((value as { hits?: unknown[] }).hits) + ? ((value as { hits: unknown[] }).hits?.length ?? 0) + : Array.isArray((value as { results?: unknown[] }).results) + ? ((value as { results: unknown[] }).results?.length ?? 0) + : 0; + + return { + effects, + debugSummary: `${getArtifactPrimaryEffectType(artifact)}:${count}`, + }; +} diff --git a/server/src/services/chat-generation-v3/operations/knowledge-operations.integration.test.ts b/server/src/services/chat-generation-v3/operations/knowledge-operations.integration.test.ts new file mode 100644 index 00000000..4ea39e2f --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/knowledge-operations.integration.test.ts @@ -0,0 +1,399 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { normalizeOperationArtifactConfig, type OperationInProfile } from "@shared/types/operation-profiles"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../../db/client"; +import { chatBranches, chats, entityProfiles } from "../../../db/schema"; +import { createKnowledgeCollection } from "../../chat-knowledge/knowledge-collections-repository"; +import { upsertKnowledgeRecord } from "../../chat-knowledge/knowledge-records-repository"; + +import { executeOperationsPhase } from "./execute-operations-phase"; + +import type { InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; + +function makeTemplateContext(): InstructionRenderContext { + return { + char: {}, + user: { name: "User" }, + chat: {}, + messages: [], + rag: {}, + art: {}, + now: new Date("2026-03-21T00:00:00.000Z").toISOString(), + }; +} + +async function seedChatScope(params: { + chatId: string; + branchId: string; + ownerId?: string; +}): Promise { + const db = await initDb(); + const ownerId = params.ownerId ?? "global"; + const now = new Date(); + const entityProfileId = `entity:${params.chatId}`; + + await db.insert(entityProfiles).values({ + id: entityProfileId, + ownerId, + name: `Entity ${params.chatId}`, + kind: "CharSpec", + specJson: "{}", + metaJson: null, + isFavorite: false, + createdAt: now, + updatedAt: now, + avatarAssetId: null, + }); + + await db.insert(chats).values({ + id: params.chatId, + ownerId, + entityProfileId, + title: `Chat ${params.chatId}`, + activeBranchId: params.branchId, + instructionId: null, + status: "active", + createdAt: now, + updatedAt: now, + lastMessageAt: null, + lastMessagePreview: null, + version: 0, + metaJson: null, + originChatId: null, + originBranchId: null, + originMessageId: null, + }); + + await db.insert(chatBranches).values({ + id: params.branchId, + ownerId, + chatId: params.chatId, + title: params.branchId, + createdAt: now, + updatedAt: now, + parentBranchId: null, + forkedFromMessageId: null, + forkedFromVariantId: null, + metaJson: null, + currentTurn: 0, + }); +} + +function makeKnowledgeSearchOp(params: { + opId: string; + order: number; + requestTemplate?: string; + artifactTag?: string; +}): OperationInProfile { + return { + opId: params.opId, + name: params.opId, + kind: "knowledge_search", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + triggers: ["generate", "regenerate"], + order: params.order, + params: { + params: { + source: params.requestTemplate + ? { + mode: "inline", + requestTemplate: params.requestTemplate, + } + : { + mode: "artifact", + artifactTag: params.artifactTag!, + }, + }, + artifact: normalizeOperationArtifactConfig({ + opId: params.opId, + kind: "knowledge_search", + title: params.opId, + rawParams: { + artifact: { + artifactId: `artifact:${params.opId}`, + tag: `${params.opId}_result`, + title: `${params.opId} result`, + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }), + }, + }, + } as OperationInProfile; +} + +function makeKnowledgeRevealOp(params: { + opId: string; + order: number; + artifactTag: string; + dependsOn?: string[]; +}): OperationInProfile { + return { + opId: params.opId, + name: params.opId, + kind: "knowledge_reveal", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + triggers: ["generate", "regenerate"], + order: params.order, + dependsOn: params.dependsOn, + params: { + params: { + source: { + mode: "artifact", + artifactTag: params.artifactTag, + }, + }, + artifact: normalizeOperationArtifactConfig({ + opId: params.opId, + kind: "knowledge_reveal", + title: params.opId, + rawParams: { + artifact: { + artifactId: `artifact:${params.opId}`, + tag: `${params.opId}_result`, + title: `${params.opId} result`, + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }), + }, + }, + } as OperationInProfile; +} + +describe("knowledge operations integration", () => { + let tempDir = ""; + let prevDataDir: string | undefined; + + beforeEach(async () => { + prevDataDir = process.env.TALESPINNER_DATA_DIR; + tempDir = await mkdtemp(path.join(tmpdir(), "talespinner-knowledge-ops-")); + process.env.TALESPINNER_DATA_DIR = tempDir; + resetDbForTests(); + await initDb(); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + if (typeof prevDataDir === "string") { + process.env.TALESPINNER_DATA_DIR = prevDataDir; + } else { + delete process.env.TALESPINNER_DATA_DIR; + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("knowledge_search reads inline request and writes JSON artifact with hits", async () => { + await seedChatScope({ chatId: "chat-ops", branchId: "branch-main" }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-ops", + branchId: null, + scope: "chat", + name: "Lore", + kind: "scenario", + layer: "baseline", + origin: "author", + }); + + await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-ops", + branchId: null, + collectionId: collection.id, + recordType: "location", + key: "dark_forest", + title: "Dark Forest", + aliases: ["Black Forest"], + tags: ["forest", "curse"], + summary: "A cursed forest", + content: { text: "The dark forest is vast." }, + accessMode: "public", + layer: "baseline", + origin: "author", + }); + + const result = await executeOperationsPhase({ + runId: "run-knowledge-search", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeKnowledgeSearchOp({ + opId: "search-op", + order: 10, + requestTemplate: + "{\"textQuery\":\"dark forest\",\"limit\":5,\"minimumShouldMatch\":1}", + }), + ], + executionMode: "sequential", + baseMessages: [ + { role: "system", content: "sys" }, + { role: "user", content: "Tell me about the forest" }, + ], + baseArtifacts: {}, + assistantText: "", + templateContext: makeTemplateContext(), + knowledgeContext: { + ownerId: "global", + chatId: "chat-ops", + branchId: "branch-main", + }, + }); + + expect(result[0]?.status).toBe("done"); + expect(result[0]?.effects[0]).toMatchObject({ + type: "artifact.upsert", + format: "json", + }); + expect(result[0]?.effects[0]).toMatchObject({ + value: { + hits: [expect.objectContaining({ recordId: expect.any(String) })], + }, + }); + }); + + test("knowledge_reveal reads request artifact and writes applied result artifact", async () => { + await seedChatScope({ chatId: "chat-ops-reveal", branchId: "branch-main" }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-ops-reveal", + branchId: null, + scope: "chat", + name: "Mystery", + kind: "scenario", + layer: "baseline", + origin: "author", + }); + + const record = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-ops-reveal", + branchId: null, + collectionId: collection.id, + recordType: "fact", + key: "sealed_room", + title: "Sealed Room", + aliases: [], + tags: ["secret"], + summary: "A hidden room under the manor", + content: { text: "The sealed room contains the evidence." }, + accessMode: "discoverable", + layer: "baseline", + origin: "author", + gatePolicy: { + read: { + all: [{ type: "manual_unlock" }], + }, + }, + }); + + const plannerArtifactValue = { + recordIds: [record.id], + reason: "manual reveal", + revealedBy: "system", + context: { + manualUnlock: true, + }, + }; + + const result = await executeOperationsPhase({ + runId: "run-knowledge-reveal", + hook: "before_main_llm", + trigger: "generate", + operations: [ + { + opId: "planner-op", + name: "planner-op", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + triggers: ["generate", "regenerate"], + order: 10, + params: { + template: JSON.stringify(plannerArtifactValue), + artifact: normalizeOperationArtifactConfig({ + opId: "planner-op", + kind: "template", + title: "planner-op", + rawParams: { + artifact: { + artifactId: "artifact:planner-op", + tag: "planner_result", + title: "planner result", + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + exposures: [], + }, + }, + }), + }, + }, + } as OperationInProfile, + makeKnowledgeRevealOp({ + opId: "reveal-op", + order: 20, + artifactTag: "planner_result", + dependsOn: ["planner-op"], + }), + ], + executionMode: "sequential", + baseMessages: [ + { role: "system", content: "sys" }, + { role: "user", content: "Reveal the hidden room" }, + ], + baseArtifacts: {}, + assistantText: "", + templateContext: makeTemplateContext(), + knowledgeContext: { + ownerId: "global", + chatId: "chat-ops-reveal", + branchId: "branch-main", + }, + }); + + const byId = new Map(result.map((item) => [item.opId, item] as const)); + expect(byId.get("planner-op")?.status).toBe("done"); + expect(byId.get("reveal-op")?.status).toBe("done"); + expect(byId.get("reveal-op")?.effects[0]).toMatchObject({ + type: "artifact.upsert", + format: "json", + value: { + results: [expect.objectContaining({ recordId: record.id, status: "revealed" })], + }, + }); + }); +}); diff --git a/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts b/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts index 619710e6..21f8fe8c 100644 --- a/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts +++ b/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts @@ -1,3 +1,8 @@ +import { + normalizeOperationArtifactConfig, + type LegacyOperationOutput, + type OperationInProfile, +} from "@shared/types/operation-profiles"; import { describe, expect, test } from "vitest"; @@ -8,10 +13,22 @@ import { executeOperationsPhase } from "./execute-operations-phase"; import type { InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; import type { RunState } from "../contracts"; -import type { OperationInProfile, OperationOutput } from "@shared/types/operation-profiles"; type TemplateOp = Extract; +function toArtifact(params: { + opId: string; + title: string; + output: LegacyOperationOutput; +}) { + return normalizeOperationArtifactConfig({ + opId: params.opId, + kind: "template", + title: params.title, + rawParams: { output: params.output }, + }); +} + function makeTemplateContext(): InstructionRenderContext { return { char: {}, @@ -54,7 +71,7 @@ function makeRunState(): RunState { }; } -function artifactOutput(tag: string): OperationOutput { +function artifactOutput(tag: string): LegacyOperationOutput { return { type: "artifacts", writeArtifact: { @@ -70,7 +87,7 @@ function makeTemplateOp(params: { opId: string; order: number; template: string; - output: OperationOutput; + output: LegacyOperationOutput; hooks: Array<"before_main_llm" | "after_main_llm">; dependsOn?: string[]; required?: boolean; @@ -88,13 +105,17 @@ function makeTemplateOp(params: { dependsOn: params.dependsOn, params: { template: params.template, - output: params.output, + artifact: toArtifact({ + opId: params.opId, + title: params.opId, + output: params.output, + }), }, }, }; } -function toExecuteArtifacts(runState: RunState): Record { +function toExecuteArtifacts(runState: RunState): Record { const merged = { ...runState.persistedArtifactsSnapshot, ...runState.runArtifacts }; return Object.fromEntries( Object.entries(merged).map(([tag, value]) => [ @@ -173,6 +194,11 @@ describe("operations flow integration (execute + commit)", () => { sessionKey: null, runState, runArtifactStore, + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main", + }, }); expect(beforeCommit.requiredError).toBe(false); @@ -229,6 +255,11 @@ describe("operations flow integration (execute + commit)", () => { sessionKey: null, runState, runArtifactStore, + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main", + }, }); expect(afterCommit.requiredError).toBe(false); @@ -283,12 +314,17 @@ describe("operations flow integration (execute + commit)", () => { sessionKey: null, runState, runArtifactStore, + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main", + }, onCommitEvent: events.onCommitEvent, }); expect(afterCommit.requiredError).toBe(true); expect(afterCommit.report.status).toBe("error"); - expect(afterCommit.report.effects[0]).toMatchObject({ + expect(afterCommit.report.effects.find((effect) => effect.effectType === "prompt.system_update")).toMatchObject({ opId: "after-invalid-required", effectType: "prompt.system_update", status: "error", diff --git a/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts b/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts index 23aa5781..86a3c31f 100644 --- a/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts +++ b/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts @@ -1,16 +1,16 @@ import { commitEffectsPhase } from "../operations/commit-effects-phase"; import { executeOperationsPhase } from "../operations/execute-operations-phase"; +import type { EmitRunEvent, StreamEventsWhile } from "./run-event-stream"; import type { RunArtifactStore } from "../artifacts/run-artifact-store"; import type { OperationSkipDetails, RunDebugStateSnapshotStage, RunEvent, + RunPersistenceTarget, RunState, UserTurnTarget, -} from "../contracts"; -import type { EmitRunEvent, StreamEventsWhile } from "./run-event-stream"; -import type { PromptDraftMessage } from "../contracts"; + PromptDraftMessage } from "../contracts"; import type { OperationHook, OperationInProfile, @@ -25,7 +25,7 @@ type ExecuteOperationHookPhaseParams = { activationSkippedByOpId: ReadonlyMap>; executionMode: "concurrent" | "sequential"; baseMessages: PromptDraftMessage[]; - baseArtifacts: Record; + baseArtifacts: Record; assistantText: string; templateContext: { char: unknown; @@ -44,6 +44,7 @@ type ExecuteOperationHookPhaseParams = { branchId: string; profile: OperationProfile | null; sessionKey: string | null; + persistenceTarget: RunPersistenceTarget; userTurnTarget?: UserTurnTarget; debugEnabled: boolean; emit: EmitRunEvent; @@ -78,6 +79,11 @@ export async function* runOperationHookPhase( baseArtifacts: params.baseArtifacts, assistantText: params.assistantText, templateContext: params.templateContext, + knowledgeContext: { + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + }, abortSignal: params.abortSignal, onOperationStarted: (data) => params.emit("operation.started", data), onOperationFinished: (data) => params.emit("operation.finished", data), @@ -100,6 +106,7 @@ export async function* runOperationHookPhase( sessionKey: params.sessionKey, runState: params.runState, runArtifactStore: params.runArtifactStore, + persistenceTarget: params.persistenceTarget, userTurnTarget: params.userTurnTarget, onUserTurnCanonicalized: (data) => { params.emit("turn.user.canonicalized", data); diff --git a/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts b/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts index ff943b56..63895b2a 100644 --- a/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts +++ b/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts @@ -1,5 +1,3 @@ -import type { GenerateMessage } from "@shared/types/generate"; - import type { ArtifactValue, PromptDraftMessage, @@ -8,6 +6,8 @@ import type { RunResult, RunState, } from "../contracts"; +import type { GenerateMessage } from "@shared/types/generate"; + export function clonePromptDraftMessages(messages: PromptDraftMessage[]): PromptDraftMessage[] { return messages.map((message) => ({ role: message.role, content: message.content })); @@ -20,7 +20,7 @@ export function cloneLlmMessages(messages: GenerateMessage[]): GenerateMessage[] export function mergeArtifacts( persisted: RunState["persistedArtifactsSnapshot"], runOnly: RunState["runArtifacts"] -): Record { +): Record { return Object.fromEntries( Object.entries({ ...persisted, ...runOnly }).map(([tag, value]) => [ tag, @@ -36,18 +36,20 @@ export function mergeArtifactsForDebug( const merged: Record = {}; for (const [tag, value] of Object.entries(persisted)) { merged[tag] = { - usage: value.usage, + format: value.format, semantics: value.semantics, persistence: value.persistence, + writeMode: value.writeMode, value: value.value, history: [...value.history], }; } for (const [tag, value] of Object.entries(runOnly)) { merged[tag] = { - usage: value.usage, + format: value.format, semantics: value.semantics, persistence: value.persistence, + writeMode: value.writeMode, value: value.value, history: [...value.history], }; diff --git a/server/src/services/chat-generation-v3/persist/finalize-run.integration.test.ts b/server/src/services/chat-generation-v3/persist/finalize-run.integration.test.ts index f3bc8bc0..5a59061a 100644 --- a/server/src/services/chat-generation-v3/persist/finalize-run.integration.test.ts +++ b/server/src/services/chat-generation-v3/persist/finalize-run.integration.test.ts @@ -1,8 +1,8 @@ import path from "node:path"; +import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { eq } from "drizzle-orm"; import { applyMigrations } from "../../../db/apply-migrations"; import { initDb, resetDbForTests } from "../../../db/client"; diff --git a/server/src/services/chat-generation-v3/persist/finalize-run.ts b/server/src/services/chat-generation-v3/persist/finalize-run.ts index dbb524cf..0c34b023 100644 --- a/server/src/services/chat-generation-v3/persist/finalize-run.ts +++ b/server/src/services/chat-generation-v3/persist/finalize-run.ts @@ -1,8 +1,8 @@ +import { withDbTransaction } from "../../../db/client"; import { finishGeneration, updateGenerationRunReports, } from "../../chat-core/generations-repository"; -import { withDbTransaction } from "../../../db/client"; import type { RunContext, RunResult } from "../contracts"; diff --git a/server/src/services/chat-generation-v3/prompt/build-base-prompt.test.ts b/server/src/services/chat-generation-v3/prompt/build-base-prompt.test.ts index 37d639e3..c94013c9 100644 --- a/server/src/services/chat-generation-v3/prompt/build-base-prompt.test.ts +++ b/server/src/services/chat-generation-v3/prompt/build-base-prompt.test.ts @@ -138,45 +138,40 @@ describe("buildBasePrompt world-info integration", () => { ); }); - test("uses st_advanced meta to build system/pre/post prompts and derived settings", async () => { + test("uses st_base instructions to build system/pre/post prompts without sampler settings", async () => { mocks.pickInstructionForChat.mockResolvedValue({ id: "tpl-1", ownerId: "global", name: "tpl", + kind: "st_base", engine: "liquidjs", - templateText: "ignored", - meta: { - tsInstruction: { - version: 1, - mode: "st_advanced", - stAdvanced: { - rawPreset: {}, - prompts: [ - { identifier: "main", content: "Main {{char.name}}" }, - { identifier: "jailbreak", content: "Post {{user.name}}" }, + stBase: { + rawPreset: {}, + prompts: [ + { identifier: "main", content: "Main {{char.name}}" }, + { identifier: "jailbreak", content: "Post {{user.name}}" }, + ], + promptOrder: [ + { + character_id: 100001, + order: [ + { identifier: "main", enabled: true }, + { identifier: "chatHistory", enabled: true }, + { identifier: "jailbreak", enabled: true }, ], - promptOrder: [ - { - character_id: 100001, - order: [ - { identifier: "main", enabled: true }, - { identifier: "chatHistory", enabled: true }, - { identifier: "jailbreak", enabled: true }, - ], - }, - ], - responseConfig: { - temperature: 0.6, - openai_max_tokens: 444, - }, - importInfo: { - source: "sillytavern", - fileName: "Default.json", - importedAt: new Date("2026-02-13T00:00:00.000Z").toISOString(), - }, }, + ], + responseConfig: { + temperature: 0.6, + openai_max_tokens: 444, + }, + importInfo: { + source: "sillytavern", + fileName: "Default.json", + importedAt: new Date("2026-02-13T00:00:00.000Z").toISOString(), }, }, + meta: null, createdAt: new Date(), updatedAt: new Date(), }); @@ -211,9 +206,73 @@ describe("buildBasePrompt world-info integration", () => { postHistorySystemMessages: ["Post Dima"], }) ); - expect(out.instructionDerivedSettings).toMatchObject({ - temperature: 0.6, - maxTokens: 444, + expect(out.instructionDerivedSettings).toEqual({}); + }); + + test("allows st_base instruction with only in-chat prompts", async () => { + mocks.pickInstructionForChat.mockResolvedValue({ + id: "tpl-1", + ownerId: "global", + name: "tpl", + kind: "st_base", + engine: "liquidjs", + stBase: { + rawPreset: {}, + prompts: [ + { + identifier: "main", + content: "Main {{char.name}}", + injection_position: 1, + injection_depth: 1, + injection_order: 100, + }, + ], + promptOrder: [ + { + character_id: 100001, + order: [{ identifier: "main", enabled: true }], + }, + ], + responseConfig: {}, + importInfo: { + source: "sillytavern", + fileName: "Default.json", + importedAt: new Date("2026-02-13T00:00:00.000Z").toISOString(), + }, + }, + meta: null, + createdAt: new Date(), + updatedAt: new Date(), }); + mocks.buildInstructionRenderContext.mockResolvedValue({ + char: { name: "Lilly" }, + user: {}, + chat: {}, + messages: [], + rag: {}, + art: {}, + now: new Date().toISOString(), + }); + mocks.renderLiquidTemplate.mockImplementation( + async ({ templateText, context }: { templateText: string; context: Record }) => + templateText.replace("{{char.name}}", String((context.char as { name?: string })?.name ?? "")) + ); + + const out = await buildBasePrompt({ + ownerId: "global", + chatId: "chat", + branchId: "branch", + entityProfileId: "entity", + historyLimit: 50, + trigger: "generate", + }); + + expect(mocks.buildPromptDraft).toHaveBeenCalledWith( + expect.objectContaining({ + systemPrompt: "", + depthInsertions: [{ depth: 1, role: "system", order: 100, content: "Main Lilly" }], + }) + ); + expect(out.prompt.systemPrompt).toBe(""); }); }); diff --git a/server/src/services/chat-generation-v3/prompt/build-base-prompt.ts b/server/src/services/chat-generation-v3/prompt/build-base-prompt.ts index 170085a3..76d147e5 100644 --- a/server/src/services/chat-generation-v3/prompt/build-base-prompt.ts +++ b/server/src/services/chat-generation-v3/prompt/build-base-prompt.ts @@ -1,7 +1,6 @@ import { - getTsInstructionMeta, - resolveStAdvancedInstructionRuntime, -} from "../../chat-core/instruction-st-preset"; + resolveStBaseInstructionRuntime, +} from "../../chat-core/instruction-st-base"; import { pickInstructionForChat } from "../../chat-core/instructions-repository"; import { buildPromptDraft } from "../../chat-core/prompt-draft-builder"; import { @@ -83,36 +82,41 @@ export async function buildBasePrompt(params: { let systemPrompt = DEFAULT_SYSTEM_PROMPT; let preHistorySystemMessages: string[] = []; let postHistorySystemMessages: string[] = []; + let depthInsertions: + | Array<{ + depth: number; + role: "system" | "user" | "assistant"; + order: number; + content: string; + }> + | undefined; let instructionDerivedSettings: Record = {}; - try { - const template = await pickInstructionForChat({ - ownerId: params.ownerId, - chatId: params.chatId, - }); - if (template) { - const tsInstruction = getTsInstructionMeta(template.meta); - if (tsInstruction?.mode === "st_advanced" && tsInstruction.stAdvanced) { - const resolved = await resolveStAdvancedInstructionRuntime({ - stAdvanced: tsInstruction.stAdvanced, - context: templateContext, - }); - if (resolved.systemPrompt.trim().length > 0) { - systemPrompt = resolved.systemPrompt; - } - preHistorySystemMessages = resolved.preHistorySystemMessages; - postHistorySystemMessages = resolved.postHistorySystemMessages; - instructionDerivedSettings = resolved.derivedSettings; - } else { - const rendered = await renderLiquidTemplate({ - templateText: template.templateText, - context: templateContext, - }); - const normalized = rendered.trim(); - if (normalized) systemPrompt = normalized; - } + const template = await pickInstructionForChat({ + ownerId: params.ownerId, + chatId: params.chatId, + }); + if (template) { + if (template.kind === "st_base") { + const resolved = await resolveStBaseInstructionRuntime({ + stBase: template.stBase, + context: templateContext, + }); + systemPrompt = resolved.systemPrompt; + preHistorySystemMessages = resolved.preHistorySystemMessages; + postHistorySystemMessages = resolved.postHistorySystemMessages; + depthInsertions = + resolved.depthInsertions.length > 0 + ? resolved.depthInsertions + : undefined; + instructionDerivedSettings = resolved.derivedSettings; + } else { + const rendered = await renderLiquidTemplate({ + templateText: template.templateText, + context: templateContext, + }); + const normalized = rendered.trim(); + if (normalized) systemPrompt = normalized; } - } catch { - // Keep default fallback. } const builtPrompt = await buildPromptDraft({ @@ -124,6 +128,7 @@ export async function buildBasePrompt(params: { preHistorySystemMessages.length > 0 ? preHistorySystemMessages : undefined, postHistorySystemMessages: postHistorySystemMessages.length > 0 ? postHistorySystemMessages : undefined, + depthInsertions, historyLimit: params.historyLimit, excludeMessageIds: params.excludeMessageIds, excludeEntryIds: params.excludeEntryIds, diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts index 8ef409b6..0ee1fe75 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts @@ -1,3 +1,5 @@ +import { structuredLogger } from "../../core/logging/structured-logger"; + import { ProfileSessionArtifactStore } from "./artifacts/profile-session-artifact-store"; import { RunArtifactStore } from "./artifacts/run-artifact-store"; import { defaultGenerationControlPort } from "./control/generation-control-port"; @@ -7,8 +9,8 @@ import { normalizeOperationActivationConfig, resolveOperationActivationState, } from "./operations/operation-activation-intervals"; -import { runOperationHookPhase } from "./orchestration/run-operation-hook-phase"; import { RunEventStream } from "./orchestration/run-event-stream"; +import { runOperationHookPhase } from "./orchestration/run-operation-hook-phase"; import { buildRunDebugStateSnapshot, buildRunResult, @@ -20,6 +22,7 @@ import { } from "./orchestration/run-state-helpers"; import { defaultGenerationPersistencePort } from "./persist/generation-persistence-port"; import { resolveRunContext } from "./prepare/resolve-run-context"; +import { buildBasePrompt } from "./prompt/build-base-prompt"; import { buildPromptDiagnosticsDebugJson, buildRedactedSnapshot, @@ -28,14 +31,12 @@ import { normalizeLlmMessagesForDebug, sumContextTokensByMessages, } from "./prompt/generation-debug-payload"; -import { buildBasePrompt } from "./prompt/build-base-prompt"; import { ChatRuntimeStateRepository, type ChatRuntimeStatePayload, type ChatRuntimeStateScope, } from "./runtime/chat-runtime-state-repository"; import { loadOrBootstrapRuntimeState } from "./runtime/operation-runtime-state"; -import { structuredLogger } from "../../core/logging/structured-logger"; import type { OperationSkipDetails, RunEvent, RunRequest, RunState } from "./contracts"; import type { OperationInProfile } from "@shared/types/operation-profiles"; @@ -277,6 +278,7 @@ export async function* runChatGenerationV3( branchId: context.branchId, profile: resolved.profile, sessionKey: context.sessionKey, + persistenceTarget: request.persistenceTarget, userTurnTarget: request.userTurnTarget, debugEnabled, emit, @@ -416,6 +418,7 @@ export async function* runChatGenerationV3( branchId: context.branchId, profile: resolved.profile, sessionKey: context.sessionKey, + persistenceTarget: request.persistenceTarget, userTurnTarget: request.userTurnTarget, debugEnabled, emit, diff --git a/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.test.ts b/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.test.ts index facdc7c9..5ab918d0 100644 --- a/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.test.ts +++ b/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.test.ts @@ -13,6 +13,7 @@ import { entityProfiles, operationProfiles, } from "../../../db/schema"; + import { ChatRuntimeStateRepository } from "./chat-runtime-state-repository"; describe("chat-runtime-state-repository", () => { diff --git a/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts b/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts index f4dcc7ae..53d5ce44 100644 --- a/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts +++ b/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts @@ -1,6 +1,7 @@ -import { and, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, eq } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../../chat-core/json"; import { initDb } from "../../../db/client"; import { chatRuntimeState } from "../../../db/schema"; diff --git a/server/src/services/chat-generation-v3/runtime/operation-runtime-state.test.ts b/server/src/services/chat-generation-v3/runtime/operation-runtime-state.test.ts index 1b66b78a..b349a871 100644 --- a/server/src/services/chat-generation-v3/runtime/operation-runtime-state.test.ts +++ b/server/src/services/chat-generation-v3/runtime/operation-runtime-state.test.ts @@ -1,5 +1,17 @@ +import { + normalizeOperationArtifactConfig, + type LegacyOperationOutput, + type OperationInProfile, +} from "@shared/types/operation-profiles"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + buildRuntimeBootstrapFromBranchHistory, + replayOperationActivationByEvents, +} from "./operation-runtime-state"; + +import type { Entry, Part, Variant } from "@shared/types/chat-entry-parts"; + const mocks = vi.hoisted(() => ({ getBranchCurrentTurn: vi.fn(), listEntriesWithActiveVariantsPage: vi.fn(), @@ -13,13 +25,14 @@ vi.mock("../../chat-entry-parts/entries-repository", () => ({ listEntriesWithActiveVariantsPage: mocks.listEntriesWithActiveVariantsPage, })); -import { - buildRuntimeBootstrapFromBranchHistory, - replayOperationActivationByEvents, -} from "./operation-runtime-state"; - -import type { Entry, Part, Variant } from "@shared/types/chat-entry-parts"; -import type { OperationInProfile } from "@shared/types/operation-profiles"; +function toTemplateArtifact(opId: string, output: LegacyOperationOutput) { + return normalizeOperationArtifactConfig({ + opId, + kind: "template", + title: opId, + rawParams: { output }, + }); +} function makeEntry(params: { entryId: string; @@ -86,7 +99,7 @@ describe("operation runtime state bootstrap", () => { order: 1, params: { template: "x", - output: { + artifact: toTemplateArtifact("op-generate", { type: "artifacts", writeArtifact: { tag: "state", @@ -94,7 +107,7 @@ describe("operation runtime state bootstrap", () => { usage: "internal", semantics: "intermediate", }, - }, + }), }, }, }, @@ -111,7 +124,7 @@ describe("operation runtime state bootstrap", () => { order: 2, params: { template: "x", - output: { + artifact: toTemplateArtifact("op-regenerate-only", { type: "artifacts", writeArtifact: { tag: "state2", @@ -119,7 +132,7 @@ describe("operation runtime state bootstrap", () => { usage: "internal", semantics: "intermediate", }, - }, + }), }, }, }, @@ -162,7 +175,7 @@ describe("operation runtime state bootstrap", () => { order: 1, params: { template: "x", - output: { + artifact: toTemplateArtifact("op-turns", { type: "artifacts", writeArtifact: { tag: "state", @@ -170,7 +183,7 @@ describe("operation runtime state bootstrap", () => { usage: "internal", semantics: "intermediate", }, - }, + }), }, }, }, diff --git a/server/src/services/chat-generation-v3/runtime/operation-runtime-state.ts b/server/src/services/chat-generation-v3/runtime/operation-runtime-state.ts index 17961b9e..5340d752 100644 --- a/server/src/services/chat-generation-v3/runtime/operation-runtime-state.ts +++ b/server/src/services/chat-generation-v3/runtime/operation-runtime-state.ts @@ -3,14 +3,15 @@ import { listEntriesWithActiveVariantsPage } from "../../chat-entry-parts/entrie import { getPromptProjection } from "../../chat-entry-parts/projection"; import { serializePart } from "../../chat-entry-parts/prompt-serializers"; import { resolveOperationActivationState } from "../operations/operation-activation-intervals"; + import { ChatRuntimeStateRepository, type ChatRuntimeStatePayload, type ChatRuntimeStateScope, } from "./chat-runtime-state-repository"; -import type { OperationInProfile } from "@shared/types/operation-profiles"; import type { GenerateMessage } from "@shared/types/generate"; +import type { OperationInProfile } from "@shared/types/operation-profiles"; type UserContextEvent = { contextTokens: number; diff --git a/server/src/services/chat-knowledge/chat-knowledge.integration.test.ts b/server/src/services/chat-knowledge/chat-knowledge.integration.test.ts new file mode 100644 index 00000000..31c8635b --- /dev/null +++ b/server/src/services/chat-knowledge/chat-knowledge.integration.test.ts @@ -0,0 +1,616 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { chatBranches, chats, entityProfiles } from "../../db/schema"; + +import { + createKnowledgeCollection, + exportKnowledgeCollection, + importKnowledgeCollection, + listKnowledgeCollections, +} from "./knowledge-collections-repository"; +import { + getKnowledgeRecordById, + listKnowledgeRecords, + upsertKnowledgeRecord, +} from "./knowledge-records-repository"; +import { revealKnowledgeRecords } from "./knowledge-reveal-service"; +import { searchKnowledgeRecords } from "./knowledge-search-service"; + +async function seedChatScope(params: { + ownerId?: string; + chatId: string; + branchIds?: string[]; +}): Promise { + const db = await initDb(); + const ownerId = params.ownerId ?? "global"; + const now = new Date(); + const entityProfileId = `entity:${params.chatId}`; + const branchIds = params.branchIds ?? []; + + await db.insert(entityProfiles).values({ + id: entityProfileId, + ownerId, + name: `Entity ${params.chatId}`, + kind: "CharSpec", + specJson: "{}", + metaJson: null, + isFavorite: false, + createdAt: now, + updatedAt: now, + avatarAssetId: null, + }); + + await db.insert(chats).values({ + id: params.chatId, + ownerId, + entityProfileId, + title: `Chat ${params.chatId}`, + activeBranchId: branchIds[0] ?? null, + instructionId: null, + status: "active", + createdAt: now, + updatedAt: now, + lastMessageAt: null, + lastMessagePreview: null, + version: 0, + metaJson: null, + originChatId: null, + originBranchId: null, + originMessageId: null, + }); + + if (branchIds.length > 0) { + await db.insert(chatBranches).values( + branchIds.map((branchId) => ({ + id: branchId, + ownerId, + chatId: params.chatId, + title: branchId, + createdAt: now, + updatedAt: now, + parentBranchId: null, + forkedFromMessageId: null, + forkedFromVariantId: null, + metaJson: null, + currentTurn: 0, + })) + ); + } +} + +describe("chat knowledge integration", () => { + let tempDir = ""; + let prevDataDir: string | undefined; + + beforeEach(async () => { + prevDataDir = process.env.TALESPINNER_DATA_DIR; + tempDir = await mkdtemp(path.join(tmpdir(), "talespinner-knowledge-")); + process.env.TALESPINNER_DATA_DIR = tempDir; + resetDbForTests(); + await initDb(); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + if (typeof prevDataDir === "string") { + process.env.TALESPINNER_DATA_DIR = prevDataDir; + } else { + delete process.env.TALESPINNER_DATA_DIR; + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("creates collections and records, then enforces unique key per scoped collection", async () => { + await seedChatScope({ chatId: "chat-1" }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-1", + branchId: null, + scope: "chat", + name: "Lore Pack", + kind: "lore", + layer: "baseline", + origin: "import", + }); + + const first = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-1", + branchId: null, + collectionId: collection.id, + recordType: "location", + key: "dark_forest", + title: "Dark Forest", + aliases: ["Black Forest"], + tags: ["forest", "curse"], + summary: "Ancient cursed forest", + content: { + text: "The Dark Forest is feared by travelers.", + }, + accessMode: "public", + layer: "baseline", + origin: "import", + }); + + const second = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-1", + branchId: null, + collectionId: collection.id, + recordType: "location", + key: "dark_forest", + title: "Dark Forest Revised", + aliases: ["Night Forest"], + tags: ["forest", "north"], + summary: "Updated summary", + content: { + text: "Updated lore text", + }, + accessMode: "public", + layer: "baseline", + origin: "import", + }); + + expect(second.id).toBe(first.id); + + const records = await listKnowledgeRecords({ + ownerId: "global", + chatId: "chat-1", + branchId: null, + collectionId: collection.id, + }); + expect(records).toHaveLength(1); + expect(records[0]?.title).toBe("Dark Forest Revised"); + }); + + test("search ranks exact key/title over tag and full-text matches", async () => { + await seedChatScope({ chatId: "chat-search", branchIds: ["branch-a"] }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-search", + branchId: null, + scope: "chat", + name: "Search Pack", + kind: "scenario", + layer: "baseline", + origin: "author", + }); + + await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-search", + branchId: null, + collectionId: collection.id, + recordType: "location", + key: "dark_forest", + title: "Dark Forest", + aliases: ["Black Forest"], + tags: ["forest", "curse"], + summary: "Cursed northern forest", + content: { text: "Dark forest with ancient curse." }, + accessMode: "public", + layer: "baseline", + origin: "author", + }); + + await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-search", + branchId: null, + collectionId: collection.id, + recordType: "location", + key: "magic_grove", + title: "Magic Grove", + aliases: ["Enchanted Forest"], + tags: ["forest", "magic"], + summary: "Forest filled with old spells", + content: { text: "Magic grove with luminous trees." }, + accessMode: "public", + layer: "baseline", + origin: "author", + }); + + const result = await searchKnowledgeRecords({ + ownerId: "global", + chatId: "chat-search", + branchId: "branch-a", + request: { + textQuery: "dark forest curse", + keys: ["dark_forest"], + limit: 10, + minimumShouldMatch: 1, + }, + }); + + expect(result.hits[0]?.record?.key).toBe("dark_forest"); + expect(result.hits[0]?.matchReasons).toContain("key_exact"); + expect(result.hits[0]?.score).toBeGreaterThan(result.hits[1]?.score ?? 0); + }); + + test("search returns discoverable record as preview only until reveal succeeds", async () => { + await seedChatScope({ chatId: "chat-reveal", branchIds: ["branch-main"] }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-reveal", + branchId: null, + scope: "chat", + name: "Mystery Pack", + kind: "scenario", + layer: "baseline", + origin: "import", + }); + + const clue = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-reveal", + branchId: null, + collectionId: collection.id, + recordType: "fact", + key: "clue_tablet", + title: "Ancient Tablet", + aliases: ["Stone Tablet"], + tags: ["clue", "tablet"], + summary: "An engraved tablet with missing symbols", + content: { solution: "The symbol points to the catacombs." }, + accessMode: "public", + layer: "baseline", + origin: "import", + }); + + const hidden = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-reveal", + branchId: null, + collectionId: collection.id, + recordType: "fact", + key: "catacomb_secret", + title: "Catacomb Secret", + aliases: ["Hidden Passage"], + tags: ["secret", "catacomb"], + summary: "There is a sealed route below the ruins", + content: { spoiler: "The king's remains are hidden there." }, + accessMode: "discoverable", + layer: "baseline", + origin: "import", + gatePolicy: { + read: { + all: [{ type: "record_revealed", recordKey: "clue_tablet" }], + }, + prompt: { + all: [{ type: "record_revealed", recordKey: "clue_tablet" }], + }, + }, + }); + + const before = await searchKnowledgeRecords({ + ownerId: "global", + chatId: "chat-reveal", + branchId: "branch-main", + request: { + textQuery: "catacomb secret", + limit: 10, + }, + }); + + const beforeHit = before.hits.find((item) => item.recordId === hidden.id); + expect(beforeHit?.visibility).toBe("preview"); + expect(beforeHit?.record).toBeNull(); + expect(beforeHit?.preview.title).toBe("Catacomb Secret"); + + const failedReveal = await revealKnowledgeRecords({ + ownerId: "global", + chatId: "chat-reveal", + branchId: "branch-main", + request: { + recordIds: [hidden.id], + reason: "premature", + revealedBy: "llm", + }, + }); + + expect(failedReveal.results[0]).toMatchObject({ + recordId: hidden.id, + status: "blocked", + }); + + await revealKnowledgeRecords({ + ownerId: "global", + chatId: "chat-reveal", + branchId: "branch-main", + request: { + recordIds: [clue.id], + reason: "found tablet", + revealedBy: "system", + context: { + manualUnlock: true, + }, + }, + }); + + const successfulReveal = await revealKnowledgeRecords({ + ownerId: "global", + chatId: "chat-reveal", + branchId: "branch-main", + request: { + recordIds: [hidden.id], + reason: "clue chain satisfied", + revealedBy: "system", + }, + }); + + expect(successfulReveal.results[0]).toMatchObject({ + recordId: hidden.id, + status: "revealed", + }); + + const after = await searchKnowledgeRecords({ + ownerId: "global", + chatId: "chat-reveal", + branchId: "branch-main", + request: { + textQuery: "catacomb secret", + limit: 10, + }, + }); + + const afterHit = after.hits.find((item) => item.recordId === hidden.id); + expect(afterHit?.visibility).toBe("full"); + expect(afterHit?.record?.content).toEqual({ spoiler: "The king's remains are hidden there." }); + + const persisted = await getKnowledgeRecordById(hidden.id); + expect(persisted?.content).toEqual({ spoiler: "The king's remains are hidden there." }); + }); + + test("reveal supports flag_equals context predicate", async () => { + await seedChatScope({ chatId: "chat-flags", branchIds: ["branch-flags"] }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-flags", + branchId: null, + scope: "chat", + name: "Flags Pack", + kind: "scenario", + layer: "baseline", + origin: "author", + }); + + const record = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-flags", + branchId: null, + collectionId: collection.id, + recordType: "fact", + key: "vault_code", + title: "Vault Code", + aliases: [], + tags: ["vault"], + summary: "The code is written under the altar", + content: { code: "8132" }, + accessMode: "discoverable", + layer: "baseline", + origin: "author", + gatePolicy: { + read: { + all: [{ type: "flag_equals", key: "quest.has_map", value: true }], + }, + }, + }); + + const blocked = await revealKnowledgeRecords({ + ownerId: "global", + chatId: "chat-flags", + branchId: "branch-flags", + request: { + recordIds: [record.id], + reason: "missing flag", + revealedBy: "llm", + context: { + flags: { + "quest.has_map": false, + }, + }, + }, + }); + + expect(blocked.results[0]?.status).toBe("blocked"); + + const allowed = await revealKnowledgeRecords({ + ownerId: "global", + chatId: "chat-flags", + branchId: "branch-flags", + request: { + recordIds: [record.id], + reason: "flag set", + revealedBy: "system", + context: { + flags: { + "quest.has_map": true, + }, + }, + }, + }); + + expect(allowed.results[0]?.status).toBe("revealed"); + }); + + test("branch overlay does not leak branch-scoped records into sibling branch", async () => { + await seedChatScope({ chatId: "chat-branches", branchIds: ["branch-a", "branch-b"] }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-branches", + branchId: null, + scope: "chat", + name: "Branch Pack", + kind: "scenario", + layer: "baseline", + origin: "author", + }); + + await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-branches", + branchId: "branch-a", + collectionId: collection.id, + recordType: "note", + key: "branch_a_note", + title: "Branch A Note", + aliases: [], + tags: ["branch"], + summary: "Only branch A should see this", + content: { note: "A only" }, + accessMode: "public", + layer: "runtime", + origin: "llm", + }); + + const branchA = await searchKnowledgeRecords({ + ownerId: "global", + chatId: "chat-branches", + branchId: "branch-a", + request: { + textQuery: "branch a note", + limit: 10, + }, + }); + expect(branchA.hits.some((item) => item.record?.key === "branch_a_note")).toBe(true); + + const branchB = await searchKnowledgeRecords({ + ownerId: "global", + chatId: "chat-branches", + branchId: "branch-b", + request: { + textQuery: "branch a note", + limit: 10, + }, + }); + expect(branchB.hits.some((item) => item.record?.key === "branch_a_note")).toBe(false); + }); + + test("export/import support baseline and runtime filtering", async () => { + await seedChatScope({ + chatId: "chat-export", + branchIds: ["branch-export"], + }); + await seedChatScope({ chatId: "chat-imported" }); + + const collection = await createKnowledgeCollection({ + ownerId: "global", + chatId: "chat-export", + branchId: null, + scope: "chat", + name: "Export Pack", + kind: "scenario", + layer: "baseline", + origin: "import", + }); + + const baseline = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-export", + branchId: null, + collectionId: collection.id, + recordType: "location", + key: "city", + title: "Old City", + aliases: [], + tags: ["city"], + summary: "Original city record", + content: { text: "Baseline city." }, + accessMode: "public", + layer: "baseline", + origin: "import", + }); + + const runtime = await upsertKnowledgeRecord({ + ownerId: "global", + chatId: "chat-export", + branchId: null, + collectionId: collection.id, + recordType: "note", + key: "runtime_note", + title: "Runtime Note", + aliases: [], + tags: ["note"], + summary: "Created in play", + content: { text: "Runtime note." }, + accessMode: "public", + layer: "runtime", + origin: "llm", + derivedFromRecordId: baseline.id, + }); + + await revealKnowledgeRecords({ + ownerId: "global", + chatId: "chat-export", + branchId: "branch-export", + request: { + recordIds: [baseline.id], + revealedBy: "system", + reason: "baseline reveal", + context: { manualUnlock: true }, + }, + }); + + const baselineOnly = await exportKnowledgeCollection({ + ownerId: "global", + chatId: "chat-export", + branchId: "branch-export", + collectionId: collection.id, + mode: "baseline_only", + }); + expect(baselineOnly.records.map((item) => item.key)).toEqual(["city"]); + + const runtimeOnly = await exportKnowledgeCollection({ + ownerId: "global", + chatId: "chat-export", + branchId: "branch-export", + collectionId: collection.id, + mode: "runtime_only", + }); + expect(runtimeOnly.records.map((item) => item.key)).toEqual(["runtime_note"]); + + const baselineWithReveals = await exportKnowledgeCollection({ + ownerId: "global", + chatId: "chat-export", + branchId: "branch-export", + collectionId: collection.id, + mode: "baseline_with_reveals", + }); + expect(baselineWithReveals.records.map((item) => item.key)).toEqual(["city"]); + expect(baselineWithReveals.accessState).toHaveLength(1); + + const imported = await importKnowledgeCollection({ + ownerId: "global", + chatId: "chat-imported", + branchId: null, + payload: baselineOnly, + }); + + const collections = await listKnowledgeCollections({ + ownerId: "global", + chatId: "chat-imported", + branchId: null, + }); + + expect(imported.collection.id).toBeTruthy(); + expect(collections.map((item) => item.name)).toContain("Export Pack"); + expect(imported.records).toHaveLength(1); + expect(imported.records[0]?.key).toBe("city"); + + expect(runtime.id).toBeTruthy(); + }); +}); diff --git a/server/src/services/chat-knowledge/knowledge-access-repository.ts b/server/src/services/chat-knowledge/knowledge-access-repository.ts new file mode 100644 index 00000000..d1bba54c --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-access-repository.ts @@ -0,0 +1,133 @@ +import { randomUUID as uuidv4 } from "node:crypto"; + +import { and, eq, isNull, or } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { knowledgeRecordAccessState } from "../../db/schema"; + +import { + encodeJson, + rowToKnowledgeRecordAccessStateDto, +} from "./knowledge-helpers"; + +import type { + KnowledgeDiscoverState, + KnowledgePromptState, + KnowledgeReadState, + KnowledgeRecordAccessStateDto, + KnowledgeRevealActor, + KnowledgeRevealState, +} from "@shared/types/chat-knowledge"; + +export async function listKnowledgeRecordAccessState(params: { + ownerId?: string; + chatId: string; + branchId: string | null; +}): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(knowledgeRecordAccessState) + .where( + and( + eq(knowledgeRecordAccessState.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecordAccessState.chatId, params.chatId), + or( + params.branchId === null + ? isNull(knowledgeRecordAccessState.branchId) + : eq(knowledgeRecordAccessState.branchId, params.branchId), + isNull(knowledgeRecordAccessState.branchId) + ) + ) + ); + return rows.map(rowToKnowledgeRecordAccessStateDto); +} + +export async function getKnowledgeRecordAccessState(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + recordId: string; +}): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(knowledgeRecordAccessState) + .where( + and( + eq(knowledgeRecordAccessState.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecordAccessState.chatId, params.chatId), + params.branchId === null + ? isNull(knowledgeRecordAccessState.branchId) + : eq(knowledgeRecordAccessState.branchId, params.branchId), + eq(knowledgeRecordAccessState.recordId, params.recordId) + ) + ) + .limit(1); + return rows[0] ? rowToKnowledgeRecordAccessStateDto(rows[0]) : null; +} + +export async function upsertKnowledgeAccessState(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + recordId: string; + discoverState: KnowledgeDiscoverState; + readState: KnowledgeReadState; + promptState: KnowledgePromptState; + revealState: KnowledgeRevealState; + revealedAt?: Date | null; + revealedBy?: KnowledgeRevealActor | null; + revealReason?: string | null; + flags?: Record; +}): Promise { + const db = await initDb(); + const now = new Date(); + await db + .insert(knowledgeRecordAccessState) + .values({ + id: uuidv4(), + ownerId: params.ownerId ?? "global", + chatId: params.chatId, + branchId: params.branchId, + recordId: params.recordId, + discoverState: params.discoverState, + readState: params.readState, + promptState: params.promptState, + revealState: params.revealState, + revealedAt: params.revealedAt ?? null, + revealedBy: params.revealedBy ?? null, + revealReason: params.revealReason ?? null, + flagsJson: encodeJson(params.flags ?? {}, "{}"), + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + knowledgeRecordAccessState.chatId, + knowledgeRecordAccessState.branchId, + knowledgeRecordAccessState.recordId, + ], + set: { + discoverState: params.discoverState, + readState: params.readState, + promptState: params.promptState, + revealState: params.revealState, + revealedAt: params.revealedAt ?? null, + revealedBy: params.revealedBy ?? null, + revealReason: params.revealReason ?? null, + flagsJson: encodeJson(params.flags ?? {}, "{}"), + updatedAt: now, + }, + }); + + const reloaded = await getKnowledgeRecordAccessState({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + recordId: params.recordId, + }); + if (!reloaded) { + throw new Error("Failed to upsert knowledge access state"); + } + return reloaded; +} diff --git a/server/src/services/chat-knowledge/knowledge-collections-repository.ts b/server/src/services/chat-knowledge/knowledge-collections-repository.ts new file mode 100644 index 00000000..99073d78 --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-collections-repository.ts @@ -0,0 +1,257 @@ +import { randomUUID as uuidv4 } from "node:crypto"; + +import { and, eq, isNull, or } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { knowledgeCollections } from "../../db/schema"; + +import { listKnowledgeRecordAccessState, upsertKnowledgeAccessState } from "./knowledge-access-repository"; +import { + encodeJson, + rowToKnowledgeCollectionDto, +} from "./knowledge-helpers"; +import { createKnowledgeRecordLinksBulk, listKnowledgeRecordLinks } from "./knowledge-links-repository"; +import { listKnowledgeRecords, upsertKnowledgeRecord } from "./knowledge-records-repository"; + +import type { + KnowledgeCollectionDto, + KnowledgeCollectionExportPayload, + KnowledgeCollectionImportResult, + KnowledgeExportMode, + KnowledgeLayer, + KnowledgeOrigin, + KnowledgeScope, +} from "@shared/types/chat-knowledge"; + +function shouldIncludeRecordForExport( + mode: KnowledgeExportMode, + layer: KnowledgeLayer +): boolean { + if (mode === "baseline_plus_runtime") return true; + if (mode === "baseline_only" || mode === "baseline_with_reveals") return layer === "baseline"; + return layer === "runtime"; +} + +export async function createKnowledgeCollection(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + scope: KnowledgeScope; + name: string; + kind?: string | null; + description?: string | null; + status?: "active" | "archived" | "deleted"; + origin: KnowledgeOrigin; + layer: KnowledgeLayer; + meta?: unknown; +}): Promise { + const db = await initDb(); + const id = uuidv4(); + const now = new Date(); + await db.insert(knowledgeCollections).values({ + id, + ownerId: params.ownerId ?? "global", + chatId: params.chatId, + branchId: params.branchId, + scope: params.scope, + name: params.name, + kind: params.kind ?? null, + description: params.description ?? null, + status: params.status ?? "active", + origin: params.origin, + layer: params.layer, + metaJson: typeof params.meta === "undefined" ? null : encodeJson(params.meta, "null"), + createdAt: now, + updatedAt: now, + }); + const created = await getKnowledgeCollectionById(id); + if (!created) throw new Error("Failed to create knowledge collection"); + return created; +} + +export async function getKnowledgeCollectionById( + id: string +): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(knowledgeCollections) + .where(eq(knowledgeCollections.id, id)) + .limit(1); + return rows[0] ? rowToKnowledgeCollectionDto(rows[0]) : null; +} + +export async function listKnowledgeCollections(params: { + ownerId?: string; + chatId: string; + branchId: string | null; +}): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(knowledgeCollections) + .where( + and( + eq(knowledgeCollections.ownerId, params.ownerId ?? "global"), + eq(knowledgeCollections.chatId, params.chatId), + params.branchId === null + ? isNull(knowledgeCollections.branchId) + : or( + isNull(knowledgeCollections.branchId), + eq(knowledgeCollections.branchId, params.branchId) + ) + ) + ); + return rows.map(rowToKnowledgeCollectionDto); +} + +export async function exportKnowledgeCollection(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + collectionId: string; + mode: KnowledgeExportMode; +}): Promise { + const collection = await getKnowledgeCollectionById(params.collectionId); + if (!collection) throw new Error("Knowledge collection not found"); + + const records = (await listKnowledgeRecords({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + collectionId: params.collectionId, + })).filter((item) => shouldIncludeRecordForExport(params.mode, item.layer)); + + const recordIds = new Set(records.map((item) => item.id)); + const links = (await listKnowledgeRecordLinks({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + })).filter((item) => recordIds.has(item.fromRecordId) && recordIds.has(item.toRecordId)); + + const accessState = + params.mode === "baseline_with_reveals" || + params.mode === "runtime_only" || + params.mode === "baseline_plus_runtime" + ? (await listKnowledgeRecordAccessState({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + })).filter((item) => recordIds.has(item.recordId)) + : []; + + return { + version: 1, + collection: { + ownerId: collection.ownerId, + chatId: collection.chatId, + branchId: collection.branchId, + scope: collection.scope, + name: collection.name, + kind: collection.kind, + description: collection.description, + status: collection.status, + origin: collection.origin, + layer: collection.layer, + meta: collection.meta, + sourceCollectionId: collection.id, + }, + records, + links, + accessState, + mode: params.mode, + }; +} + +export async function importKnowledgeCollection(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + payload: KnowledgeCollectionExportPayload; +}): Promise { + const collection = await createKnowledgeCollection({ + ownerId: params.ownerId ?? params.payload.collection.ownerId, + chatId: params.chatId, + branchId: params.branchId, + scope: params.payload.collection.scope, + name: params.payload.collection.name, + kind: params.payload.collection.kind, + description: params.payload.collection.description, + status: params.payload.collection.status, + origin: params.payload.collection.origin, + layer: params.payload.collection.layer, + meta: params.payload.collection.meta, + }); + + const idMap = new Map(); + const importedRecords = []; + for (const item of params.payload.records) { + const created = await upsertKnowledgeRecord({ + ownerId: params.ownerId ?? item.ownerId, + chatId: params.chatId, + branchId: params.branchId, + collectionId: collection.id, + recordType: item.recordType, + key: item.key, + title: item.title, + aliases: item.aliases, + tags: item.tags, + summary: item.summary, + content: item.content, + accessMode: item.accessMode, + origin: item.origin, + layer: item.layer, + derivedFromRecordId: null, + sourceMessageId: item.sourceMessageId, + sourceOperationId: item.sourceOperationId, + status: item.status, + gatePolicy: item.gatePolicy, + meta: item.meta, + }); + idMap.set(item.id, created.id); + importedRecords.push(created); + } + + const importedLinks = await createKnowledgeRecordLinksBulk({ + ownerId: params.ownerId ?? "global", + chatId: params.chatId, + branchId: params.branchId, + items: params.payload.links + .map((item) => ({ + fromRecordId: idMap.get(item.fromRecordId) ?? "", + relationType: item.relationType, + toRecordId: idMap.get(item.toRecordId) ?? "", + meta: item.meta, + })) + .filter((item) => item.fromRecordId.length > 0 && item.toRecordId.length > 0), + }); + + const importedAccessState = []; + for (const item of params.payload.accessState) { + const nextRecordId = idMap.get(item.recordId); + if (!nextRecordId) continue; + importedAccessState.push( + await upsertKnowledgeAccessState({ + ownerId: params.ownerId ?? item.ownerId, + chatId: params.chatId, + branchId: params.branchId, + recordId: nextRecordId, + discoverState: item.discoverState, + readState: item.readState, + promptState: item.promptState, + revealState: item.revealState, + revealedAt: item.revealedAt, + revealedBy: item.revealedBy, + revealReason: item.revealReason, + flags: item.flags, + }) + ); + } + + return { + collection, + records: importedRecords, + links: importedLinks, + accessState: importedAccessState, + }; +} diff --git a/server/src/services/chat-knowledge/knowledge-gate-policy.ts b/server/src/services/chat-knowledge/knowledge-gate-policy.ts new file mode 100644 index 00000000..007c9d22 --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-gate-policy.ts @@ -0,0 +1,146 @@ +import { getKnowledgeRecordAccessState } from "./knowledge-access-repository"; +import { findKnowledgeRecordsByKeys } from "./knowledge-records-repository"; + +import type { + KnowledgeAccessMode, + KnowledgeGateExpression, + KnowledgeGateNode, + KnowledgeGatePredicate, + KnowledgeRecordDto, + KnowledgeRuntimeContext, +} from "@shared/types/chat-knowledge"; + +type EvaluationEnv = { + ownerId: string; + chatId: string; + branchId: string | null; + context?: KnowledgeRuntimeContext; +}; + +export function getDefaultAccessSnapshot(accessMode: KnowledgeAccessMode) { + if (accessMode === "public") { + return { + discoverState: "visible" as const, + readState: "full" as const, + promptState: "allowed" as const, + revealState: "hidden" as const, + }; + } + if (accessMode === "discoverable") { + return { + discoverState: "discoverable" as const, + readState: "blocked" as const, + promptState: "blocked" as const, + revealState: "hidden" as const, + }; + } + return { + discoverState: "hidden" as const, + readState: "blocked" as const, + promptState: "blocked" as const, + revealState: "hidden" as const, + }; +} + +async function resolveRecordState(params: { + predicate: Extract; + env: EvaluationEnv; +}) { + const { predicate, env } = params; + let recordId = predicate.recordId; + if (!recordId && predicate.recordKey) { + const records = await findKnowledgeRecordsByKeys({ + ownerId: env.ownerId, + chatId: env.chatId, + branchId: env.branchId, + keys: [predicate.recordKey], + }); + recordId = records[0]?.id; + } + if (!recordId) return null; + return getKnowledgeRecordAccessState({ + ownerId: env.ownerId, + chatId: env.chatId, + branchId: env.branchId, + recordId, + }); +} + +async function evaluatePredicate(params: { + predicate: KnowledgeGatePredicate; + env: EvaluationEnv; +}): Promise { + const { predicate, env } = params; + if (predicate.type === "flag_equals") { + return env.context?.flags?.[predicate.key] === predicate.value; + } + if (predicate.type === "counter_gte") { + return (env.context?.counters?.[predicate.key] ?? 0) >= predicate.value; + } + if (predicate.type === "manual_unlock") { + return env.context?.manualUnlock === true; + } + if (predicate.type === "branch_only") { + return predicate.branchId ? predicate.branchId === env.branchId : env.branchId !== null; + } + + const state = await resolveRecordState({ + predicate, + env, + }); + if (!state) return false; + if (predicate.type === "record_revealed") { + return state.revealState === "revealed"; + } + if (predicate.revealState && state.revealState !== predicate.revealState) return false; + if (predicate.readState && state.readState !== predicate.readState) return false; + if (predicate.promptState && state.promptState !== predicate.promptState) return false; + return true; +} + +async function evaluateNode(params: { + node: KnowledgeGateNode; + env: EvaluationEnv; +}): Promise { + const { node, env } = params; + if ("type" in node) { + return evaluatePredicate({ + predicate: node, + env, + }); + } + if ("all" in node) { + for (const item of node.all) { + if (!(await evaluateNode({ node: item, env }))) return false; + } + return true; + } + if ("any" in node) { + for (const item of node.any) { + if (await evaluateNode({ node: item, env })) return true; + } + return false; + } + return !(await evaluateNode({ node: node.not, env })); +} + +export async function evaluateKnowledgeGate(params: { + record: KnowledgeRecordDto; + gate?: { mode?: "always" } | KnowledgeGateExpression; + ownerId: string; + chatId: string; + branchId: string | null; + context?: KnowledgeRuntimeContext; +}): Promise { + if (!params.gate) return true; + if ("mode" in params.gate) return params.gate.mode === "always"; + return evaluateNode({ + node: params.gate as KnowledgeGateExpression, + env: { + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + context: params.context, + }, + }); +} diff --git a/server/src/services/chat-knowledge/knowledge-helpers.ts b/server/src/services/chat-knowledge/knowledge-helpers.ts new file mode 100644 index 00000000..1eaf7b24 --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-helpers.ts @@ -0,0 +1,148 @@ +import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; + +import type { + knowledgeCollections, + knowledgeRecordAccessState, + knowledgeRecordLinks, + knowledgeRecords, +} from "../../db/schema"; +import type { + KnowledgeAccessMode, + KnowledgeCollectionDto, + KnowledgeGatePolicy, + KnowledgeLayer, + KnowledgeOrigin, + KnowledgeRecordAccessStateDto, + KnowledgeRecordDto, + KnowledgeRecordLinkDto, +} from "@shared/types/chat-knowledge"; + +function normalizeStringArray(input: unknown): string[] { + if (!Array.isArray(input)) return []; + return Array.from( + new Set( + input + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter((item) => item.length > 0) + ) + ); +} + +function flattenContent(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (typeof value === "number" || typeof value === "boolean") return [String(value)]; + if (!value || typeof value !== "object") return []; + if (Array.isArray(value)) return value.flatMap((item) => flattenContent(item)); + return Object.values(value as Record).flatMap((item) => flattenContent(item)); +} + +export function buildKnowledgeSearchText(params: { + title: string; + aliases: string[]; + tags: string[]; + summary?: string | null; + content: unknown; + accessMode: KnowledgeAccessMode; +}): string { + const base = [params.title, ...params.aliases, ...params.tags, params.summary ?? ""]; + const contentText = + params.accessMode === "public" ? flattenContent(params.content) : []; + return [...base, ...contentText] + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .join(" "); +} + +export function rowToKnowledgeCollectionDto( + row: typeof knowledgeCollections.$inferSelect +): KnowledgeCollectionDto { + return { + id: row.id, + ownerId: row.ownerId, + chatId: row.chatId, + branchId: row.branchId ?? null, + scope: row.scope, + name: row.name, + kind: row.kind ?? null, + description: row.description ?? null, + status: row.status, + origin: row.origin, + layer: row.layer, + meta: safeJsonParse(row.metaJson, null), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function rowToKnowledgeRecordDto( + row: typeof knowledgeRecords.$inferSelect +): KnowledgeRecordDto { + return { + id: row.id, + ownerId: row.ownerId, + chatId: row.chatId, + branchId: row.branchId ?? null, + collectionId: row.collectionId, + recordType: row.recordType, + key: row.key, + title: row.title, + aliases: normalizeStringArray(safeJsonParse(row.aliasesJson, [])), + tags: normalizeStringArray(safeJsonParse(row.tagsJson, [])), + summary: row.summary ?? null, + content: safeJsonParse(row.contentJson, null), + searchText: row.searchText, + accessMode: row.accessMode, + origin: row.origin as KnowledgeOrigin, + layer: row.layer as KnowledgeLayer, + derivedFromRecordId: row.derivedFromRecordId ?? null, + sourceMessageId: row.sourceMessageId ?? null, + sourceOperationId: row.sourceOperationId ?? null, + status: row.status, + gatePolicy: safeJsonParse(row.gatePolicyJson, null), + meta: safeJsonParse(row.metaJson, null), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function rowToKnowledgeRecordLinkDto( + row: typeof knowledgeRecordLinks.$inferSelect +): KnowledgeRecordLinkDto { + return { + id: row.id, + ownerId: row.ownerId, + chatId: row.chatId, + branchId: row.branchId ?? null, + fromRecordId: row.fromRecordId, + relationType: row.relationType, + toRecordId: row.toRecordId, + meta: safeJsonParse(row.metaJson, null), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function rowToKnowledgeRecordAccessStateDto( + row: typeof knowledgeRecordAccessState.$inferSelect +): KnowledgeRecordAccessStateDto { + return { + id: row.id, + ownerId: row.ownerId, + chatId: row.chatId, + branchId: row.branchId ?? null, + recordId: row.recordId, + discoverState: row.discoverState, + readState: row.readState, + promptState: row.promptState, + revealState: row.revealState, + revealedAt: row.revealedAt ?? null, + revealedBy: row.revealedBy ?? null, + revealReason: row.revealReason ?? null, + flags: safeJsonParse>(row.flagsJson, {}), + updatedAt: row.updatedAt, + }; +} + +export function encodeJson(value: unknown, fallback: string): string { + return safeJsonStringify(value, fallback); +} diff --git a/server/src/services/chat-knowledge/knowledge-links-repository.ts b/server/src/services/chat-knowledge/knowledge-links-repository.ts new file mode 100644 index 00000000..1b59fd5d --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-links-repository.ts @@ -0,0 +1,72 @@ +import { randomUUID as uuidv4 } from "node:crypto"; + +import { and, eq, isNull, or } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { knowledgeRecordLinks } from "../../db/schema"; + +import { encodeJson, rowToKnowledgeRecordLinkDto } from "./knowledge-helpers"; + +import type { KnowledgeRecordLinkDto } from "@shared/types/chat-knowledge"; + +export async function createKnowledgeRecordLinksBulk(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + items: Array<{ + fromRecordId: string; + relationType: string; + toRecordId: string; + meta?: unknown; + }>; +}): Promise { + if (params.items.length === 0) return []; + const db = await initDb(); + const now = new Date(); + await db + .insert(knowledgeRecordLinks) + .values( + params.items.map((item) => ({ + id: uuidv4(), + ownerId: params.ownerId ?? "global", + chatId: params.chatId, + branchId: params.branchId, + fromRecordId: item.fromRecordId, + relationType: item.relationType, + toRecordId: item.toRecordId, + metaJson: typeof item.meta === "undefined" ? null : encodeJson(item.meta, "null"), + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoNothing(); + return listKnowledgeRecordLinks({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + }); +} + +export async function listKnowledgeRecordLinks(params: { + ownerId?: string; + chatId: string; + branchId: string | null; +}): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(knowledgeRecordLinks) + .where( + and( + eq(knowledgeRecordLinks.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecordLinks.chatId, params.chatId), + or( + params.branchId === null + ? isNull(knowledgeRecordLinks.branchId) + : eq(knowledgeRecordLinks.branchId, params.branchId), + isNull(knowledgeRecordLinks.branchId) + ) + ) + ); + return rows.map(rowToKnowledgeRecordLinkDto); +} diff --git a/server/src/services/chat-knowledge/knowledge-records-repository.ts b/server/src/services/chat-knowledge/knowledge-records-repository.ts new file mode 100644 index 00000000..cba5669f --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-records-repository.ts @@ -0,0 +1,229 @@ +import { randomUUID as uuidv4 } from "node:crypto"; + +import { and, eq, inArray, isNull, or } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { knowledgeRecords } from "../../db/schema"; + +import { + buildKnowledgeSearchText, + encodeJson, + rowToKnowledgeRecordDto, +} from "./knowledge-helpers"; + +import type { + KnowledgeAccessMode, + KnowledgeGatePolicy, + KnowledgeLayer, + KnowledgeOrigin, + KnowledgeRecordDto, + KnowledgeRecordStatus, +} from "@shared/types/chat-knowledge"; + +function buildOverlayBranchScope(branchId: string | null) { + return branchId === null + ? isNull(knowledgeRecords.branchId) + : or(isNull(knowledgeRecords.branchId), eq(knowledgeRecords.branchId, branchId)); +} + +function buildExactBranchScope(branchId: string | null) { + return branchId === null + ? isNull(knowledgeRecords.branchId) + : eq(knowledgeRecords.branchId, branchId); +} + +export async function getKnowledgeRecordById( + id: string +): Promise { + const db = await initDb(); + const rows = await db.select().from(knowledgeRecords).where(eq(knowledgeRecords.id, id)).limit(1); + return rows[0] ? rowToKnowledgeRecordDto(rows[0]) : null; +} + +export async function getKnowledgeRecordsByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + const db = await initDb(); + const rows = await db.select().from(knowledgeRecords).where(inArray(knowledgeRecords.id, ids)); + return rows.map(rowToKnowledgeRecordDto); +} + +export async function getScopedKnowledgeRecordsByIds(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + ids: string[]; +}): Promise { + if (params.ids.length === 0) return []; + const db = await initDb(); + const rows = await db + .select() + .from(knowledgeRecords) + .where( + and( + eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.chatId, params.chatId), + buildOverlayBranchScope(params.branchId), + inArray(knowledgeRecords.id, params.ids) + ) + ); + return rows.map(rowToKnowledgeRecordDto); +} + +export async function findKnowledgeRecordsByKeys(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + keys: string[]; +}): Promise { + if (params.keys.length === 0) return []; + const db = await initDb(); + const rows = await db + .select() + .from(knowledgeRecords) + .where( + and( + eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.chatId, params.chatId), + buildOverlayBranchScope(params.branchId), + inArray(knowledgeRecords.key, params.keys) + ) + ); + return rows.map(rowToKnowledgeRecordDto); +} + +export async function listKnowledgeRecords(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + collectionId?: string; + includeArchived?: boolean; +}): Promise { + const db = await initDb(); + const where = [ + eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.chatId, params.chatId), + buildOverlayBranchScope(params.branchId), + ]; + if (params.collectionId) where.push(eq(knowledgeRecords.collectionId, params.collectionId)); + if (!params.includeArchived) where.push(eq(knowledgeRecords.status, "active")); + const rows = await db.select().from(knowledgeRecords).where(and(...where)); + return rows.map(rowToKnowledgeRecordDto); +} + +export async function upsertKnowledgeRecord(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + collectionId: string; + recordType: string; + key: string; + title: string; + aliases: string[]; + tags: string[]; + summary?: string | null; + content: unknown; + accessMode: KnowledgeAccessMode; + origin: KnowledgeOrigin; + layer: KnowledgeLayer; + derivedFromRecordId?: string | null; + sourceMessageId?: string | null; + sourceOperationId?: string | null; + status?: KnowledgeRecordStatus; + gatePolicy?: KnowledgeGatePolicy | null; + meta?: unknown; +}): Promise { + const db = await initDb(); + const now = new Date(); + const searchText = buildKnowledgeSearchText({ + title: params.title, + aliases: params.aliases, + tags: params.tags, + summary: params.summary ?? null, + content: params.content, + accessMode: params.accessMode, + }); + const rows = await db + .select() + .from(knowledgeRecords) + .where( + and( + eq(knowledgeRecords.chatId, params.chatId), + buildExactBranchScope(params.branchId), + eq(knowledgeRecords.collectionId, params.collectionId), + eq(knowledgeRecords.key, params.key) + ) + ) + .limit(1); + const current = rows[0]; + + if (current) { + await db + .update(knowledgeRecords) + .set({ + recordType: params.recordType, + title: params.title, + aliasesJson: encodeJson(params.aliases, "[]"), + tagsJson: encodeJson(params.tags, "[]"), + summary: params.summary ?? null, + contentJson: encodeJson(params.content, "null"), + searchText, + accessMode: params.accessMode, + origin: params.origin, + layer: params.layer, + derivedFromRecordId: params.derivedFromRecordId ?? null, + sourceMessageId: params.sourceMessageId ?? null, + sourceOperationId: params.sourceOperationId ?? null, + status: params.status ?? "active", + gatePolicyJson: + typeof params.gatePolicy === "undefined" ? null : encodeJson(params.gatePolicy, "null"), + metaJson: typeof params.meta === "undefined" ? null : encodeJson(params.meta, "null"), + updatedAt: now, + }) + .where(eq(knowledgeRecords.id, current.id)); + } else { + await db.insert(knowledgeRecords).values({ + id: uuidv4(), + ownerId: params.ownerId ?? "global", + chatId: params.chatId, + branchId: params.branchId, + collectionId: params.collectionId, + recordType: params.recordType, + key: params.key, + title: params.title, + aliasesJson: encodeJson(params.aliases, "[]"), + tagsJson: encodeJson(params.tags, "[]"), + summary: params.summary ?? null, + contentJson: encodeJson(params.content, "null"), + searchText, + accessMode: params.accessMode, + origin: params.origin, + layer: params.layer, + derivedFromRecordId: params.derivedFromRecordId ?? null, + sourceMessageId: params.sourceMessageId ?? null, + sourceOperationId: params.sourceOperationId ?? null, + status: params.status ?? "active", + gatePolicyJson: + typeof params.gatePolicy === "undefined" ? null : encodeJson(params.gatePolicy, "null"), + metaJson: typeof params.meta === "undefined" ? null : encodeJson(params.meta, "null"), + createdAt: now, + updatedAt: now, + }); + } + + const reloadedRows = await db + .select() + .from(knowledgeRecords) + .where( + and( + eq(knowledgeRecords.chatId, params.chatId), + buildExactBranchScope(params.branchId), + eq(knowledgeRecords.collectionId, params.collectionId), + eq(knowledgeRecords.key, params.key) + ) + ) + .limit(1); + if (!reloadedRows[0]) { + throw new Error("Failed to upsert knowledge record"); + } + return rowToKnowledgeRecordDto(reloadedRows[0]); +} diff --git a/server/src/services/chat-knowledge/knowledge-reveal-service.ts b/server/src/services/chat-knowledge/knowledge-reveal-service.ts new file mode 100644 index 00000000..26ad5126 --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-reveal-service.ts @@ -0,0 +1,123 @@ +import { getKnowledgeRecordAccessState, upsertKnowledgeAccessState } from "./knowledge-access-repository"; +import { evaluateKnowledgeGate } from "./knowledge-gate-policy"; +import { + findKnowledgeRecordsByKeys, + getScopedKnowledgeRecordsByIds, +} from "./knowledge-records-repository"; + +import type { + KnowledgeRecordDto, + KnowledgeRevealRequest, + KnowledgeRevealResult, +} from "@shared/types/chat-knowledge"; + +async function resolveTargetRecords(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + request: KnowledgeRevealRequest; +}): Promise { + const byId = await getScopedKnowledgeRecordsByIds({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + ids: params.request.recordIds ?? [], + }); + const byKey = await findKnowledgeRecordsByKeys({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + keys: params.request.recordKeys ?? [], + }); + const seen = new Set(); + return [...byId, ...byKey].filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); +} + +async function canRevealRecord(params: { + ownerId: string; + chatId: string; + branchId: string | null; + record: KnowledgeRecordDto; + request: KnowledgeRevealRequest; +}): Promise { + const gate = params.record.gatePolicy?.read ?? params.record.gatePolicy?.prompt; + if (!gate) return true; + return evaluateKnowledgeGate({ + record: params.record, + gate, + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + context: params.request.context, + }); +} + +export async function revealKnowledgeRecords(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + request: KnowledgeRevealRequest; +}): Promise { + const ownerId = params.ownerId ?? "global"; + const targetRecords = await resolveTargetRecords(params); + const targetIds = new Set(targetRecords.map((item) => item.id)); + const missingIds = (params.request.recordIds ?? []).filter((item) => !targetIds.has(item)); + + const results: KnowledgeRevealResult["results"] = []; + for (const missing of missingIds) { + results.push({ + recordId: missing, + status: "not_found", + reason: "record_not_found", + }); + } + + for (const record of targetRecords) { + const allowed = await canRevealRecord({ + ownerId, + chatId: params.chatId, + branchId: params.branchId, + record, + request: params.request, + }); + if (!allowed) { + results.push({ + recordId: record.id, + status: "blocked", + reason: "gate_policy_blocked", + }); + continue; + } + const current = await getKnowledgeRecordAccessState({ + ownerId, + chatId: params.chatId, + branchId: params.branchId, + recordId: record.id, + }); + await upsertKnowledgeAccessState({ + ownerId, + chatId: params.chatId, + branchId: params.branchId, + recordId: record.id, + discoverState: "visible", + readState: "full", + promptState: "allowed", + revealState: "revealed", + revealedAt: new Date(), + revealedBy: params.request.revealedBy ?? current?.revealedBy ?? "system", + revealReason: params.request.reason ?? current?.revealReason ?? null, + flags: current?.flags ?? {}, + }); + results.push({ + recordId: record.id, + status: "revealed", + reason: current?.revealReason ?? null, + }); + } + + return { results }; +} diff --git a/server/src/services/chat-knowledge/knowledge-search-service.ts b/server/src/services/chat-knowledge/knowledge-search-service.ts new file mode 100644 index 00000000..9ea133c4 --- /dev/null +++ b/server/src/services/chat-knowledge/knowledge-search-service.ts @@ -0,0 +1,202 @@ +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { knowledgeRecords } from "../../db/schema"; + +import { getKnowledgeRecordAccessState } from "./knowledge-access-repository"; +import { getDefaultAccessSnapshot } from "./knowledge-gate-policy"; +import { rowToKnowledgeRecordDto } from "./knowledge-helpers"; + +import type { + KnowledgeRecordDto, + KnowledgeSearchHit, + KnowledgeSearchRequest, + KnowledgeSearchResult, +} from "@shared/types/chat-knowledge"; + +function buildBranchScope(branchId: string | null) { + return branchId === null + ? isNull(knowledgeRecords.branchId) + : or(isNull(knowledgeRecords.branchId), eq(knowledgeRecords.branchId, branchId)); +} + +function normalizeTokens(input: string | undefined): string[] { + if (!input) return []; + return Array.from(new Set((input.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).filter(Boolean))); +} + +function normalizeFtsQuery(tokens: string[]): string { + return tokens.map((token) => `${token.replace(/"/g, "")}*`).join(" OR "); +} + +function countTokenMatches(record: KnowledgeRecordDto, tokens: string[]): number { + if (tokens.length === 0) return 0; + const haystack = `${record.title} ${record.aliases.join(" ")} ${record.tags.join(" ")} ${ + record.summary ?? "" + } ${record.searchText}`.toLowerCase(); + return tokens.filter((token) => haystack.includes(token)).length; +} + +function buildMatchReasons(params: { + record: KnowledgeRecordDto; + request: KnowledgeSearchRequest; + textScore: number; +}): string[] { + const reasons: string[] = []; + if (params.request.keys?.includes(params.record.key)) reasons.push("key_exact"); + if ( + params.request.titles?.some((item) => item.toLowerCase() === params.record.title.toLowerCase()) + ) { + reasons.push("title_exact"); + } + if ( + params.request.aliases?.some((item) => + params.record.aliases.some((alias) => alias.toLowerCase() === item.toLowerCase()) + ) + ) { + reasons.push("alias_exact"); + } + if ( + params.request.tags?.some((item) => + params.record.tags.some((tag) => tag.toLowerCase() === item.toLowerCase()) + ) + ) { + reasons.push("tag_match"); + } + if (params.textScore > 0) reasons.push("fts"); + return reasons; +} + +function buildPreview(record: KnowledgeRecordDto) { + return { + title: record.title, + summary: record.summary, + aliases: record.aliases, + tags: record.tags, + recordType: record.recordType, + }; +} + +async function getFtsScores(params: { + ids: string[]; + textQuery?: string; +}): Promise> { + if (params.ids.length === 0 || !params.textQuery?.trim()) return new Map(); + const db = await initDb(); + const tokens = normalizeTokens(params.textQuery); + if (tokens.length === 0) return new Map(); + const idsSql = params.ids.map((id) => `'${id.replace(/'/g, "''")}'`).join(","); + const raw = await db.all<{ record_id: string; rank: number }>(sql.raw(` + SELECT record_id, bm25(knowledge_records_fts) AS rank + FROM knowledge_records_fts + WHERE knowledge_records_fts MATCH '${normalizeFtsQuery(tokens).replace(/'/g, "''")}' + AND record_id IN (${idsSql}) + `)); + const out = new Map(); + for (const row of raw) { + out.set(row.record_id, row.rank < 0 ? Math.abs(row.rank) : row.rank); + } + return out; +} + +function computeExactScore(params: { + record: KnowledgeRecordDto; + request: KnowledgeSearchRequest; +}): number { + let score = 0; + if (params.request.keys?.includes(params.record.key)) score += 120; + if ( + params.request.titles?.some((item) => item.toLowerCase() === params.record.title.toLowerCase()) + ) { + score += 100; + } + if ( + params.request.aliases?.some((item) => + params.record.aliases.some((alias) => alias.toLowerCase() === item.toLowerCase()) + ) + ) { + score += 80; + } + const tagMatches = + params.request.tags?.filter((item) => + params.record.tags.some((tag) => tag.toLowerCase() === item.toLowerCase()) + ).length ?? 0; + score += tagMatches * 20; + return score; +} + +export async function searchKnowledgeRecords(params: { + ownerId?: string; + chatId: string; + branchId: string | null; + request: KnowledgeSearchRequest; +}): Promise { + const db = await initDb(); + const where = [ + eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.chatId, params.chatId), + buildBranchScope(params.branchId), + eq(knowledgeRecords.status, "active"), + ]; + if (!params.request.includeHiddenCandidates) { + where.push(inArray(knowledgeRecords.accessMode, ["public", "discoverable"])); + } + if (params.request.collectionIds?.length) { + where.push(inArray(knowledgeRecords.collectionId, params.request.collectionIds)); + } + if (params.request.recordTypes?.length) { + where.push(inArray(knowledgeRecords.recordType, params.request.recordTypes)); + } + const rows = await db.select().from(knowledgeRecords).where(and(...where)); + const records = rows.map(rowToKnowledgeRecordDto); + const ftsScores = await getFtsScores({ + ids: records.map((item) => item.id), + textQuery: params.request.textQuery, + }); + const tokens = normalizeTokens(params.request.textQuery); + const hits: KnowledgeSearchHit[] = []; + + for (const record of records) { + if (record.accessMode === "internal") continue; + const defaultAccess = getDefaultAccessSnapshot(record.accessMode); + const accessState = + (await getKnowledgeRecordAccessState({ + ownerId: params.ownerId, + chatId: params.chatId, + branchId: params.branchId, + recordId: record.id, + })) ?? null; + const matchedTokens = countTokenMatches(record, tokens); + const minimumShouldMatch = Math.max(0, params.request.minimumShouldMatch ?? 0); + if (minimumShouldMatch > 0 && matchedTokens < minimumShouldMatch) continue; + + const exactScore = computeExactScore({ record, request: params.request }); + const textScore = ftsScores.has(record.id) + ? Math.max(0, 30 - Math.min(30, ftsScores.get(record.id)!)) + : 0; + const score = exactScore + textScore + matchedTokens * 2; + if (score <= 0 && tokens.length > 0) continue; + if (typeof params.request.minScore === "number" && score < params.request.minScore) continue; + + const visibility = + accessState?.readState === "full" || defaultAccess.readState === "full" ? "full" : "preview"; + + hits.push({ + recordId: record.id, + score, + matchReasons: buildMatchReasons({ record, request: params.request, textScore }), + visibility, + preview: buildPreview(record), + record: visibility === "full" ? record : null, + }); + } + + hits.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.recordId.localeCompare(b.recordId); + }); + + return { + hits: hits.slice(0, Math.max(1, Math.min(200, params.request.limit ?? 10))), + }; +} diff --git a/server/src/services/llm/llm-presets-repository.ts b/server/src/services/llm/llm-presets-repository.ts index cb5ecf8e..7d90bc45 100644 --- a/server/src/services/llm/llm-presets-repository.ts +++ b/server/src/services/llm/llm-presets-repository.ts @@ -1,5 +1,6 @@ -import { and, desc, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; + +import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; import { HttpError } from "@core/middleware/error-handler"; diff --git a/server/src/services/llm/llm-repository.ts b/server/src/services/llm/llm-repository.ts index 25a175b3..3e3f1ca1 100644 --- a/server/src/services/llm/llm-repository.ts +++ b/server/src/services/llm/llm-repository.ts @@ -1,6 +1,7 @@ -import { and, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, eq } from "drizzle-orm"; + import { decryptSecret, encryptSecret, diff --git a/server/src/services/operations/guard-operation-params.ts b/server/src/services/operations/guard-operation-params.ts new file mode 100644 index 00000000..059ba983 --- /dev/null +++ b/server/src/services/operations/guard-operation-params.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; + +import type { + GuardAuxLlmParams, + GuardLiquidParams, + GuardOperationParams, + GuardOutputContract, + GuardOutputDefinition, + LlmOperationRetry, + LlmOperationRetryOn, + LlmOperationSamplers, +} from "@shared/types/operation-profiles"; + +const guardOutputKeySchema = z + .string() + .trim() + .min(1) + .regex(/^[a-z][a-zA-Z0-9_]*$/, "guard output key must match ^[a-z][a-zA-Z0-9_]*$"); + +const guardOutputDefinitionSchema: z.ZodType = z.object({ + key: guardOutputKeySchema, + title: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), +}); + +export const guardOutputContractSchema: z.ZodType = z + .array(guardOutputDefinitionSchema) + .min(1) + .superRefine((items, ctx) => { + const seen = new Set(); + for (const item of items) { + if (seen.has(item.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `duplicate guard output key: ${item.key}`, + path: ["key"], + }); + } + seen.add(item.key); + } + }); + +const retryOnSchema = z.enum(["timeout", "provider_error", "rate_limit"] satisfies LlmOperationRetryOn[]); +const reasoningEffortSchema = z.enum(["low", "medium", "high"]); + +const samplersSchema: z.ZodType = z + .object({ + temperature: z.number().finite().optional(), + topP: z.number().finite().optional(), + topK: z.number().finite().optional(), + minP: z.number().finite().optional(), + topA: z.number().finite().optional(), + frequencyPenalty: z.number().finite().optional(), + presencePenalty: z.number().finite().optional(), + repetitionPenalty: z.number().finite().optional(), + seed: z.number().finite().optional(), + maxTokens: z.number().finite().optional(), + reasoning: z + .object({ + enabled: z.boolean().optional(), + effort: reasoningEffortSchema.optional(), + maxTokens: z.number().finite().optional(), + exclude: z.boolean().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +const retrySchema: z.ZodType = z + .object({ + maxAttempts: z.number().int().min(1).max(10), + backoffMs: z.number().int().min(0).max(120_000).optional(), + retryOn: z.array(retryOnSchema).min(1).optional(), + }) + .strict(); + +export const liquidGuardParamsSchema = z + .object({ + engine: z.literal("liquid"), + outputContract: guardOutputContractSchema, + template: z.string(), + strictVariables: z.boolean().optional(), + }) + .strict(); + +export const auxLlmGuardParamsSchema = z + .object({ + engine: z.literal("aux_llm"), + outputContract: guardOutputContractSchema, + providerId: z.enum(["openrouter", "openai_compatible"]), + credentialRef: z.string().trim().min(1), + model: z.string().trim().min(1).optional(), + system: z.string().optional(), + prompt: z.string().min(1), + strictVariables: z.boolean().optional(), + samplers: samplersSchema.optional(), + timeoutMs: z.number().int().min(1).max(300_000).optional(), + retry: retrySchema.optional(), + }) + .strict(); + +export const guardOperationParamsSchema = z.discriminatedUnion("engine", [ + liquidGuardParamsSchema, + auxLlmGuardParamsSchema, +]); + +export type NormalizedGuardOperationParams = + | (Omit & { + strictVariables: boolean; + }) + | (Omit & { + strictVariables: boolean; + }); + +export function parseGuardOperationParams( + raw: unknown +): NormalizedGuardOperationParams { + const parsed = guardOperationParamsSchema.parse(raw) as Omit; + if (parsed.engine === "liquid") { + const liquid = parsed as Omit; + return { + ...liquid, + strictVariables: liquid.strictVariables === true, + }; + } + + const aux = parsed as Omit; + return { + ...aux, + strictVariables: aux.strictVariables === true, + }; +} diff --git a/server/src/services/operations/guard-output-contract.ts b/server/src/services/operations/guard-output-contract.ts new file mode 100644 index 00000000..91a15ab2 --- /dev/null +++ b/server/src/services/operations/guard-output-contract.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import type { + GuardOutputContract, + GuardOutputDefinition, +} from "@shared/types/operation-profiles"; + +function toShape(contract: GuardOutputContract): Record { + return Object.fromEntries( + contract.map((item) => [item.key, z.boolean()]) + ); +} + +export function compileGuardOutputSchema(contract: GuardOutputContract): z.ZodType> { + return z.object(toShape(contract)).strict(); +} + +export function buildGuardOutputJsonSchemaSpec(contract: GuardOutputContract): Record { + return Object.fromEntries(contract.map((item) => [item.key, "boolean"])); +} + +export function normalizeGuardOutputContract(contract: GuardOutputDefinition[]): GuardOutputContract { + return contract.map((item) => ({ + key: item.key, + title: item.title, + description: item.description, + })); +} diff --git a/server/src/services/operations/knowledge-operation-params.test.ts b/server/src/services/operations/knowledge-operation-params.test.ts new file mode 100644 index 00000000..077a233f --- /dev/null +++ b/server/src/services/operations/knowledge-operation-params.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; + +import { parseKnowledgeRevealOperationParams, parseKnowledgeSearchOperationParams } from "./knowledge-operation-params"; + +describe("knowledge operation params", () => { + test("parses inline knowledge search source", () => { + const parsed = parseKnowledgeSearchOperationParams({ + source: { + mode: "inline", + requestTemplate: "{\"textQuery\":\"forest\",\"limit\":5}", + }, + }); + + expect(parsed).toEqual({ + source: { + mode: "inline", + requestTemplate: "{\"textQuery\":\"forest\",\"limit\":5}", + strictVariables: false, + }, + }); + }); + + test("parses artifact knowledge reveal source", () => { + const parsed = parseKnowledgeRevealOperationParams({ + source: { + mode: "artifact", + artifactTag: "planner_result", + }, + }); + + expect(parsed).toEqual({ + source: { + mode: "artifact", + artifactTag: "planner_result", + }, + }); + }); + + test("rejects invalid artifact tag", () => { + expect(() => + parseKnowledgeSearchOperationParams({ + source: { + mode: "artifact", + artifactTag: "bad tag", + }, + }) + ).toThrow(/artifactTag/i); + }); +}); diff --git a/server/src/services/operations/knowledge-operation-params.ts b/server/src/services/operations/knowledge-operation-params.ts new file mode 100644 index 00000000..b90a1b00 --- /dev/null +++ b/server/src/services/operations/knowledge-operation-params.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +import type { + KnowledgeRevealOperationParams, + KnowledgeSearchOperationParams, + KnowledgeRequestSource, +} from "@shared/types/chat-knowledge"; + +const artifactTagSchema = z + .string() + .trim() + .min(1) + .regex(/^[a-z][a-z0-9_]*$/, "artifactTag must match ^[a-z][a-z0-9_]*$"); + +const requestSourceSchema: z.ZodType = z.discriminatedUnion("mode", [ + z + .object({ + mode: z.literal("inline"), + requestTemplate: z.string().min(1), + strictVariables: z.boolean().optional(), + }) + .transform((value) => ({ + ...value, + strictVariables: value.strictVariables === true, + })), + z.object({ + mode: z.literal("artifact"), + artifactTag: artifactTagSchema, + }), +]); + +const searchParamsSchema: z.ZodType = z.object({ + source: requestSourceSchema, +}); + +const revealParamsSchema: z.ZodType = z.object({ + source: requestSourceSchema, +}); + +export function parseKnowledgeSearchOperationParams( + raw: unknown +): KnowledgeSearchOperationParams { + return searchParamsSchema.parse(raw); +} + +export function parseKnowledgeRevealOperationParams( + raw: unknown +): KnowledgeRevealOperationParams { + return revealParamsSchema.parse(raw); +} diff --git a/server/src/services/operations/operation-block-validator.test.ts b/server/src/services/operations/operation-block-validator.test.ts index add2bd01..3582a909 100644 --- a/server/src/services/operations/operation-block-validator.test.ts +++ b/server/src/services/operations/operation-block-validator.test.ts @@ -1,8 +1,35 @@ +import { buildOperationArtifactId } from "@shared/types/operation-profiles"; import { describe, expect, test } from "vitest"; + import { validateOperationBlockUpsertInput } from "./operation-block-validator"; describe("operation block validator", () => { + test("rejects legacy operation kind", () => { + expect(() => + validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "6ff77029-5037-4d21-8ace-c9836f58a14b", + name: "legacy-op", + kind: "legacy", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + params: {}, + }, + }, + }, + ], + }) + ).toThrow(/Validation error/); + }); + test("accepts valid block", () => { const out = validateOperationBlockUpsertInput({ name: "block", @@ -37,6 +64,8 @@ describe("operation block validator", () => { ], }); expect(out.operations).toHaveLength(1); + expect(out.operations[0]?.config.params.artifact.artifactId).toBe(buildOperationArtifactId("6ff77029-5037-4d21-8ace-c9836f58a14b")); + expect(out.operations[0]?.config.params.artifact.tag).toBe("world_state"); }); test("rejects dependency to unknown opId", () => { @@ -116,4 +145,404 @@ describe("operation block validator", () => { expect(issues.some((issue) => issue.message === "activation must include at least one interval")).toBe(true); } }); + + test("fills missing tag for artifact configs loaded from older saved blocks", () => { + const out = validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "9ff77029-5037-4d21-8ace-c9836f58a14b", + name: "saved-op", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + template: "Hello", + artifact: { + artifactId: "artifact:9ff77029-5037-4d21-8ace-c9836f58a14b", + title: "Saved artifact", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + ], + }); + + expect(out.operations[0]?.config.params.artifact.tag).toBe("saved_op"); + }); + + test("migrates legacy prompt_time output into artifact exposures", () => { + const out = validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "7ff77029-5037-4d21-8ace-c9836f58a14b", + name: "prompt-op", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + template: "Hello", + output: { + type: "prompt_time", + promptTime: { + kind: "append_after_last_user", + role: "system", + source: "legacy", + }, + }, + }, + }, + }, + ], + }); + + expect(out.operations[0]?.config.params.artifact.exposures).toEqual([ + { + type: "prompt_message", + role: "system", + anchor: "after_last_user", + source: "legacy", + }, + ]); + }); + + test("rejects prompt_message exposure in after_main_llm hook", () => { + expect(() => + validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "8ff77029-5037-4d21-8ace-c9836f58a14b", + name: "bad-op", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["after_main_llm"], + order: 10, + params: { + template: "Hello", + artifact: { + artifactId: "artifact:bad-op", + tag: "bad_artifact", + title: "Bad artifact", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [ + { + type: "prompt_message", + role: "system", + anchor: "after_last_user", + }, + ], + }, + }, + }, + }, + ], + }) + ).toThrow(/before_main_llm/i); + }); + + test("accepts valid guard with run condition consumer", () => { + const out = validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "11111111-1111-4111-8111-111111111111", + name: "combat-guard", + kind: "guard", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + engine: "liquid", + outputContract: [ + { key: "isBattle", title: "Battle" }, + { key: "isNight", title: "Night" }, + ], + template: "{\"isBattle\": true, \"isNight\": false}", + artifact: { + artifactId: "artifact:11111111-1111-4111-8111-111111111111", + tag: "combat_guard_state", + title: "Combat guard state", + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + { + opId: "22222222-2222-4222-8222-222222222222", + name: "combat-consumer", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 20, + dependsOn: ["11111111-1111-4111-8111-111111111111"], + runConditions: [ + { + type: "guard_output", + sourceOpId: "11111111-1111-4111-8111-111111111111", + outputKey: "isBattle", + operator: "is_true", + }, + ], + params: { + template: "combat", + artifact: { + artifactId: "artifact:22222222-2222-4222-8222-222222222222", + tag: "combat_text", + title: "Combat text", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + ], + }); + + expect(out.operations[0]?.kind).toBe("guard"); + expect(out.operations[1]?.config.runConditions).toEqual([ + { + type: "guard_output", + sourceOpId: "11111111-1111-4111-8111-111111111111", + outputKey: "isBattle", + operator: "is_true", + }, + ]); + }); + + test("rejects guard with non-json artifact format", () => { + expect(() => + validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "11111111-1111-4111-8111-111111111111", + name: "combat-guard", + kind: "guard", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "{\"isBattle\": true}", + artifact: { + artifactId: "artifact:11111111-1111-4111-8111-111111111111", + tag: "combat_guard_state", + title: "Combat guard state", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + ], + }) + ).toThrow(/guard artifact format/i); + }); + + test("rejects run condition with unknown output key", () => { + expect(() => + validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "11111111-1111-4111-8111-111111111111", + name: "combat-guard", + kind: "guard", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "{\"isBattle\": true}", + artifact: { + artifactId: "artifact:11111111-1111-4111-8111-111111111111", + tag: "combat_guard_state", + title: "Combat guard state", + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + { + opId: "22222222-2222-4222-8222-222222222222", + name: "consumer", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 20, + dependsOn: ["11111111-1111-4111-8111-111111111111"], + runConditions: [ + { + type: "guard_output", + sourceOpId: "11111111-1111-4111-8111-111111111111", + outputKey: "missingFlag", + operator: "is_true", + }, + ], + params: { + template: "Hello", + artifact: { + artifactId: "artifact:22222222-2222-4222-8222-222222222222", + tag: "consumer_state", + title: "Consumer state", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + ], + }) + ).toThrow(/runCondition references unknown guard output/i); + }); + + test("rejects run condition without matching dependsOn", () => { + expect(() => + validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "11111111-1111-4111-8111-111111111111", + name: "combat-guard", + kind: "guard", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "{\"isBattle\": true}", + artifact: { + artifactId: "artifact:11111111-1111-4111-8111-111111111111", + tag: "combat_guard_state", + title: "Combat guard state", + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + { + opId: "22222222-2222-4222-8222-222222222222", + name: "consumer", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 20, + runConditions: [ + { + type: "guard_output", + sourceOpId: "11111111-1111-4111-8111-111111111111", + outputKey: "isBattle", + operator: "is_true", + }, + ], + params: { + template: "Hello", + artifact: { + artifactId: "artifact:22222222-2222-4222-8222-222222222222", + tag: "consumer_state", + title: "Consumer state", + format: "markdown", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }, + }, + ], + }) + ).toThrow(/runCondition sourceOpId must also appear in dependsOn/i); + }); }); + diff --git a/server/src/services/operations/operation-block-validator.ts b/server/src/services/operations/operation-block-validator.ts index dcf93dd5..5bf4425f 100644 --- a/server/src/services/operations/operation-block-validator.ts +++ b/server/src/services/operations/operation-block-validator.ts @@ -1,24 +1,45 @@ +import { + normalizeOperationArtifactConfig, + type ArtifactExposure, + type ArtifactFormat, + type ArtifactPersistence, + type ArtifactUsage, + type ArtifactWriteMode, + type OperationActivationConfig, + type OperationBlockExport, + type OperationBlockUpsertInput, + type OperationHook, + type OperationInProfile, + type OperationKind, + type OperationProfile, + type OperationRunCondition, + type OperationTemplateParams, + type OperationTrigger, + type PromptTimeMessageRole, +} from "@shared/types/operation-profiles"; import { z } from "zod"; import { HttpError } from "@core/middleware/error-handler"; import { validateLiquidTemplate } from "../chat-core/prompt-template-renderer"; +import { + auxLlmGuardParamsSchema, + guardOperationParamsSchema, + liquidGuardParamsSchema, +} from "./guard-operation-params"; +import { compileGuardOutputSchema } from "./guard-output-contract"; +import { + parseKnowledgeRevealOperationParams, + parseKnowledgeSearchOperationParams, +} from "./knowledge-operation-params"; import { compileLlmJsonSchemaSpec } from "./llm-json-schema-spec"; import { llmOperationParamsSchema } from "./llm-operation-params"; import type { - OperationActivationConfig, - ArtifactPersistence, - ArtifactUsage, - OperationBlockExport, - OperationBlockUpsertInput, - OperationHook, - OperationInProfile, - OperationKind, - OperationTrigger, - PromptTimeMessageRole, -} from "@shared/types/operation-profiles"; + KnowledgeRevealOperationParams, + KnowledgeSearchOperationParams, +} from "@shared/types/chat-knowledge"; const uuidSchema = z.string().uuid(); @@ -40,6 +61,63 @@ const operationActivationSchema = z const artifactPersistenceSchema = z.enum(["persisted", "run_only"] satisfies ArtifactPersistence[]); const artifactUsageSchema = z.enum(["prompt_only", "ui_only", "prompt+ui", "internal"] satisfies ArtifactUsage[]); +const artifactWriteModeSchema = z.enum(["replace", "append"] satisfies ArtifactWriteMode[]); +const artifactFormatSchema = z.enum(["text", "markdown", "json"] satisfies ArtifactFormat[]); +const promptTimeRoleSchema = z + .enum(["system", "developer", "user", "assistant"]) + .transform((value): PromptTimeMessageRole => (value === "developer" ? "system" : value)); + +const promptPartExposureSchema = z.object({ + type: z.literal("prompt_part"), + target: z.literal("system"), + mode: z.enum(["prepend", "append", "replace"]), + source: z.string().trim().min(1).optional(), +}); + +const promptMessageExposureSchema = z.union([ + z.object({ + type: z.literal("prompt_message"), + role: promptTimeRoleSchema, + anchor: z.literal("after_last_user"), + source: z.string().trim().min(1).optional(), + }), + z.object({ + type: z.literal("prompt_message"), + role: promptTimeRoleSchema, + anchor: z.literal("depth_from_end"), + depthFromEnd: z.number().finite().transform((value) => Math.max(0, Math.floor(Math.abs(value)))), + source: z.string().trim().min(1).optional(), + }), +]); + +const turnRewriteExposureSchema = z.object({ + type: z.literal("turn_rewrite"), + target: z.enum(["current_user_main", "assistant_output_main"]), + mode: z.literal("replace"), +}); + +const uiInlineExposureSchema = z.union([ + z.object({ + type: z.literal("ui_inline"), + role: promptTimeRoleSchema, + anchor: z.literal("after_last_user"), + source: z.string().trim().min(1).optional(), + }), + z.object({ + type: z.literal("ui_inline"), + role: promptTimeRoleSchema, + anchor: z.literal("depth_from_end"), + depthFromEnd: z.number().finite().transform((value) => Math.max(0, Math.floor(Math.abs(value)))), + source: z.string().trim().min(1).optional(), + }), +]); + +const artifactExposureSchema: z.ZodType = z.union([ + promptPartExposureSchema, + promptMessageExposureSchema, + turnRewriteExposureSchema, + uiInlineExposureSchema, +]); const artifactTagSchema = z .string() @@ -47,27 +125,30 @@ const artifactTagSchema = z .min(1) .regex(/^[a-z][a-z0-9_]*$/, "tag must match ^[a-z][a-z0-9_]*$"); -function normalizePromptTimeRole(value: "system" | "developer" | "user" | "assistant"): PromptTimeMessageRole { - return value === "developer" ? "system" : value; -} - -function normalizeDepthFromEnd(value: number): number { - if (!Number.isFinite(value)) return 0; - return Math.abs(Math.floor(value)); -} - -const promptTimeRoleSchema = z - .enum(["system", "developer", "user", "assistant"]) - .transform((value): PromptTimeMessageRole => normalizePromptTimeRole(value)); +const artifactConfigSchema = z.object({ + artifactId: z.string().trim().min(1), + tag: artifactTagSchema.optional(), + title: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + format: artifactFormatSchema, + persistence: artifactPersistenceSchema, + writeMode: artifactWriteModeSchema, + history: z.object({ + enabled: z.boolean(), + maxItems: z.number().int().min(1), + }), + semantics: z.string().trim().min(1).optional(), + exposures: z.array(artifactExposureSchema), +}); -const artifactWriteTargetSchema = z.object({ +const legacyArtifactWriteTargetSchema = z.object({ tag: artifactTagSchema, persistence: artifactPersistenceSchema, usage: artifactUsageSchema, semantics: z.string().min(1), }); -const promptTimeEffectSchema = z.discriminatedUnion("kind", [ +const legacyPromptTimeEffectSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("append_after_last_user"), role: promptTimeRoleSchema, @@ -80,62 +161,58 @@ const promptTimeEffectSchema = z.discriminatedUnion("kind", [ }), z.object({ kind: z.literal("insert_at_depth"), - depthFromEnd: z.number().finite().transform((value) => normalizeDepthFromEnd(value)), + depthFromEnd: z.number().finite().transform((value) => Math.max(0, Math.floor(Math.abs(value)))), role: promptTimeRoleSchema, source: z.string().trim().min(1).optional(), }), ]); -const turnCanonicalizationEffectSchema = z.object({ - kind: z.literal("replace_text"), - target: z.enum(["user", "assistant"]), -}); - -const operationOutputSchema = z.discriminatedUnion("type", [ +const legacyOperationOutputSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("artifacts"), - writeArtifact: artifactWriteTargetSchema, + writeArtifact: legacyArtifactWriteTargetSchema, }), z.object({ type: z.literal("prompt_time"), - promptTime: promptTimeEffectSchema, + promptTime: legacyPromptTimeEffectSchema, }), z.object({ type: z.literal("turn_canonicalization"), - canonicalization: turnCanonicalizationEffectSchema, + canonicalization: z.object({ + kind: z.literal("replace_text"), + target: z.enum(["user", "assistant"]), + }), }), ]); -const templateParamsNewSchema = z.object({ +const templateParamsSchema = z.object({ template: z.string(), strictVariables: z.boolean().optional(), - output: operationOutputSchema, -}); - -const templateParamsLegacySchema = z.object({ - template: z.string(), - strictVariables: z.boolean().optional(), - writeArtifact: artifactWriteTargetSchema, -}); - -const templateParamsSchema = z.union([templateParamsNewSchema, templateParamsLegacySchema]).transform((v) => { - if ("output" in v) return v; - return { - template: v.template, - strictVariables: v.strictVariables, - output: { - type: "artifacts" as const, - writeArtifact: v.writeArtifact, - }, - }; + artifact: artifactConfigSchema.optional(), + output: legacyOperationOutputSchema.optional(), + writeArtifact: legacyArtifactWriteTargetSchema.optional(), }); const otherKindParamsSchema = z.object({ params: z.record(z.string(), z.unknown()), - output: operationOutputSchema, + artifact: artifactConfigSchema.optional(), + output: legacyOperationOutputSchema.optional(), }); -const operationConfigTemplateSchema = z.object({ +const runConditionSchema: z.ZodType = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("guard_output"), + sourceOpId: uuidSchema, + outputKey: z + .string() + .trim() + .min(1) + .regex(/^[a-z][a-zA-Z0-9_]*$/, "guard output key must match ^[a-z][a-zA-Z0-9_]*$"), + operator: z.enum(["is_true", "is_false"]), + }), +]); + +const operationConfigBaseSchema = z.object({ enabled: z.boolean(), required: z.boolean(), hooks: z.array(operationHookSchema).min(1), @@ -143,63 +220,154 @@ const operationConfigTemplateSchema = z.object({ activation: operationActivationSchema.optional(), order: z.number().finite(), dependsOn: z.array(uuidSchema).optional(), + runConditions: z.array(runConditionSchema).optional(), +}); + +const operationConfigTemplateSchema = operationConfigBaseSchema.extend({ params: templateParamsSchema, }); -const operationConfigOtherSchema = z.object({ - enabled: z.boolean(), - required: z.boolean(), - hooks: z.array(operationHookSchema).min(1), - triggers: z.array(operationTriggerSchema).min(1).optional(), - activation: operationActivationSchema.optional(), - order: z.number().finite(), - dependsOn: z.array(uuidSchema).optional(), +const operationConfigOtherSchema = operationConfigBaseSchema.extend({ params: otherKindParamsSchema, }); -const operationConfigLlmSchema = z.object({ - enabled: z.boolean(), - required: z.boolean(), - hooks: z.array(operationHookSchema).min(1), - triggers: z.array(operationTriggerSchema).min(1).optional(), - activation: operationActivationSchema.optional(), - order: z.number().finite(), - dependsOn: z.array(uuidSchema).optional(), +const knowledgeRequestSourceSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("inline"), + requestTemplate: z.string().min(1), + strictVariables: z.boolean().optional(), + }), + z.object({ + mode: z.literal("artifact"), + artifactTag: artifactTagSchema, + }), +]); + +const operationConfigKnowledgeSearchSchema = operationConfigBaseSchema.extend({ params: z.object({ - params: llmOperationParamsSchema, - output: operationOutputSchema, + params: z.object({ + source: knowledgeRequestSourceSchema, + }), + artifact: artifactConfigSchema.optional(), + output: legacyOperationOutputSchema.optional(), }), }); -export const operationInProfileSchema: z.ZodType = z.discriminatedUnion("kind", [ - z.object({ - opId: uuidSchema, - name: z.string().trim().min(1), - description: z.string().trim().min(1).optional(), - kind: z.literal("template"), - config: operationConfigTemplateSchema, - }), - z.object({ - opId: uuidSchema, - name: z.string().trim().min(1), - description: z.string().trim().min(1).optional(), - kind: z.literal("llm"), - config: operationConfigLlmSchema, +const operationConfigKnowledgeRevealSchema = operationConfigBaseSchema.extend({ + params: z.object({ + params: z.object({ + source: knowledgeRequestSourceSchema, + }), + artifact: artifactConfigSchema.optional(), + output: legacyOperationOutputSchema.optional(), }), - z.object({ - opId: uuidSchema, - name: z.string().trim().min(1), - description: z.string().trim().min(1).optional(), - kind: z.enum([ - "rag", - "tool", - "compute", - "transform", - "legacy", - ] satisfies Exclude[]), - config: operationConfigOtherSchema, +}); + +const operationConfigLlmSchema = operationConfigBaseSchema.extend({ + params: z.object({ + params: llmOperationParamsSchema, + artifact: artifactConfigSchema.optional(), + output: legacyOperationOutputSchema.optional(), }), -]); +}); + +const operationConfigGuardSchema = operationConfigBaseSchema.extend({ + params: z.discriminatedUnion("engine", [ + liquidGuardParamsSchema.extend({ + artifact: artifactConfigSchema.optional(), + }), + auxLlmGuardParamsSchema.extend({ + artifact: artifactConfigSchema.optional(), + }), + ]), +}); + +export const operationInProfileSchema: z.ZodType = z + .discriminatedUnion("kind", [ + z.object({ + opId: uuidSchema, + name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + kind: z.literal("template"), + config: operationConfigTemplateSchema, + }), + z.object({ + opId: uuidSchema, + name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + kind: z.literal("llm"), + config: operationConfigLlmSchema, + }), + z.object({ + opId: uuidSchema, + name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + kind: z.literal("guard"), + config: operationConfigGuardSchema, + }), + z.object({ + opId: uuidSchema, + name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + kind: z.literal("knowledge_search"), + config: operationConfigKnowledgeSearchSchema, + }), + z.object({ + opId: uuidSchema, + name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + kind: z.literal("knowledge_reveal"), + config: operationConfigKnowledgeRevealSchema, + }), + z.object({ + opId: uuidSchema, + name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + kind: z.enum([ + "rag", + "tool", + "compute", + "transform", + ] satisfies Exclude< + OperationKind, + "template" | "llm" | "guard" | "knowledge_search" | "knowledge_reveal" + >[]), + config: operationConfigOtherSchema, + }), + ]) + .transform((op): OperationInProfile => { + const artifact = normalizeOperationArtifactConfig({ + opId: op.opId, + kind: op.kind, + title: op.name, + rawParams: op.config.params, + }); + + const normalizedConfig = { + ...op.config, + params: + op.kind === "template" + ? { + template: op.config.params.template, + strictVariables: op.config.params.strictVariables, + artifact, + } + : op.kind === "guard" + ? { + ...op.config.params, + artifact, + } + : { + params: op.config.params.params, + artifact, + }, + }; + + return { + ...op, + config: normalizedConfig, + } as OperationInProfile; + }); const upsertInputSchema: z.ZodType = z.object({ name: z.string().trim().min(1), @@ -230,6 +398,21 @@ function normalizeDependsOn(dependsOn: string[] | undefined): string[] | undefin return Array.from(new Set(dependsOn)); } +function normalizeRunConditions( + runConditions: OperationRunCondition[] | undefined +): OperationRunCondition[] | undefined { + if (!runConditions?.length) return undefined; + const seen = new Set(); + const normalized: OperationRunCondition[] = []; + for (const condition of runConditions) { + const key = `${condition.type}:${condition.sourceOpId}:${condition.outputKey}:${condition.operator}`; + if (seen.has(key)) continue; + seen.add(key); + normalized.push(condition); + } + return normalized.length > 0 ? normalized : undefined; +} + function normalizeActivation( activation: OperationActivationConfig | undefined ): OperationActivationConfig | undefined { @@ -280,6 +463,36 @@ function detectDependencyCycle(ops: OperationInProfile[]): boolean { return false; } +function validateExposurePolicy(op: OperationInProfile, exposure: ArtifactExposure): void { + if (exposure.type === "prompt_part" || exposure.type === "prompt_message") { + if (!op.config.hooks.includes("before_main_llm")) { + throw new HttpError( + 400, + `${exposure.type} requires before_main_llm hook`, + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + return; + } + + if (exposure.type === "turn_rewrite" && exposure.target === "assistant_output_main") { + if (!op.config.hooks.includes("after_main_llm")) { + throw new HttpError( + 400, + "turn_rewrite target=assistant_output_main requires after_main_llm hook", + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + return; + } + + if (exposure.type === "ui_inline") { + return; + } +} + function validateCrossRules(input: ValidatedOperationBlockInput): void { const opsById = new Map(); const opIds = new Set(); @@ -293,7 +506,13 @@ function validateCrossRules(input: ValidatedOperationBlockInput): void { opsById.set(op.opId, op); } + const artifactIds = new Map(); + const artifactTags = new Map(); for (const op of input.operations) { + for (const exposure of op.config.params.artifact.exposures) { + validateExposurePolicy(op, exposure); + } + const deps = op.config.dependsOn ?? []; for (const dep of deps) { if (dep === op.opId) { @@ -326,36 +545,57 @@ function validateCrossRules(input: ValidatedOperationBlockInput): void { } } - const params: Record = op.config.params as unknown as Record; - const output = params.output as - | { type: "prompt_time"; promptTime?: { target?: unknown } } - | { type: "turn_canonicalization"; canonicalization?: { target?: unknown } } - | undefined; - if (output?.type === "prompt_time" && !op.config.hooks.includes("before_main_llm")) { - throw new HttpError( - 400, - "prompt_time output requires before_main_llm hook", - "VALIDATION_ERROR", - { opId: op.opId } - ); - } + for (const condition of op.config.runConditions ?? []) { + if (condition.type !== "guard_output") continue; + if (!opIds.has(condition.sourceOpId)) { + throw new HttpError(400, "runCondition references unknown opId", "VALIDATION_ERROR", { + opId: op.opId, + sourceOpId: condition.sourceOpId, + }); + } + if (!(op.config.dependsOn ?? []).includes(condition.sourceOpId)) { + throw new HttpError( + 400, + "runCondition sourceOpId must also appear in dependsOn", + "VALIDATION_ERROR", + { + opId: op.opId, + sourceOpId: condition.sourceOpId, + } + ); + } - if ( - output?.type === "turn_canonicalization" && - output?.canonicalization?.target === "assistant" && - !op.config.hooks.includes("after_main_llm") - ) { - throw new HttpError( - 400, - "turn_canonicalization target=assistant requires after_main_llm hook", - "VALIDATION_ERROR", - { opId: op.opId } - ); + const sourceOp = opsById.get(condition.sourceOpId); + if (!sourceOp || sourceOp.kind !== "guard") { + throw new HttpError( + 400, + "runCondition sourceOpId must reference guard operation", + "VALIDATION_ERROR", + { + opId: op.opId, + sourceOpId: condition.sourceOpId, + } + ); + } + + const outputKeys = new Set(sourceOp.config.params.outputContract.map((item) => item.key)); + if (!outputKeys.has(condition.outputKey)) { + throw new HttpError( + 400, + "runCondition references unknown guard output", + "VALIDATION_ERROR", + { + opId: op.opId, + sourceOpId: condition.sourceOpId, + outputKey: condition.outputKey, + } + ); + } } if (op.kind === "template") { try { - validateLiquidTemplate(op.config.params.template); + validateLiquidTemplate((op.config.params as OperationTemplateParams).template); } catch (error) { throw new HttpError( 400, @@ -422,27 +662,124 @@ function validateCrossRules(input: ValidatedOperationBlockInput): void { } } } - } - if (detectDependencyCycle(input.operations)) { - throw new HttpError(400, "Dependency cycle detected", "VALIDATION_ERROR"); - } + if (op.kind === "guard") { + const { artifact: _artifact, ...rawGuardParams } = op.config.params; + const guardParams = guardOperationParamsSchema.parse(rawGuardParams); + try { + compileGuardOutputSchema(guardParams.outputContract); + } catch (error) { + throw new HttpError( + 400, + `Guard output contract is invalid: ${error instanceof Error ? error.message : String(error)}`, + "VALIDATION_ERROR", + { opId: op.opId } + ); + } - const tags = new Map(); // tag -> opId - for (const op of input.operations) { - const params = op.config.params as unknown as { output?: { type?: string; writeArtifact?: { tag?: string } } }; - if (!params?.output || params.output.type !== "artifacts") continue; - const tag = params.output.writeArtifact?.tag; - if (!tag) continue; - const existing = tags.get(tag); - if (existing) { + if (op.config.params.artifact.format !== "json") { + throw new HttpError( + 400, + "Guard artifact format must be json", + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + + if (guardParams.engine === "liquid") { + try { + validateLiquidTemplate(guardParams.template); + } catch (error) { + throw new HttpError( + 400, + `Guard template не компилируется: ${error instanceof Error ? error.message : String(error)}`, + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + } + + if (guardParams.engine === "aux_llm") { + try { + validateLiquidTemplate(guardParams.prompt); + } catch (error) { + throw new HttpError( + 400, + `Guard prompt template не компилируется: ${error instanceof Error ? error.message : String(error)}`, + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + if (typeof guardParams.system === "string" && guardParams.system.length > 0) { + try { + validateLiquidTemplate(guardParams.system); + } catch (error) { + throw new HttpError( + 400, + `Guard system template не компилируется: ${error instanceof Error ? error.message : String(error)}`, + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + } + } + } + + if (op.kind === "knowledge_search" || op.kind === "knowledge_reveal") { + const parser: + | ((raw: unknown) => KnowledgeSearchOperationParams) + | ((raw: unknown) => KnowledgeRevealOperationParams) = + op.kind === "knowledge_search" + ? parseKnowledgeSearchOperationParams + : parseKnowledgeRevealOperationParams; + const knowledgeParams = parser(op.config.params.params); + + if (op.config.params.artifact.format !== "json") { + throw new HttpError( + 400, + `${op.kind} artifact format must be json`, + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + + if (knowledgeParams.source.mode === "inline") { + try { + validateLiquidTemplate(knowledgeParams.source.requestTemplate); + } catch (error) { + throw new HttpError( + 400, + `${op.kind} template не компилируется: ${error instanceof Error ? error.message : String(error)}`, + "VALIDATION_ERROR", + { opId: op.opId } + ); + } + } + } + + const existingArtifactId = artifactIds.get(op.config.params.artifact.artifactId); + if (existingArtifactId) { + throw new HttpError(400, "Duplicate artifactId in block", "VALIDATION_ERROR", { + artifactId: op.config.params.artifact.artifactId, + opId: op.opId, + conflictsWithOpId: existingArtifactId, + }); + } + artifactIds.set(op.config.params.artifact.artifactId, op.opId); + + const existingTag = artifactTags.get(op.config.params.artifact.tag); + if (existingTag) { throw new HttpError(400, "Duplicate artifact tag in block", "VALIDATION_ERROR", { - tag, + tag: op.config.params.artifact.tag, opId: op.opId, - conflictsWithOpId: existing, + conflictsWithOpId: existingTag, }); } - tags.set(tag, op.opId); + artifactTags.set(op.config.params.artifact.tag, op.opId); + } + + if (detectDependencyCycle(input.operations)) { + throw new HttpError(400, "Dependency cycle detected", "VALIDATION_ERROR"); } } @@ -461,6 +798,7 @@ export function validateOperationBlockUpsertInput(raw: unknown): ValidatedOperat triggers: normalizeTriggers(op.config.triggers), activation: normalizeActivation(op.config.activation), dependsOn: normalizeDependsOn(op.config.dependsOn), + runConditions: normalizeRunConditions(op.config.runConditions), }; return { ...op, config: normalizedConfig } as OperationInProfile; }); @@ -498,3 +836,32 @@ export function validateOperationBlockImport(raw: unknown): ValidatedOperationBl meta: parsed.data.meta, }); } + +export function validateCompiledProfileArtifactWriters(profile: OperationProfile): void { + const artifactIds = new Map(); + const artifactTags = new Map(); + + for (const op of profile.operations ?? []) { + const artifactId = op.config.params.artifact.artifactId; + const existingArtifactId = artifactIds.get(artifactId); + if (existingArtifactId) { + throw new HttpError(400, "Duplicate artifactId in compiled profile", "VALIDATION_ERROR", { + artifactId, + opId: op.opId, + conflictsWithOpId: existingArtifactId, + }); + } + artifactIds.set(artifactId, op.opId); + + const tag = op.config.params.artifact.tag; + const existingTag = artifactTags.get(tag); + if (existingTag) { + throw new HttpError(400, "Duplicate artifact tag in compiled profile", "VALIDATION_ERROR", { + tag, + opId: op.opId, + conflictsWithOpId: existingTag, + }); + } + artifactTags.set(tag, op.opId); + } +} diff --git a/server/src/services/operations/operation-blocks-repository.ts b/server/src/services/operations/operation-blocks-repository.ts index 7b2deb0e..7a710dc2 100644 --- a/server/src/services/operations/operation-blocks-repository.ts +++ b/server/src/services/operations/operation-blocks-repository.ts @@ -1,6 +1,7 @@ -import { and, asc, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, asc, eq } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; import { operationBlocks } from "../../db/schema"; @@ -25,6 +26,13 @@ function parseSpecJson(value: string): OperationBlockSpecRow { function rowToDto(row: typeof operationBlocks.$inferSelect): OperationBlock { const spec = parseSpecJson(row.specJson); + const normalized = validateOperationBlockUpsertInput({ + name: row.name, + description: row.description ?? undefined, + enabled: row.enabled, + operations: spec.operations, + meta: safeJsonParse(row.metaJson, null), + }); return { blockId: row.id, ownerId: row.ownerId, @@ -32,8 +40,8 @@ function rowToDto(row: typeof operationBlocks.$inferSelect): OperationBlock { description: row.description ?? undefined, enabled: row.enabled, version: row.version, - operations: spec.operations, - meta: safeJsonParse(row.metaJson, null), + operations: normalized.operations, + meta: normalized.meta, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/server/src/services/operations/operation-profile-resolver.test.ts b/server/src/services/operations/operation-profile-resolver.test.ts index b6a4e4e2..d1bdd85c 100644 --- a/server/src/services/operations/operation-profile-resolver.test.ts +++ b/server/src/services/operations/operation-profile-resolver.test.ts @@ -1,8 +1,13 @@ +import { + normalizeOperationArtifactConfig, + type LegacyOperationOutput, + type OperationBlock, + type OperationProfile, +} from "@shared/types/operation-profiles"; import { describe, expect, test, vi } from "vitest"; import { resolveCompiledOperationProfile } from "./operation-profile-resolver"; -import type { OperationBlock, OperationProfile } from "@shared/types/operation-profiles"; const blockById = new Map(); @@ -14,6 +19,8 @@ function makeBlock(params: { blockId: string; version: number; order: number; + artifactTag?: string; + withGuardConsumer?: boolean; }): OperationBlock { const now = new Date(); return { @@ -22,32 +29,118 @@ function makeBlock(params: { name: params.blockId, enabled: true, version: params.version, - operations: [ - { - opId: "op-1e6df0d9-2d3a-420f-83f0-a4e90ca1b056", - name: "op", - kind: "template", - config: { - enabled: true, - required: false, - hooks: ["before_main_llm"], - order: params.order, - dependsOn: [], - params: { - template: "hello", - output: { - type: "artifacts", - writeArtifact: { - tag: `${params.blockId.replace(/-/g, "_")}_state`, - persistence: "run_only", - usage: "internal", - semantics: "intermediate", + operations: params.withGuardConsumer + ? [ + { + opId: "11111111-1111-4111-8111-111111111111", + name: "guard", + kind: "guard", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: params.order, + dependsOn: [], + params: { + engine: "liquid", + outputContract: [{ key: "isBattle", title: "Battle" }], + template: "{\"isBattle\": true}", + artifact: normalizeOperationArtifactConfig({ + opId: "11111111-1111-4111-8111-111111111111", + kind: "guard", + title: "guard", + rawParams: { + artifact: { + artifactId: "artifact:11111111-1111-4111-8111-111111111111", + tag: `block_${params.blockId.replace(/-/g, "_")}_guard`, + title: "Guard state", + format: "json", + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: 20, + }, + exposures: [], + }, + }, + }), }, }, }, - }, - }, - ], + { + opId: "22222222-2222-4222-8222-222222222222", + name: "consumer", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: params.order + 1, + dependsOn: ["11111111-1111-4111-8111-111111111111"], + runConditions: [ + { + type: "guard_output", + sourceOpId: "11111111-1111-4111-8111-111111111111", + outputKey: "isBattle", + operator: "is_true", + }, + ], + params: { + template: "hello", + artifact: normalizeOperationArtifactConfig({ + opId: "22222222-2222-4222-8222-222222222222", + kind: "template", + title: "consumer", + rawParams: { + output: { + type: "artifacts", + writeArtifact: { + tag: params.artifactTag ?? `block_${params.blockId.replace(/-/g, "_")}_state`, + persistence: "run_only", + usage: "internal", + semantics: "intermediate", + }, + } satisfies LegacyOperationOutput, + }, + }), + }, + }, + }, + ] + : [ + { + opId: "op-1e6df0d9-2d3a-420f-83f0-a4e90ca1b056", + name: "op", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: params.order, + dependsOn: [], + params: { + template: "hello", + artifact: normalizeOperationArtifactConfig({ + opId: "op-1e6df0d9-2d3a-420f-83f0-a4e90ca1b056", + kind: "template", + title: "op", + rawParams: { + output: { + type: "artifacts", + writeArtifact: { + tag: params.artifactTag ?? `block_${params.blockId.replace(/-/g, "_")}_state`, + persistence: "run_only", + usage: "internal", + semantics: "intermediate", + }, + } satisfies LegacyOperationOutput, + }, + }), + }, + }, + }, + ], meta: null, createdAt: now, updatedAt: now, @@ -123,4 +216,80 @@ describe("operation profile resolver", () => { expect(out.blockVersionFingerprint).toBe(""); expect(out.operations).toEqual([]); }); + + test("rejects duplicate artifact writers after block flattening", async () => { + blockById.clear(); + blockById.set("11111111-1111-4111-8111-111111111111", makeBlock({ + blockId: "11111111-1111-4111-8111-111111111111", + version: 1, + order: 10, + artifactTag: "shared_state", + })); + blockById.set("22222222-2222-4222-8222-222222222222", makeBlock({ + blockId: "22222222-2222-4222-8222-222222222222", + version: 1, + order: 20, + artifactTag: "shared_state", + })); + + const now = new Date(); + const profile: OperationProfile = { + profileId: "profile-dup", + ownerId: "global", + name: "dup", + enabled: true, + executionMode: "sequential", + operationProfileSessionId: "sess-dup", + version: 1, + blockRefs: [ + { blockId: "11111111-1111-4111-8111-111111111111", enabled: true, order: 10 }, + { blockId: "22222222-2222-4222-8222-222222222222", enabled: true, order: 20 }, + ], + meta: null, + createdAt: now, + updatedAt: now, + }; + + await expect(resolveCompiledOperationProfile(profile)).rejects.toThrow( + /duplicate artifact (tag|id) in compiled profile/i + ); + }); + + test("prefixes guard run condition source ids when flattening block operations", async () => { + blockById.clear(); + blockById.set("11111111-1111-4111-8111-111111111111", makeBlock({ + blockId: "11111111-1111-4111-8111-111111111111", + version: 1, + order: 10, + withGuardConsumer: true, + })); + + const now = new Date(); + const profile: OperationProfile = { + profileId: "profile-guard", + ownerId: "global", + name: "guard", + enabled: true, + executionMode: "sequential", + operationProfileSessionId: "sess-guard", + version: 1, + blockRefs: [ + { blockId: "11111111-1111-4111-8111-111111111111", enabled: true, order: 10 }, + ], + meta: null, + createdAt: now, + updatedAt: now, + }; + + const out = await resolveCompiledOperationProfile(profile); + const consumer = out.operations.find((item) => item.kind === "template" && item.name === "consumer"); + expect(consumer?.config.runConditions).toEqual([ + { + type: "guard_output", + sourceOpId: "11111111-1111-4111-8111-111111111111:11111111-1111-4111-8111-111111111111", + outputKey: "isBattle", + operator: "is_true", + }, + ]); + }); }); diff --git a/server/src/services/operations/operation-profile-resolver.ts b/server/src/services/operations/operation-profile-resolver.ts index 461f331e..34b9dc1a 100644 --- a/server/src/services/operations/operation-profile-resolver.ts +++ b/server/src/services/operations/operation-profile-resolver.ts @@ -1,11 +1,16 @@ +import { buildOperationArtifactId } from "@shared/types/operation-profiles"; + import { HttpError } from "@core/middleware/error-handler"; +import { validateCompiledProfileArtifactWriters } from "./operation-block-validator"; import { getOperationBlockById } from "./operation-blocks-repository"; import type { + OperationArtifactConfig, OperationBlock, OperationInProfile, OperationProfile, + OperationKind, } from "@shared/types/operation-profiles"; const ORDER_BUCKET = 1_000_000; @@ -30,7 +35,19 @@ function mapOperationToRuntime(params: { const blockPrefix = `${params.blockId}:`; const opId = `${blockPrefix}${params.op.opId}`; const dependsOn = params.op.config.dependsOn?.map((dep) => `${blockPrefix}${dep}`); + const runConditions = params.op.config.runConditions?.map((condition) => + condition.type === "guard_output" + ? { + ...condition, + sourceOpId: `${blockPrefix}${condition.sourceOpId}`, + } + : condition + ); const order = params.blockOrderIndex * ORDER_BUCKET + normalizeOrder(params.op.config.order); + const artifact: OperationArtifactConfig = { + ...params.op.config.params.artifact, + artifactId: buildOperationArtifactId(opId), + }; return { ...params.op, opId, @@ -38,8 +55,19 @@ function mapOperationToRuntime(params: { ...params.op.config, order, dependsOn: dependsOn?.length ? dependsOn : undefined, + runConditions: runConditions?.length ? runConditions : undefined, + params: + params.op.kind === "template" + ? { + ...params.op.config.params, + artifact, + } + : { + ...params.op.config.params, + artifact, + }, }, - } as OperationInProfile; + } as Extract; } async function resolveBlocks( @@ -72,6 +100,7 @@ export async function resolveCompiledOperationProfile( ): Promise { if (!Array.isArray(profile.blockRefs) || profile.blockRefs.length === 0) { const operations = profile.operations ?? []; + validateCompiledProfileArtifactWriters({ ...profile, operations }); return { profile, operations, @@ -101,6 +130,8 @@ export async function resolveCompiledOperationProfile( .map((item) => `${item.blockId}:${item.version}`) .join("|"); + validateCompiledProfileArtifactWriters({ ...profile, operations }); + return { profile, operations, diff --git a/server/src/services/operations/operation-profiles-repository.ts b/server/src/services/operations/operation-profiles-repository.ts index 84f10670..c234f9e9 100644 --- a/server/src/services/operations/operation-profiles-repository.ts +++ b/server/src/services/operations/operation-profiles-repository.ts @@ -1,6 +1,7 @@ -import { and, asc, eq } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, asc, eq } from "drizzle-orm"; + import { HttpError } from "@core/middleware/error-handler"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; diff --git a/server/src/services/operations/template-operations-runtime.test.ts b/server/src/services/operations/template-operations-runtime.test.ts index bca3ab4a..9a5a75df 100644 --- a/server/src/services/operations/template-operations-runtime.test.ts +++ b/server/src/services/operations/template-operations-runtime.test.ts @@ -1,3 +1,9 @@ +import { + buildOperationArtifactId, + normalizeOperationArtifactConfig, + type LegacyOperationOutput, + type OperationProfile, +} from "@shared/types/operation-profiles"; import { describe, expect, test } from "vitest"; import { @@ -5,7 +11,15 @@ import { applyTemplateOperationsToPromptDraft, } from "./template-operations-runtime"; -import type { OperationProfile } from "@shared/types/operation-profiles"; + +function toArtifact(opId: string, title: string, output: LegacyOperationOutput) { + return normalizeOperationArtifactConfig({ + opId, + kind: "template", + title, + rawParams: { output }, + }); +} function makeProfile(operations: OperationProfile["operations"]): OperationProfile { @@ -41,10 +55,10 @@ describe("template operations runtime", () => { order: 10, params: { template: "[{{user.name}}]", - output: { + artifact: toArtifact("a7ea76de-6e0a-4f8a-a9fb-6eb9ad33c7df", "sys prepend", { type: "prompt_time", promptTime: { kind: "system_update", mode: "prepend", source: "t" }, - }, + }), }, }, }, @@ -86,10 +100,10 @@ describe("template operations runtime", () => { order: 10, params: { template: "normalized", - output: { + artifact: toArtifact("de11b614-a56e-4d95-9ee3-5a4ffb0b2d44", "assistant post", { type: "turn_canonicalization", canonicalization: { kind: "replace_text", target: "assistant" }, - }, + }), }, }, }, @@ -129,7 +143,7 @@ describe("template operations runtime", () => { order: 10, params: { template: "{{promptSystem}}", - output: { + artifact: toArtifact("4b7c0a14-5528-4f65-beb0-f8a65ef6a1f2", "copy system", { type: "artifacts", writeArtifact: { tag: "sys_copy", @@ -137,7 +151,7 @@ describe("template operations runtime", () => { usage: "internal", semantics: "intermediate", }, - }, + }), }, }, }, @@ -163,6 +177,83 @@ describe("template operations runtime", () => { }, }); - expect(out.artifacts.sys_copy?.value).toBe("sys one\n\nsys two"); + expect(out.artifacts[buildOperationArtifactId("4b7c0a14-5528-4f65-beb0-f8a65ef6a1f2")]?.value).toBe( + "sys one\n\nsys two" + ); + }); + + test("provides artifact tag and artByOpId access for cross-operation artifact references", async () => { + const profile = makeProfile([ + { + opId: "producer-op", + name: "producer", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + triggers: ["generate"], + order: 10, + params: { + template: "VALUE_FROM_PRODUCER", + artifact: toArtifact("producer-op", "producer", { + type: "artifacts", + writeArtifact: { + tag: "producer_value", + persistence: "run_only", + usage: "internal", + semantics: "intermediate", + }, + }), + }, + }, + }, + { + opId: "consumer-op", + name: "consumer", + kind: "template", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + triggers: ["generate"], + order: 20, + dependsOn: ["producer-op"], + params: { + template: "{{art.producer_value.value}}|{{artByOpId[\"producer-op\"].value}}", + artifact: toArtifact("consumer-op", "consumer", { + type: "artifacts", + writeArtifact: { + tag: "consumer_value", + persistence: "run_only", + usage: "internal", + semantics: "intermediate", + }, + }), + }, + }, + }, + ]); + + const out = await applyTemplateOperationsToPromptDraft({ + runId: "run-4", + profile, + trigger: "generate", + draftMessages: [{ role: "user", content: "hello" }], + templateContext: { + char: {}, + user: {}, + chat: {}, + messages: [], + rag: {}, + art: {}, + now: new Date().toISOString(), + }, + }); + + expect(out.artifacts[buildOperationArtifactId("consumer-op")]?.value).toBe( + "VALUE_FROM_PRODUCER|VALUE_FROM_PRODUCER" + ); }); }); + diff --git a/server/src/services/operations/template-operations-runtime.ts b/server/src/services/operations/template-operations-runtime.ts index 8b160b6f..629b5e34 100644 --- a/server/src/services/operations/template-operations-runtime.ts +++ b/server/src/services/operations/template-operations-runtime.ts @@ -1,15 +1,11 @@ import { runOrchestrator } from "@core/operation-orchestrator"; import { renderLiquidTemplate } from "../chat-core/prompt-template-renderer"; +import { compileArtifactExposureEffect } from "../chat-generation-v3/contracts"; +import { applyPromptEffect } from "../chat-generation-v3/operations/effect-handlers/prompt-effects"; import type { InstructionRenderContext } from "../chat-core/prompt-template-renderer"; -import type { - OperationInProfile, - OperationOutput, - OperationProfile, - OperationTrigger, -} from "@shared/types/operation-profiles"; - +import type { OperationInProfile, OperationProfile, OperationTrigger } from "@shared/types/operation-profiles"; export type PromptDraftMessage = { role: "system" | "user" | "assistant"; @@ -18,7 +14,7 @@ export type PromptDraftMessage = { type RuntimeState = { messages: PromptDraftMessage[]; - art: Record; + art: Record; assistantText: string; }; @@ -34,108 +30,89 @@ function normalizeText(value: unknown): string { return typeof value === "string" ? value : String(value ?? ""); } -function normalizePromptTimeRole(value: unknown): PromptDraftMessage["role"] { - if (value === "assistant" || value === "user" || value === "system") return value; - if (value === "developer") return "system"; - return "system"; -} - -function resolveMinInsertIndex(messages: PromptDraftMessage[]): number { - const firstSystemIdx = messages.findIndex((message) => message.role === "system"); - return firstSystemIdx >= 0 ? firstSystemIdx + 1 : 0; -} - -function applyPromptTimeEffect( - state: RuntimeState, - output: Extract, - payload: string -): void { - const promptTime = output.promptTime; - if (promptTime.kind === "system_update") { - const idx = state.messages.findIndex((m) => m.role === "system"); - const current = idx >= 0 ? state.messages[idx]!.content : ""; - - const next = - promptTime.mode === "replace" - ? payload - : promptTime.mode === "prepend" - ? `${payload}${current}` - : `${current}${payload}`; - - if (idx >= 0) state.messages[idx] = { role: "system", content: next }; - else state.messages.unshift({ role: "system", content: next }); - return; - } - - if (promptTime.kind === "append_after_last_user") { - const lastUserIdx = state.messages.map((m) => m.role).lastIndexOf("user"); - const insertAt = lastUserIdx >= 0 ? lastUserIdx + 1 : state.messages.length; - state.messages.splice(insertAt, 0, { role: normalizePromptTimeRole(promptTime.role), content: payload }); - return; +function mapArtifactsByOpId( + operations: OperationInProfile[], + artifacts: Record +): Record { + const mapped: Record = {}; + for (const op of operations) { + const artifact = artifacts[op.config.params.artifact.artifactId]; + if (!artifact) continue; + mapped[op.opId] = { value: artifact.value, history: [...artifact.history] }; } - - const raw = state.messages.length - Math.abs(promptTime.depthFromEnd); - const minInsertAt = resolveMinInsertIndex(state.messages); - const insertAt = Math.min(state.messages.length, Math.max(minInsertAt, raw)); - state.messages.splice(insertAt, 0, { role: normalizePromptTimeRole(promptTime.role), content: payload }); + return mapped; } -function applyTurnCanonicalizationEffect( - state: RuntimeState, - output: Extract, - payload: string, - hook: "before_main_llm" | "after_main_llm" -): void { - const canonicalization = output.canonicalization; - if (canonicalization.kind !== "replace_text") return; - - if (canonicalization.target === "assistant" && hook === "after_main_llm") { - state.assistantText = payload; - return; - } - - if (canonicalization.target === "user") { - const lastUserIdx = state.messages.map((m) => m.role).lastIndexOf("user"); - if (lastUserIdx >= 0) { - state.messages[lastUserIdx] = { role: "user", content: payload }; - } +function mapArtifactsForTemplate( + operations: OperationInProfile[], + artifacts: Record +): Record { + const mapped: Record = {}; + for (const op of operations) { + const artifact = artifacts[op.config.params.artifact.artifactId]; + if (!artifact) continue; + const snapshot = { value: artifact.value, history: [...artifact.history] }; + mapped[op.config.params.artifact.tag] = snapshot; + mapped[op.config.params.artifact.artifactId] = snapshot; } + return mapped; } -function applyOperationOutput( +function applyArtifactEffects( state: RuntimeState, - output: OperationOutput, - payload: string, - hook: "before_main_llm" | "after_main_llm" + op: Extract, + payload: string ): void { - if (output.type === "prompt_time") { - if (hook === "before_main_llm") applyPromptTimeEffect(state, output, payload); - return; - } - - if (output.type === "turn_canonicalization") { - applyTurnCanonicalizationEffect(state, output, payload, hook); - return; - } - - const tag = output.writeArtifact.tag; - const existing = state.art[tag]; + const artifact = op.config.params.artifact; + const existing = state.art[artifact.artifactId]; if (existing) { existing.value = payload; existing.history.push(payload); - return; + } else { + state.art[artifact.artifactId] = { value: payload, history: [payload] }; + } + + for (const exposure of artifact.exposures) { + const effect = compileArtifactExposureEffect({ + opId: op.opId, + artifact, + exposure, + value: payload, + }); + if ( + effect.type === "prompt.system_update" || + effect.type === "prompt.append_after_last_user" || + effect.type === "prompt.insert_at_depth" + ) { + state.messages = applyPromptEffect(state.messages, effect); + continue; + } + if (effect.type === "turn.assistant.replace_text") { + state.assistantText = effect.text; + continue; + } + if (effect.type === "turn.user.replace_text") { + const lastUserIdx = state.messages.map((m) => m.role).lastIndexOf("user"); + if (lastUserIdx >= 0) { + state.messages[lastUserIdx] = { role: "user", content: effect.text }; + } + } } - state.art[tag] = { value: payload, history: [payload] }; } function buildOperationContext( base: InstructionRenderContext, - state: RuntimeState + state: RuntimeState, + operations: OperationInProfile[] ): InstructionRenderContext { return { ...base, promptSystem: resolvePromptSystem(state.messages), - art: { ...(base.art ?? {}), ...state.art }, + art: { ...(base.art ?? {}), ...mapArtifactsForTemplate(operations, state.art) }, + artByOpId: { + ...(base.artByOpId ?? {}), + ...mapArtifactsByOpId(operations, state.art), + }, messages: state.messages.map((m) => ({ role: m.role, content: m.content })), }; } @@ -173,12 +150,12 @@ async function runTemplateOperations(params: { const rendered = normalizeText( await renderLiquidTemplate({ templateText: op.config.params.template, - context: buildOperationContext(params.templateContext, params.state), + context: buildOperationContext(params.templateContext, params.state, params.profile.operations ?? []), options: { strictVariables: Boolean(op.config.params.strictVariables) }, }) ); - applyOperationOutput(params.state, op.config.params.output, rendered, params.hook); + applyArtifactEffects(params.state, op, rendered); return { output: rendered }; }, })), @@ -204,7 +181,7 @@ export async function applyTemplateOperationsToPromptDraft(params: { templateContext: InstructionRenderContext; }): Promise<{ messages: PromptDraftMessage[]; - artifacts: Record; + artifacts: Record; }> { const state: RuntimeState = { messages: params.draftMessages.map((m) => ({ ...m })), @@ -233,7 +210,7 @@ export async function applyTemplateOperationsAfterMainLlm(params: { templateContext: InstructionRenderContext; }): Promise<{ assistantText: string; - artifacts: Record; + artifacts: Record; }> { const state: RuntimeState = { messages: params.draftMessages.map((m) => ({ ...m })), @@ -241,7 +218,6 @@ export async function applyTemplateOperationsAfterMainLlm(params: { assistantText: params.assistantText, }; - // Make the generated assistant message available for after_main_llm templates. state.messages.push({ role: "assistant", content: state.assistantText }); await runTemplateOperations({ @@ -258,3 +234,4 @@ export async function applyTemplateOperationsAfterMainLlm(params: { artifacts: state.art, }; } + diff --git a/server/src/services/rag.service.ts b/server/src/services/rag.service.ts index 12ca2101..5fd022bb 100644 --- a/server/src/services/rag.service.ts +++ b/server/src/services/rag.service.ts @@ -1,6 +1,7 @@ +import { randomUUID as uuidv4 } from "node:crypto"; + import axios from "axios"; import { and, desc, eq } from "drizzle-orm"; -import { randomUUID as uuidv4 } from "node:crypto"; import { z } from "zod"; import { HttpError } from "@core/middleware/error-handler"; diff --git a/server/src/services/rag/chroma-rag.service.test.ts b/server/src/services/rag/chroma-rag.service.test.ts index 574f374c..a1842929 100644 --- a/server/src/services/rag/chroma-rag.service.test.ts +++ b/server/src/services/rag/chroma-rag.service.test.ts @@ -2,11 +2,11 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { HttpError } from "../../core/middleware/error-handler"; +import { chromaClient } from "./chroma-client"; import { bootstrapChroma, createChromaRagService, } from "./chroma-rag.service"; -import { chromaClient } from "./chroma-client"; afterEach(() => { vi.restoreAllMocks(); diff --git a/server/src/services/rag/chroma-rag.service.ts b/server/src/services/rag/chroma-rag.service.ts index 2b455faa..22bee5d6 100644 --- a/server/src/services/rag/chroma-rag.service.ts +++ b/server/src/services/rag/chroma-rag.service.ts @@ -1,11 +1,12 @@ import { getChromaConfig } from "../../config/chroma-config"; import { HttpError } from "../../core/middleware/error-handler"; +import { generateRagEmbedding } from "../rag.service"; +import { normalizeWorldInfoBookEntries } from "../world-info/world-info-normalizer"; import { getBookDataEntries, listWorldInfoBooksForIndexing, } from "../world-info/world-info-repositories"; -import { normalizeWorldInfoBookEntries } from "../world-info/world-info-normalizer"; -import type { WorldInfoBookDto } from "../world-info/world-info-types"; + import { chromaClient, @@ -15,7 +16,9 @@ import { type ChromaWhere, } from "./chroma-client"; import { extractEmbeddings } from "./rag-embeddings-normalizer"; -import { generateRagEmbedding } from "../rag.service"; + +import type { WorldInfoBookDto } from "../world-info/world-info-types"; + type ChromaMetadataValue = string | number | boolean | null; diff --git a/server/src/services/samplers.service.ts b/server/src/services/samplers.service.ts index 571e13bd..88a10143 100644 --- a/server/src/services/samplers.service.ts +++ b/server/src/services/samplers.service.ts @@ -22,6 +22,16 @@ class SamplersSettings extends ConfigService { } } +export function resolveImportedSamplerPresetName(input: string, existingNames: string[]): string { + const base = input.trim() || "Imported sampler preset"; + if (!existingNames.includes(base)) return base; + for (let idx = 2; idx <= 9999; idx += 1) { + const candidate = `${base} (imported ${idx})`; + if (!existingNames.includes(candidate)) return candidate; + } + return `${base} (imported ${Date.now()})`; +} + export const samplersService = { samplers: new Samplers(), samplersSettings: new SamplersSettings(), diff --git a/server/src/services/template-runtime/expr/compile-expr.test.ts b/server/src/services/template-runtime/expr/compile-expr.test.ts new file mode 100644 index 00000000..c1853c1d --- /dev/null +++ b/server/src/services/template-runtime/expr/compile-expr.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vitest"; + +import { compileExpr, validateExpr, type RuntimeError } from "../index"; + +describe("template-runtime/compileExpr", () => { + test("parses literals, paths, arrays, and objects", () => { + expect(compileExpr("null")).toMatchObject({ kind: "literal", value: null }); + expect(compileExpr("true")).toMatchObject({ kind: "literal", value: true }); + expect(compileExpr("42")).toMatchObject({ kind: "literal", value: 42 }); + expect(compileExpr('"hello"')).toMatchObject({ kind: "literal", value: "hello" }); + expect(compileExpr("char.name")).toMatchObject({ + kind: "path", + segments: ["char", "name"], + }); + expect(compileExpr("[1, 2, 3]")).toMatchObject({ + kind: "array", + items: [{ kind: "literal", value: 1 }, { kind: "literal", value: 2 }, { kind: "literal", value: 3 }], + }); + expect(compileExpr('{ uwu: true, "ara": false }')).toMatchObject({ + kind: "object", + entries: [ + { key: "uwu", value: { kind: "literal", value: true } }, + { key: "ara", value: { kind: "literal", value: false } }, + ], + }); + }); + + test("preserves precedence and associativity", () => { + expect(compileExpr("not a and b or c")).toMatchObject({ + kind: "binary", + op: "or", + left: { + kind: "binary", + op: "and", + left: { + kind: "unary", + op: "not", + value: { kind: "path", segments: ["a"] }, + }, + right: { kind: "path", segments: ["b"] }, + }, + right: { kind: "path", segments: ["c"] }, + }); + }); + + test("parses function calls and nested expressions", () => { + expect(compileExpr('contains(lastUserMessage, "uwu", "i")')).toMatchObject({ + kind: "call", + name: "contains", + args: [ + { kind: "path", segments: ["lastUserMessage"] }, + { kind: "literal", value: "uwu" }, + { kind: "literal", value: "i" }, + ], + }); + }); + + test("validateExpr accepts valid sources and rejects invalid ones", () => { + expect(() => validateExpr('{ uwu: contains(lastUserMessage, "uwu", "i") }')).not.toThrow(); + expect(() => validateExpr("char.name == ")).toThrow(); + }); + + test("throws typed parse errors", () => { + try { + compileExpr("[1,"); + throw new Error("expected parse error"); + } catch (error) { + const runtimeError = error as RuntimeError; + expect(runtimeError.code).toBe("EXPR_PARSE_ERROR"); + expect(runtimeError.message).toContain("Expected"); + } + }); +}); diff --git a/server/src/services/template-runtime/expr/evaluate-expr.test.ts b/server/src/services/template-runtime/expr/evaluate-expr.test.ts new file mode 100644 index 00000000..7f1ecf03 --- /dev/null +++ b/server/src/services/template-runtime/expr/evaluate-expr.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "vitest"; + +import { evaluateExpr, type InstructionRenderContext, type RuntimeError } from "../index"; + +function makeContext(): InstructionRenderContext { + return { + char: { name: "Alice" }, + user: { name: "User" }, + chat: { title: "Chat" }, + messages: [ + { role: "system", content: "S0" }, + { role: "assistant", content: "A1" }, + { role: "user", content: "U1 uwu" }, + { role: "assistant", content: "A2 ara" }, + { role: "user", content: "U2 uwu ara" }, + ], + rag: {}, + art: { + note: { value: "memo", history: ["memo"] }, + }, + promptSystem: "SYS", + outlet: { + default: "OUTLET_TEXT", + }, + lastUserMessage: "U2 uwu ara", + lastAssistantMessage: "A2 ara", + now: new Date("2026-03-16T00:00:00.000Z").toISOString(), + }; +} + +describe("template-runtime/evaluateExpr", () => { + test("resolves paths and missing values in non-strict mode", () => { + expect( + evaluateExpr({ + source: "char.name", + context: makeContext(), + }) + ).toBe("Alice"); + + expect( + evaluateExpr({ + source: "missing.value", + context: makeContext(), + }) + ).toBeNull(); + }); + + test("throws on missing values in strict mode", () => { + expect(() => + evaluateExpr({ + source: "missing.value", + context: makeContext(), + options: { + strictVariables: true, + }, + }) + ).toThrow(); + }); + + test("evaluates booleans, equality, arrays, and objects", () => { + expect( + evaluateExpr({ + source: 'contains(lastUserMessage, "uwu", "i") and char.name == "Alice"', + context: makeContext(), + }) + ).toBe(true); + + expect( + evaluateExpr({ + source: '{ uwu: contains(lastUserMessage, "uwu", "i"), ara: contains(lastUserMessage, "ara", "i") }', + context: makeContext(), + }) + ).toEqual({ + uwu: true, + ara: true, + }); + }); + + test("supports helper registry semantics", () => { + expect( + evaluateExpr({ + source: "size(recentMessages(2))", + context: makeContext(), + }) + ).toBe(2); + + expect( + evaluateExpr({ + source: 'recentMessagesText(2)', + context: makeContext(), + }) + ).toBe("assistant: A2 ara\nuser: U2 uwu ara"); + + expect( + evaluateExpr({ + source: "outlet('default')", + context: makeContext(), + }) + ).toBe("OUTLET_TEXT"); + }); + + test("uses deterministic rng for pickRandom", () => { + expect( + evaluateExpr({ + source: 'pickRandom("A", "B", "C")', + context: makeContext(), + options: { + rng: () => 0, + }, + }) + ).toBe("A"); + + expect( + evaluateExpr({ + source: 'pickRandom("A", "B", "C")', + context: makeContext(), + options: { + rng: () => 0.999999, + }, + }) + ).toBe("C"); + }); + + test("reads variables from render and turn scopes", () => { + expect( + evaluateExpr({ + source: "flag", + context: makeContext(), + state: { + scopes: { + local: {}, + render: { flag: "render-flag" }, + turn: { flag: "turn-flag" }, + }, + }, + }) + ).toBe("render-flag"); + + expect( + evaluateExpr({ + source: "turnOnly", + context: makeContext(), + state: { + scopes: { + local: {}, + render: {}, + turn: { turnOnly: 7 }, + }, + }, + }) + ).toBe(7); + }); + + test("throws typed helper errors", () => { + try { + evaluateExpr({ + source: 'match(lastUserMessage, "[", "i")', + context: makeContext(), + }); + throw new Error("expected helper error"); + } catch (error) { + const runtimeError = error as RuntimeError; + expect(runtimeError.code).toBe("HELPER_ERROR"); + } + }); +}); diff --git a/server/src/services/template-runtime/expr/evaluator.ts b/server/src/services/template-runtime/expr/evaluator.ts new file mode 100644 index 00000000..07ed2350 --- /dev/null +++ b/server/src/services/template-runtime/expr/evaluator.ts @@ -0,0 +1,229 @@ +import { runtimeError } from "../runtime/errors"; +import { invokeHelper } from "../runtime/helpers"; +import { resolvePathValue } from "../runtime/path"; +import { popLocalScope, popMacro, pushLocalScope, pushMacro, snapshotRuntimeState, step } from "../runtime/state"; + +import { parseExpr } from "./parser"; + +import type { ExecutionState } from "../runtime/state"; +import type { CompiledMacroDefinition, ExprAst, ExprNode, InstructionRenderContext, MacroRegistry } from "../types"; + +type EvaluateExprParams = { + ast: ExprAst; + context: InstructionRenderContext; + state: ExecutionState; + macros?: MacroRegistry; + nesting?: number; +}; + +function assertNesting(state: ExecutionState, nesting: number): void { + if (nesting > state.options.maxObjectNesting) { + throw runtimeError("LIMIT_EXCEEDED", "runtime exceeded maxObjectNesting", { + maxObjectNesting: state.options.maxObjectNesting, + nesting, + }); + } +} + +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function isTruthy(value: unknown): boolean { + return value ? true : false; +} + +function toArgRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +} + +function bindMacroParams( + macro: CompiledMacroDefinition, + providedArgs: Record, + context: InstructionRenderContext, + state: ExecutionState, + macros?: MacroRegistry +): Record { + const bound: Record = {}; + + for (const param of macro.params) { + if (param.name in providedArgs) { + bound[param.name] = providedArgs[param.name]; + continue; + } + + if (param.defaultExpr) { + bound[param.name] = evaluateExprNode({ + ast: parseExpr(param.defaultExpr), + context, + state, + macros, + }); + continue; + } + + if (param.required) { + throw runtimeError("INVALID_MACRO", `Missing required macro param: ${param.name}`, { + macro: macro.name, + param: param.name, + }); + } + + bound[param.name] = null; + } + + return bound; +} + +export function evaluateExprNode(params: EvaluateExprParams): unknown { + step(params.state, `expr:${params.ast.kind}`); + const nesting = params.nesting ?? 0; + assertNesting(params.state, nesting); + + switch (params.ast.kind) { + case "literal": + return params.ast.value; + case "path": + return resolvePathValue({ + segments: params.ast.segments, + context: params.context, + state: params.state, + }); + case "array": + return params.ast.items.map((item) => + evaluateExprNode({ + ast: item, + context: params.context, + state: params.state, + macros: params.macros, + nesting: nesting + 1, + }) + ); + case "object": + return Object.fromEntries( + params.ast.entries.map((entry) => [ + entry.key, + evaluateExprNode({ + ast: entry.value, + context: params.context, + state: params.state, + macros: params.macros, + nesting: nesting + 1, + }), + ]) + ); + case "unary": + return !isTruthy( + evaluateExprNode({ + ast: params.ast.value, + context: params.context, + state: params.state, + macros: params.macros, + nesting: nesting + 1, + }) + ); + case "binary": { + const left = evaluateExprNode({ + ast: params.ast.left, + context: params.context, + state: params.state, + macros: params.macros, + nesting: nesting + 1, + }); + + if (params.ast.op === "and") { + return isTruthy(left) + ? isTruthy( + evaluateExprNode({ + ast: params.ast.right, + context: params.context, + state: params.state, + macros: params.macros, + nesting: nesting + 1, + }) + ) + : false; + } + + if (params.ast.op === "or") { + return isTruthy(left) + ? true + : isTruthy( + evaluateExprNode({ + ast: params.ast.right, + context: params.context, + state: params.state, + macros: params.macros, + nesting: nesting + 1, + }) + ); + } + + const right = evaluateExprNode({ + ast: params.ast.right, + context: params.context, + state: params.state, + macros: params.macros, + nesting: nesting + 1, + }); + + return params.ast.op === "eq" ? valuesEqual(left, right) : !valuesEqual(left, right); + } + case "call": + return evaluateCall(params.ast, params.context, params.state, params.macros, nesting + 1); + } +} + +function evaluateCall( + node: Extract, + context: InstructionRenderContext, + state: ExecutionState, + macros?: MacroRegistry, + nesting = 0 +): unknown { + const args = node.args.map((arg) => + evaluateExprNode({ + ast: arg, + context, + state, + macros, + nesting, + }) + ); + + if (node.name === "call") { + const macroName = typeof args[0] === "string" ? args[0] : ""; + const macro = macros?.getCompiled(macroName); + if (!macro || macro.kind !== "value") { + throw runtimeError("UNKNOWN_MACRO", `Unknown value macro: ${macroName}`, { macroName }); + } + + pushMacro(state, macroName); + try { + pushLocalScope(state, bindMacroParams(macro, toArgRecord(args[1]), context, state, macros)); + const result = evaluateExprNode({ + ast: macro.body, + context, + state, + macros, + nesting, + }); + popLocalScope(state); + return result; + } finally { + popMacro(state); + } + } + + if (node.name === "use") { + throw runtimeError("INVALID_MACRO", "use() is not available in expr runtime"); + } + + return invokeHelper(node.name, { + args, + context, + state: snapshotRuntimeState(state), + options: state.options, + }); +} diff --git a/server/src/services/template-runtime/expr/expr-parser.js b/server/src/services/template-runtime/expr/expr-parser.js new file mode 100644 index 00000000..bfe938c6 --- /dev/null +++ b/server/src/services/template-runtime/expr/expr-parser.js @@ -0,0 +1,1822 @@ +/* eslint-disable */ +// @generated by Peggy 5.1.0. +// +// https://peggyjs.org/ + +"use strict"; + +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } + + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } + + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } + + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, + + any() { + return "any character"; + }, + + end() { + return "end of input"; + }, + + other(expectation) { + return expectation.description; + }, + }; + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); + + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; + } +} + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + const peg$FAILED = {}; + const peg$source = options.grammarSource; + + const peg$startRuleFunctions = { + start: peg$parsestart, + }; + let peg$startRuleFunction = peg$parsestart; + + const peg$c0 = "or"; + const peg$c1 = "and"; + const peg$c2 = "=="; + const peg$c3 = "!="; + const peg$c4 = "not"; + const peg$c5 = "("; + const peg$c6 = ")"; + const peg$c7 = ","; + const peg$c8 = "."; + const peg$c9 = "["; + const peg$c10 = "]"; + const peg$c11 = "{"; + const peg$c12 = "}"; + const peg$c13 = ":"; + const peg$c14 = "null"; + const peg$c15 = "true"; + const peg$c16 = "false"; + const peg$c17 = "-"; + const peg$c18 = "\""; + const peg$c19 = "'"; + const peg$c20 = "\\n"; + const peg$c21 = "\\r"; + const peg$c22 = "\\t"; + const peg$c23 = "\\\""; + const peg$c24 = "\\\\"; + const peg$c25 = "\\'"; + + const peg$r0 = /^[0-9]/; + const peg$r1 = /^[a-zA-Z_]/; + const peg$r2 = /^[a-zA-Z0-9_]/; + const peg$r3 = /^[ \t\r\n]/; + + const peg$e0 = peg$literalExpectation("or", false); + const peg$e1 = peg$literalExpectation("and", false); + const peg$e2 = peg$literalExpectation("==", false); + const peg$e3 = peg$literalExpectation("!=", false); + const peg$e4 = peg$literalExpectation("not", false); + const peg$e5 = peg$literalExpectation("(", false); + const peg$e6 = peg$literalExpectation(")", false); + const peg$e7 = peg$literalExpectation(",", false); + const peg$e8 = peg$literalExpectation(".", false); + const peg$e9 = peg$literalExpectation("[", false); + const peg$e10 = peg$literalExpectation("]", false); + const peg$e11 = peg$literalExpectation("{", false); + const peg$e12 = peg$literalExpectation("}", false); + const peg$e13 = peg$literalExpectation(":", false); + const peg$e14 = peg$literalExpectation("null", false); + const peg$e15 = peg$literalExpectation("true", false); + const peg$e16 = peg$literalExpectation("false", false); + const peg$e17 = peg$literalExpectation("-", false); + const peg$e18 = peg$classExpectation([["0", "9"]], false, false, false); + const peg$e19 = peg$literalExpectation("\"", false); + const peg$e20 = peg$literalExpectation("'", false); + const peg$e21 = peg$literalExpectation("\\n", false); + const peg$e22 = peg$literalExpectation("\\r", false); + const peg$e23 = peg$literalExpectation("\\t", false); + const peg$e24 = peg$literalExpectation("\\\"", false); + const peg$e25 = peg$literalExpectation("\\\\", false); + const peg$e26 = peg$anyExpectation(); + const peg$e27 = peg$literalExpectation("\\'", false); + const peg$e28 = peg$classExpectation([["a", "z"], ["A", "Z"], "_"], false, false, false); + const peg$e29 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_"], false, false, false); + const peg$e30 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false, false); + + function peg$f0(expr) { return expr; } + function peg$f1(head, right) { return right; } + function peg$f2(head, tail) { + return tail.reduce((left, right) => binary("or", left, right), head); + } + function peg$f3(head, right) { return right; } + function peg$f4(head, tail) { + return tail.reduce((left, right) => binary("and", left, right), head); + } + function peg$f5(head, op, right) { return { op, right }; } + function peg$f6(head, tail) { + return tail.reduce( + (left, item) => binary(item.op === "==" ? "eq" : "neq", left, item.right), + head + ); + } + function peg$f7(value) { return { kind: "unary", op: "not", value }; } + function peg$f8(expr) { return expr; } + function peg$f9(name, args) { + return { kind: "call", name, args: args ?? [] }; + } + function peg$f10(head, value) { return value; } + function peg$f11(head, tail) { + return [head, ...tail]; + } + function peg$f12(head, segment) { return segment; } + function peg$f13(head, tail) { + return { kind: "path", segments: [head, ...tail] }; + } + function peg$f14(items) { + return { kind: "array", items: items ?? [] }; + } + function peg$f15(entries) { + return { kind: "object", entries: entries ?? [] }; + } + function peg$f16(head, entry) { return entry; } + function peg$f17(head, tail) { + return [head, ...tail]; + } + function peg$f18(key, value) { + return { key, value }; + } + function peg$f19() { return { kind: "literal", value: null }; } + function peg$f20() { return { kind: "literal", value: true }; } + function peg$f21() { return { kind: "literal", value: false }; } + function peg$f22(value) { + return { kind: "literal", value: Number(value) }; + } + function peg$f23(value) { return { kind: "literal", value }; } + function peg$f24(chars) { return chars.join(""); } + function peg$f25(chars) { return chars.join(""); } + function peg$f26() { return "\n"; } + function peg$f27() { return "\r"; } + function peg$f28() { return "\t"; } + function peg$f29() { return '"'; } + function peg$f30() { return "\\"; } + function peg$f31(char) { return char; } + function peg$f32() { return "\n"; } + function peg$f33() { return "\r"; } + function peg$f34() { return "\t"; } + function peg$f35() { return "'"; } + function peg$f36() { return "\\"; } + function peg$f37(char) { return char; } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; + + let peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos, + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text, ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description }; + } + + function peg$computePosDetails(pos) { + let details = peg$posDetailsCache[pos]; + let p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column, + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); + + const res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column, + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column, + }, + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsestart() { + let s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parseor_expr(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f0(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseor_expr() { + let s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parseand_expr(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c0) { + s5 = peg$c0; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse__(); + if (s6 !== peg$FAILED) { + s7 = peg$parseand_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f1(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c0) { + s5 = peg$c0; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse__(); + if (s6 !== peg$FAILED) { + s7 = peg$parseand_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f1(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f2(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseand_expr() { + let s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parseequality_expr(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.substr(peg$currPos, 3) === peg$c1) { + s5 = peg$c1; + peg$currPos += 3; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse__(); + if (s6 !== peg$FAILED) { + s7 = peg$parseequality_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f3(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.substr(peg$currPos, 3) === peg$c1) { + s5 = peg$c1; + peg$currPos += 3; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse__(); + if (s6 !== peg$FAILED) { + s7 = peg$parseequality_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f3(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f4(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseequality_expr() { + let s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parseunary_expr(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c2) { + s5 = peg$c2; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s5 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c3) { + s5 = peg$c3; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseunary_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f5(s1, s5, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c2) { + s5 = peg$c2; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s5 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c3) { + s5 = peg$c3; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseunary_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f5(s1, s5, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f6(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseunary_expr() { + let s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 3) === peg$c4) { + s1 = peg$c4; + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse__(); + if (s2 !== peg$FAILED) { + s3 = peg$parseunary_expr(); + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f7(s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$parseprimary(); + } + + return s0; + } + + function peg$parseprimary() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$parsecall(); + if (s0 === peg$FAILED) { + s0 = peg$parseliteral(); + if (s0 === peg$FAILED) { + s0 = peg$parsearray(); + if (s0 === peg$FAILED) { + s0 = peg$parseobject(); + if (s0 === peg$FAILED) { + s0 = peg$parsepath(); + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 40) { + s1 = peg$c5; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + s3 = peg$parseor_expr(); + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 41) { + s5 = peg$c6; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s5 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f8(s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + } + } + } + } + + return s0; + } + + function peg$parsecall() { + let s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parseidentifier(); + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 40) { + s3 = peg$c5; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + s5 = peg$parsearg_list(); + if (s5 === peg$FAILED) { + s5 = null; + } + s6 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 41) { + s7 = peg$c6; + peg$currPos++; + } else { + s7 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s7 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f9(s1, s5); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsearg_list() { + let s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parseor_expr(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 44) { + s5 = peg$c7; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseor_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f10(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 44) { + s5 = peg$c7; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseor_expr(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f10(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f11(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsepath() { + let s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parseidentifier(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 46) { + s5 = peg$c8; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseidentifier(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f12(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 46) { + s5 = peg$c8; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseidentifier(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f12(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f13(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsearray() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 91) { + s1 = peg$c9; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + s3 = peg$parsearg_list(); + if (s3 === peg$FAILED) { + s3 = null; + } + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 93) { + s5 = peg$c10; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s5 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f14(s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseobject() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 123) { + s1 = peg$c11; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e11); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + s3 = peg$parseobject_entries(); + if (s3 === peg$FAILED) { + s3 = null; + } + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 125) { + s5 = peg$c12; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s5 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f15(s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseobject_entries() { + let s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parseobject_entry(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 44) { + s5 = peg$c7; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseobject_entry(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f16(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 44) { + s5 = peg$c7; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parseobject_entry(); + if (s7 !== peg$FAILED) { + peg$savedPos = s3; + s3 = peg$f16(s1, s7); + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f17(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseobject_entry() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parseidentifier(); + if (s1 === peg$FAILED) { + s1 = peg$parsestring_literal(); + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 58) { + s3 = peg$c13; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + s5 = peg$parseor_expr(); + if (s5 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f18(s1, s5); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseliteral() { + let s0; + + s0 = peg$parsenull_literal(); + if (s0 === peg$FAILED) { + s0 = peg$parseboolean_literal(); + if (s0 === peg$FAILED) { + s0 = peg$parsenumber_literal(); + if (s0 === peg$FAILED) { + s0 = peg$parsestring_literal_node(); + } + } + } + + return s0; + } + + function peg$parsenull_literal() { + let s0, s1; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c14) { + s1 = peg$c14; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f19(); + } + s0 = s1; + + return s0; + } + + function peg$parseboolean_literal() { + let s0, s1; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c15) { + s1 = peg$c15; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f20(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 5) === peg$c16) { + s1 = peg$c16; + peg$currPos += 5; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f21(); + } + s0 = s1; + } + + return s0; + } + + function peg$parsenumber_literal() { + let s0, s1, s2, s3, s4, s5, s6, s7, s8; + + s0 = peg$currPos; + s1 = peg$currPos; + s2 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 45) { + s3 = peg$c17; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e17); } + } + if (s3 === peg$FAILED) { + s3 = null; + } + s4 = []; + s5 = input.charAt(peg$currPos); + if (peg$r0.test(s5)) { + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + if (s5 !== peg$FAILED) { + while (s5 !== peg$FAILED) { + s4.push(s5); + s5 = input.charAt(peg$currPos); + if (peg$r0.test(s5)) { + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + } + } else { + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + s5 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 46) { + s6 = peg$c8; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s6 !== peg$FAILED) { + s7 = []; + s8 = input.charAt(peg$currPos); + if (peg$r0.test(s8)) { + peg$currPos++; + } else { + s8 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + if (s8 !== peg$FAILED) { + while (s8 !== peg$FAILED) { + s7.push(s8); + s8 = input.charAt(peg$currPos); + if (peg$r0.test(s8)) { + peg$currPos++; + } else { + s8 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + } + } else { + s7 = peg$FAILED; + } + if (s7 !== peg$FAILED) { + s6 = [s6, s7]; + s5 = s6; + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + if (s5 === peg$FAILED) { + s5 = null; + } + s3 = [s3, s4, s5]; + s2 = s3; + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s1 = input.substring(s1, peg$currPos); + } else { + s1 = s2; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f22(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsestring_literal_node() { + let s0, s1; + + s0 = peg$currPos; + s1 = peg$parsestring_literal(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f23(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsestring_literal() { + let s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c18; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parsedouble_char(); + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parsedouble_char(); + } + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c18; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f24(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 39) { + s1 = peg$c19; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parsesingle_char(); + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parsesingle_char(); + } + if (input.charCodeAt(peg$currPos) === 39) { + s3 = peg$c19; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f25(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + + return s0; + } + + function peg$parsedouble_char() { + let s0, s1, s2; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c20) { + s1 = peg$c20; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e21); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f26(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c21) { + s1 = peg$c21; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f27(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c22) { + s1 = peg$c22; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f28(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c23) { + s1 = peg$c23; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f29(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c24) { + s1 = peg$c24; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e25); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f30(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$currPos; + peg$silentFails++; + if (input.charCodeAt(peg$currPos) === 34) { + s2 = peg$c18; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + peg$silentFails--; + if (s2 === peg$FAILED) { + s1 = undefined; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + if (input.length > peg$currPos) { + s2 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f31(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + } + } + } + } + + return s0; + } + + function peg$parsesingle_char() { + let s0, s1, s2; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c20) { + s1 = peg$c20; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e21); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f32(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c21) { + s1 = peg$c21; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f33(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c22) { + s1 = peg$c22; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f34(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c25) { + s1 = peg$c25; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f35(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c24) { + s1 = peg$c24; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e25); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f36(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$currPos; + peg$silentFails++; + if (input.charCodeAt(peg$currPos) === 39) { + s2 = peg$c19; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + peg$silentFails--; + if (s2 === peg$FAILED) { + s1 = undefined; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + if (input.length > peg$currPos) { + s2 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f37(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + } + } + } + } + + return s0; + } + + function peg$parseidentifier() { + let s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = peg$currPos; + s2 = input.charAt(peg$currPos); + if (peg$r1.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + if (s2 !== peg$FAILED) { + s3 = []; + s4 = input.charAt(peg$currPos); + if (peg$r2.test(s4)) { + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = input.charAt(peg$currPos); + if (peg$r2.test(s4)) { + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + } + } + s2 = [s2, s3]; + s1 = s2; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s0 = input.substring(s0, peg$currPos); + } else { + s0 = s1; + } + + return s0; + } + + function peg$parse_() { + let s0, s1; + + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + } + + return s0; + } + + function peg$parse__() { + let s0, s1; + + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + if (s1 !== peg$FAILED) { + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + } + } else { + s0 = peg$FAILED; + } + + return s0; + } + + + function binary(op, left, right) { + return { kind: "binary", op, left, right }; + } + + peg$result = peg$startRuleFunction(); + + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } +} + +module.exports = { + StartRules: ["start"], + SyntaxError: peg$SyntaxError, + parse: peg$parse, +}; diff --git a/server/src/services/template-runtime/expr/expr-parser.peggy b/server/src/services/template-runtime/expr/expr-parser.peggy new file mode 100644 index 00000000..747606fb --- /dev/null +++ b/server/src/services/template-runtime/expr/expr-parser.peggy @@ -0,0 +1,120 @@ +{ + function binary(op, left, right) { + return { kind: "binary", op, left, right }; + } +} + +start + = _ expr:or_expr _ { return expr; } + +or_expr + = head:and_expr tail:(_ "or" __ right:and_expr { return right; })* { + return tail.reduce((left, right) => binary("or", left, right), head); + } + +and_expr + = head:equality_expr tail:(_ "and" __ right:equality_expr { return right; })* { + return tail.reduce((left, right) => binary("and", left, right), head); + } + +equality_expr + = head:unary_expr tail:(_ op:("==" / "!=") _ right:unary_expr { return { op, right }; })* { + return tail.reduce( + (left, item) => binary(item.op === "==" ? "eq" : "neq", left, item.right), + head + ); + } + +unary_expr + = "not" __ value:unary_expr { return { kind: "unary", op: "not", value }; } + / primary + +primary + = call + / literal + / array + / object + / path + / "(" _ expr:or_expr _ ")" { return expr; } + +call + = name:identifier _ "(" _ args:arg_list? _ ")" { + return { kind: "call", name, args: args ?? [] }; + } + +arg_list + = head:or_expr tail:(_ "," _ value:or_expr { return value; })* { + return [head, ...tail]; + } + +path + = head:identifier tail:(_ "." _ segment:identifier { return segment; })* { + return { kind: "path", segments: [head, ...tail] }; + } + +array + = "[" _ items:arg_list? _ "]" { + return { kind: "array", items: items ?? [] }; + } + +object + = "{" _ entries:object_entries? _ "}" { + return { kind: "object", entries: entries ?? [] }; + } + +object_entries + = head:object_entry tail:(_ "," _ entry:object_entry { return entry; })* { + return [head, ...tail]; + } + +object_entry + = key:(identifier / string_literal) _ ":" _ value:or_expr { + return { key, value }; + } + +literal + = null_literal + / boolean_literal + / number_literal + / string_literal_node + +null_literal + = "null" { return { kind: "literal", value: null }; } + +boolean_literal + = "true" { return { kind: "literal", value: true }; } + / "false" { return { kind: "literal", value: false }; } + +number_literal + = value:$("-"? [0-9]+ ("." [0-9]+)?) { + return { kind: "literal", value: Number(value) }; + } + +string_literal_node + = value:string_literal { return { kind: "literal", value }; } + +string_literal + = '"' chars:double_char* '"' { return chars.join(""); } + / "'" chars:single_char* "'" { return chars.join(""); } + +double_char + = "\\n" { return "\n"; } + / "\\r" { return "\r"; } + / "\\t" { return "\t"; } + / '\\"' { return '"'; } + / "\\\\" { return "\\"; } + / !'"' char:. { return char; } + +single_char + = "\\n" { return "\n"; } + / "\\r" { return "\r"; } + / "\\t" { return "\t"; } + / "\\'" { return "'"; } + / "\\\\" { return "\\"; } + / !"'" char:. { return char; } + +identifier + = $([a-zA-Z_] [a-zA-Z0-9_]*) + +_ = [ \t\r\n]* +__ = [ \t\r\n]+ diff --git a/server/src/services/template-runtime/expr/parser.ts b/server/src/services/template-runtime/expr/parser.ts new file mode 100644 index 00000000..17716d4e --- /dev/null +++ b/server/src/services/template-runtime/expr/parser.ts @@ -0,0 +1,17 @@ +import { runtimeError } from "../runtime/errors"; + +import parser from "./expr-parser"; + +import type { ExprAst } from "../types"; + +export function parseExpr(source: string): ExprAst { + try { + return parser.parse(source) as ExprAst; + } catch (error) { + throw runtimeError( + "EXPR_PARSE_ERROR", + error instanceof Error ? error.message : String(error), + { source } + ); + } +} diff --git a/server/src/services/template-runtime/index.ts b/server/src/services/template-runtime/index.ts new file mode 100644 index 00000000..00271064 --- /dev/null +++ b/server/src/services/template-runtime/index.ts @@ -0,0 +1,94 @@ +import { evaluateExprNode } from "./expr/evaluator"; +import { parseExpr } from "./expr/parser"; +import { createMacroRegistry, validateMacro } from "./macros/registry"; +import { assertNodeLimit, createExecutionState } from "./runtime/state"; +import { lowerTemplate, parseTemplate } from "./template/compiler"; +import { renderTemplateAst } from "./template/renderer"; +import { countExprNodes, countTemplateNodes } from "./types"; + +import type { + ExprAst, + InstructionRenderContext, + MacroRegistry, + RuntimeOptions, + RuntimeState, + TemplateAst, +} from "./types"; + +export type { + DebugTrace, + ExprAst, + ExprNode, + InstructionRenderContext, + MacroDefinition, + MacroRegistry, + RuntimeError, + RuntimeOptions, + RuntimeState, + TemplateAst, + TemplateNode, +} from "./types"; + +export function compileExpr(source: string): ExprAst { + return parseExpr(source); +} + +export function validateExpr(source: string): void { + compileExpr(source); +} + +export function evaluateExpr(params: { + source?: string; + ast?: ExprAst; + context: InstructionRenderContext; + state?: RuntimeState; + macros?: MacroRegistry; + options?: RuntimeOptions; +}): unknown { + const ast = params.ast ?? compileExpr(params.source ?? ""); + const executionState = createExecutionState({ + state: params.state, + options: params.options, + macros: params.macros, + }); + assertNodeLimit(countExprNodes(ast), executionState, "expr"); + return evaluateExprNode({ + ast, + context: params.context, + state: executionState, + macros: params.macros, + }); +} + +export function compileTemplate(source: string): TemplateAst { + return lowerTemplate(parseTemplate(source)); +} + +export function validateTemplate(source: string): void { + compileTemplate(source); +} + +export function renderTemplate(params: { + source?: string; + ast?: TemplateAst; + context: InstructionRenderContext; + state?: RuntimeState; + macros?: MacroRegistry; + options?: RuntimeOptions; +}): string { + const ast = params.ast ?? compileTemplate(params.source ?? ""); + const executionState = createExecutionState({ + state: params.state, + options: params.options, + macros: params.macros, + }); + assertNodeLimit(countTemplateNodes(ast.nodes), executionState, "template"); + return renderTemplateAst({ + ast, + context: params.context, + state: executionState, + macros: params.macros, + }); +} + +export { createMacroRegistry, validateMacro }; diff --git a/server/src/services/template-runtime/macros/macro-runtime.test.ts b/server/src/services/template-runtime/macros/macro-runtime.test.ts new file mode 100644 index 00000000..d63c3e51 --- /dev/null +++ b/server/src/services/template-runtime/macros/macro-runtime.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "vitest"; + +import { + createMacroRegistry, + evaluateExpr, + renderTemplate, + type InstructionRenderContext, + type RuntimeError, +} from "../index"; + +function makeContext(): InstructionRenderContext { + return { + char: { name: "Alice" }, + user: { name: "User" }, + chat: { title: "Chat" }, + messages: [{ role: "user", content: "uwu ara" }], + rag: {}, + art: {}, + lastUserMessage: "uwu ara", + now: new Date("2026-03-16T00:00:00.000Z").toISOString(), + }; +} + +describe("template-runtime/macros", () => { + test("invokes value and template macros with named params", () => { + const macros = createMacroRegistry([ + { + name: "detectFlags", + kind: "value", + params: [{ name: "text", required: true }], + body: '{ uwu: contains(text, "uwu", "i"), ara: contains(text, "ara", "i") }', + }, + { + name: "sceneHeader", + kind: "template", + params: [ + { name: "char", required: true }, + { name: "user", required: true }, + ], + body: "Character={{char.name}}|User={{user.name}}", + }, + ]); + + expect( + evaluateExpr({ + source: 'call("detectFlags", { text: lastUserMessage })', + context: makeContext(), + macros, + }) + ).toEqual({ + uwu: true, + ara: true, + }); + + expect( + renderTemplate({ + source: '{{ use("sceneHeader", { char: char, user: user }) }}', + context: makeContext(), + macros, + }) + ).toBe("Character=Alice|User=User"); + }); + + test("supports required and default params", () => { + const macros = createMacroRegistry([ + { + name: "greeting", + kind: "template", + params: [ + { name: "name", required: false, defaultExpr: '"Guest"' }, + ], + body: "Hello {{name}}", + }, + ]); + + expect( + renderTemplate({ + source: '{{ use("greeting", {}) }}', + context: makeContext(), + macros, + }) + ).toBe("Hello Guest"); + + expect(() => + renderTemplate({ + source: '{{ use("missingMacro", {}) }}', + context: makeContext(), + macros, + }) + ).toThrow(); + }); + + test("creates fresh local scopes and allows nested macro calls", () => { + const macros = createMacroRegistry([ + { + name: "inner", + kind: "template", + params: [{ name: "name", required: true }], + body: '{{set("name", "Inner", "local")}}<{{name}}>', + }, + { + name: "outer", + kind: "template", + params: [{ name: "name", required: true }], + body: '{{name}}-{{ use("inner", { name: name }) }}-{{name}}', + }, + ]); + + expect( + renderTemplate({ + source: '{{ use("outer", { name: "Outer" }) }}', + context: makeContext(), + macros, + }) + ).toBe("Outer--Outer"); + }); + + test("detects direct and indirect cycles", () => { + const direct = createMacroRegistry([ + { + name: "loop", + kind: "template", + params: [], + body: '{{ use("loop", {}) }}', + }, + ]); + + expect(() => + renderTemplate({ + source: '{{ use("loop", {}) }}', + context: makeContext(), + macros: direct, + }) + ).toThrow(); + + const indirect = createMacroRegistry([ + { + name: "a", + kind: "template", + params: [], + body: '{{ use("b", {}) }}', + }, + { + name: "b", + kind: "template", + params: [], + body: '{{ use("a", {}) }}', + }, + ]); + + try { + renderTemplate({ + source: '{{ use("a", {}) }}', + context: makeContext(), + macros: indirect, + }); + throw new Error("expected cycle error"); + } catch (error) { + const runtimeError = error as RuntimeError; + expect(runtimeError.code).toBe("MACRO_CYCLE"); + } + }); +}); diff --git a/server/src/services/template-runtime/macros/registry.ts b/server/src/services/template-runtime/macros/registry.ts new file mode 100644 index 00000000..4e3c5499 --- /dev/null +++ b/server/src/services/template-runtime/macros/registry.ts @@ -0,0 +1,75 @@ +import { parseExpr } from "../expr/parser"; +import { runtimeError } from "../runtime/errors"; +import { lowerTemplate, parseTemplate } from "../template/compiler"; + +import type { CompiledMacroDefinition, MacroDefinition, MacroRegistry } from "../types"; + +export function validateMacro(definition: MacroDefinition): void { + if (!definition.name || definition.name.trim().length === 0) { + throw runtimeError("INVALID_MACRO", "Macro name is required"); + } + if (definition.kind !== "value" && definition.kind !== "template") { + throw runtimeError("INVALID_MACRO", `Unsupported macro kind: ${definition.kind}`); + } + + const seen = new Set(); + for (const param of definition.params) { + if (!param.name || param.name.trim().length === 0) { + throw runtimeError("INVALID_MACRO", "Macro param name is required", { macro: definition.name }); + } + if (seen.has(param.name)) { + throw runtimeError("INVALID_MACRO", `Duplicate macro param: ${param.name}`, { + macro: definition.name, + }); + } + seen.add(param.name); + } +} + +export function createMacroRegistry(definitions: MacroDefinition[] = []): MacroRegistry { + const source = new Map(); + const compiled = new Map(); + + for (const definition of definitions) { + validateMacro(definition); + if (source.has(definition.name)) { + throw runtimeError("INVALID_MACRO", `Duplicate macro name: ${definition.name}`, { + macro: definition.name, + }); + } + source.set(definition.name, { + ...definition, + engine: "native_v1", + }); + } + + return { + definitions: source, + get(name: string) { + return source.get(name); + }, + getCompiled(name: string) { + const existing = compiled.get(name); + if (existing) return existing; + const definition = source.get(name); + if (!definition) return undefined; + + const built = + definition.kind === "value" + ? { + name: definition.name, + kind: "value" as const, + params: definition.params, + body: parseExpr(definition.body), + } + : { + name: definition.name, + kind: "template" as const, + params: definition.params, + body: lowerTemplate(parseTemplate(definition.body)), + }; + compiled.set(name, built); + return built; + }, + }; +} diff --git a/server/src/services/template-runtime/runtime/errors.ts b/server/src/services/template-runtime/runtime/errors.ts new file mode 100644 index 00000000..6a7a8687 --- /dev/null +++ b/server/src/services/template-runtime/runtime/errors.ts @@ -0,0 +1,9 @@ +import { RuntimeError, type RuntimeErrorCode } from "../types"; + +export function runtimeError( + code: RuntimeErrorCode, + message: string, + details?: Record +): RuntimeError { + return new RuntimeError(code, message, details); +} diff --git a/server/src/services/template-runtime/runtime/helpers.ts b/server/src/services/template-runtime/runtime/helpers.ts new file mode 100644 index 00000000..d391905b --- /dev/null +++ b/server/src/services/template-runtime/runtime/helpers.ts @@ -0,0 +1,136 @@ +import { runtimeError } from "./errors"; + +import type { HelperDefinition, HelperInvocation } from "../types"; + +function asString(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || typeof value === "undefined") return ""; + return String(value); +} + +function asPositiveInteger(value: unknown): number | null { + const raw = typeof value === "string" && value.trim().length > 0 ? Number(value) : value; + if (typeof raw !== "number" || !Number.isFinite(raw)) return null; + const normalized = Math.floor(raw); + return normalized > 0 ? normalized : null; +} + +function approxTokensByChars(chars: number): number { + return chars <= 0 ? 0 : Math.ceil(Math.floor(chars) / 4); +} + +function conversationalMessages(messages: Array<{ role: string; content: string }>) { + return messages.filter((message) => message.role === "user" || message.role === "assistant"); +} + +function recentMessagesByTokenLimit( + messages: Array<{ role: string; content: string }>, + tokenLimit: number +): Array<{ role: string; content: string }> { + if (tokenLimit <= 0) return []; + const selected: Array<{ role: string; content: string }> = []; + let total = 0; + + for (let idx = messages.length - 1; idx >= 0; idx -= 1) { + const message = messages[idx]; + if (!message) continue; + selected.push(message); + total += approxTokensByChars(message.content.length); + if (total >= tokenLimit) break; + } + + selected.reverse(); + return selected; +} + +function formatMessages(messages: Array<{ role: string; content: string }>): string { + return messages.map((message) => `${message.role}: ${message.content}`).join("\n"); +} + +function clampRng(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0; + if (value >= 1) return 0.999_999_999_999; + return value; +} + +const helpers: Record = { + contains: ({ args }) => { + const [text, needle, flags] = args; + const haystack = asString(text); + const rawNeedle = asString(needle); + if (rawNeedle.length === 0) return false; + if (typeof flags === "string" && flags.length > 0) { + return new RegExp(rawNeedle, flags).test(haystack); + } + return haystack.includes(rawNeedle); + }, + match: ({ args }) => { + const [text, pattern, flags] = args; + return new RegExp(asString(pattern), typeof flags === "string" ? flags : "").test( + asString(text) + ); + }, + lower: ({ args }) => asString(args[0]).toLowerCase(), + upper: ({ args }) => asString(args[0]).toUpperCase(), + trimText: ({ args }) => asString(args[0]).trim(), + size: ({ args }) => { + const value = args[0]; + if (Array.isArray(value) || typeof value === "string") return value.length; + if (value && typeof value === "object") return Object.keys(value).length; + return 0; + }, + empty: ({ args }) => { + const value = args[0]; + if (value === null || typeof value === "undefined") return true; + if (typeof value === "string") return value.length === 0; + if (Array.isArray(value)) return value.length === 0; + if (typeof value === "object") return Object.keys(value).length === 0; + return false; + }, + coalesce: ({ args }) => args.find((value) => value !== null && typeof value !== "undefined") ?? null, + json: ({ args }) => JSON.stringify(args[0]), + outlet: ({ args, context }) => { + const key = asString(args[0]); + return context.outlet?.[key] ?? ""; + }, + pickRandom: ({ args, options }) => { + if (args.length === 0) return null; + const idx = Math.floor(clampRng(options.rng()) * args.length); + return args[idx] ?? args[0] ?? null; + }, + recentMessages: ({ args, context }) => { + const count = asPositiveInteger(args[0]); + if (!count) return []; + return conversationalMessages(context.messages).slice(-count); + }, + recentMessagesText: ({ args, context }) => { + const count = asPositiveInteger(args[0]); + if (!count) return ""; + return formatMessages(conversationalMessages(context.messages).slice(-count)); + }, + recentMessagesByContextTokens: ({ args, context }) => { + const limit = asPositiveInteger(args[0]); + if (!limit) return []; + return recentMessagesByTokenLimit(conversationalMessages(context.messages), limit); + }, + recentMessagesByContextTokensText: ({ args, context }) => { + const limit = asPositiveInteger(args[0]); + if (!limit) return ""; + return formatMessages(recentMessagesByTokenLimit(conversationalMessages(context.messages), limit)); + }, +}; + +export function invokeHelper(name: string, input: HelperInvocation): unknown { + const helper = helpers[name]; + if (!helper) { + throw runtimeError("UNKNOWN_HELPER", `Unknown helper: ${name}`, { helper: name }); + } + try { + return helper(input); + } catch (error) { + if (error instanceof Error && "code" in error) throw error; + throw runtimeError("HELPER_ERROR", error instanceof Error ? error.message : String(error), { + helper: name, + }); + } +} diff --git a/server/src/services/template-runtime/runtime/limits-and-integration.test.ts b/server/src/services/template-runtime/runtime/limits-and-integration.test.ts new file mode 100644 index 00000000..6a3dad26 --- /dev/null +++ b/server/src/services/template-runtime/runtime/limits-and-integration.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "vitest"; + +import { createMacroRegistry, evaluateExpr, renderTemplate, type InstructionRenderContext, type RuntimeError } from "../index"; + +function makeContext(): InstructionRenderContext { + return { + char: { name: "Alice", title: "Captain" }, + user: { name: "User" }, + chat: { title: "Chat" }, + messages: [ + { role: "assistant", content: "A1" }, + { role: "user", content: "U1 uwu" }, + { role: "assistant", content: "A2 ara" }, + { role: "user", content: "U2 uwu ara" }, + ], + rag: {}, + art: { + note: { value: "NOTE", history: ["NOTE"] }, + }, + promptSystem: "SYS", + outlet: { + default: "OUTLET_TEXT", + }, + lastUserMessage: "U2 uwu ara", + lastAssistantMessage: "A2 ara", + now: new Date("2026-03-16T00:00:00.000Z").toISOString(), + }; +} + +describe("template-runtime/limits-and-integration", () => { + test("builds deterministic flags object from lastUserMessage", () => { + expect( + evaluateExpr({ + source: '{ uwu: contains(lastUserMessage, "uwu", "i"), ara: contains(lastUserMessage, "ara", "i") }', + context: makeContext(), + }) + ).toEqual({ + uwu: true, + ara: true, + }); + }); + + test("renders prompt-like output with current prompt fields", () => { + expect( + renderTemplate({ + source: "System={{promptSystem}}\nCharacter={{char.name}}\nLastUser={{lastUserMessage}}\nArtifact={{art.note.value}}", + context: makeContext(), + }) + ).toBe("System=SYS\nCharacter=Alice\nLastUser=U2 uwu ara\nArtifact=NOTE"); + }); + + test("supports ST-heavy templates and baseline helper semantics", () => { + expect( + renderTemplate({ + source: + "U={{lastUserMessage}}\nA={{lastAssistantMessage}}\nO={{outlet::default}}\n{{trim}}\n{{recentMessagesText(2)}}", + context: makeContext(), + }) + ).toBe( + "U=U2 uwu ara\nA=A2 ara\nO=OUTLET_TEXT\nassistant: A2 ara\nuser: U2 uwu ara" + ); + }); + + test("enforces max output chars and max macro depth", () => { + expect(() => + renderTemplate({ + source: "abcdef", + context: makeContext(), + options: { + maxOutputChars: 3, + }, + }) + ).toThrow(); + + const macros = createMacroRegistry([ + { + name: "depth1", + kind: "template", + params: [], + body: '{{ use("depth2", {}) }}', + }, + { + name: "depth2", + kind: "template", + params: [], + body: '{{ use("depth3", {}) }}', + }, + { + name: "depth3", + kind: "template", + params: [], + body: "ok", + }, + ]); + + expect(() => + renderTemplate({ + source: '{{ use("depth1", {}) }}', + context: makeContext(), + macros, + options: { + maxMacroDepth: 1, + }, + }) + ).toThrow(); + }); + + test("enforces execution-step limits and helper validation errors", () => { + expect(() => + renderTemplate({ + source: "{{#each [1,2,3,4] as item}}[{{item}}]{{/each}}", + context: makeContext(), + options: { + maxExecutionSteps: 3, + }, + }) + ).toThrow(); + + try { + evaluateExpr({ + source: 'match(lastUserMessage, "[", "i")', + context: makeContext(), + }); + throw new Error("expected helper error"); + } catch (error) { + const runtimeError = error as RuntimeError; + expect(runtimeError.code).toBe("HELPER_ERROR"); + } + }); +}); diff --git a/server/src/services/template-runtime/runtime/path.ts b/server/src/services/template-runtime/runtime/path.ts new file mode 100644 index 00000000..5a221ae1 --- /dev/null +++ b/server/src/services/template-runtime/runtime/path.ts @@ -0,0 +1,43 @@ +import { runtimeError } from "./errors"; +import { readScopedValue } from "./state"; + +import type { ExecutionState } from "./state"; +import type { InstructionRenderContext } from "../types"; + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +export function resolvePathValue(params: { + segments: string[]; + context: InstructionRenderContext; + state: ExecutionState; +}): unknown { + const [head, ...tail] = params.segments; + let current: unknown = readScopedValue(params.state, head); + + if (typeof current === "undefined") { + current = (params.context as unknown as Record)[head]; + } + + if (typeof current === "undefined") { + if (params.state.options.strictVariables) { + throw runtimeError("MISSING_VARIABLE", `Unknown variable: ${head}`, { path: params.segments }); + } + return null; + } + + for (const segment of tail) { + if (current === null || typeof current !== "object" || !hasOwn(current, segment)) { + if (params.state.options.strictVariables) { + throw runtimeError("MISSING_VARIABLE", `Unknown path: ${params.segments.join(".")}`, { + path: params.segments, + }); + } + return null; + } + current = (current as Record)[segment]; + } + + return typeof current === "undefined" ? null : current; +} diff --git a/server/src/services/template-runtime/runtime/state.ts b/server/src/services/template-runtime/runtime/state.ts new file mode 100644 index 00000000..1c4207fe --- /dev/null +++ b/server/src/services/template-runtime/runtime/state.ts @@ -0,0 +1,184 @@ +import { runtimeError } from "./errors"; + +import type { DebugTrace, MacroRegistry, RuntimeOptions, RuntimeState, StatementScope } from "../types"; + +const DEFAULT_OPTIONS = { + strictVariables: false, + maxMacroDepth: 8, + maxExecutionSteps: 2_000, + maxOutputChars: 200_000, + maxObjectNesting: 16, + maxAstNodes: 2_000, +} as const; + +export type NormalizedRuntimeOptions = Required< + Pick< + RuntimeOptions, + | "strictVariables" + | "maxMacroDepth" + | "maxExecutionSteps" + | "maxOutputChars" + | "maxObjectNesting" + | "maxAstNodes" + > +> & { + rng: () => number; + debugTrace?: DebugTrace; +}; + +export type ExecutionState = { + localScopes: Array>; + renderScope: Record; + turnScope: Record; + sessionScope?: Record; + steps: number; + macroStack: string[]; + trimSentinelUsed: boolean; + macros?: MacroRegistry; + options: NormalizedRuntimeOptions; +}; + +export const TRIM_SENTINEL = "__TS_NATIVE_TEMPLATE_TRIM__"; + +export function normalizeRuntimeOptions(options?: RuntimeOptions): NormalizedRuntimeOptions { + return { + ...DEFAULT_OPTIONS, + ...options, + rng: options?.rng ?? Math.random, + }; +} + +export function createExecutionState(params: { + state?: RuntimeState; + options?: RuntimeOptions; + macros?: MacroRegistry; +}): ExecutionState { + return { + localScopes: [structuredClone(params.state?.scopes.local ?? {})], + renderScope: structuredClone(params.state?.scopes.render ?? {}), + turnScope: structuredClone(params.state?.scopes.turn ?? {}), + sessionScope: params.state?.scopes.session + ? structuredClone(params.state.scopes.session) + : undefined, + steps: 0, + macroStack: [], + trimSentinelUsed: false, + macros: params.macros, + options: normalizeRuntimeOptions(params.options), + }; +} + +export function snapshotRuntimeState(state: ExecutionState): RuntimeState { + return { + scopes: { + local: structuredClone(state.localScopes[state.localScopes.length - 1] ?? {}), + render: structuredClone(state.renderScope), + turn: structuredClone(state.turnScope), + ...(state.sessionScope ? { session: structuredClone(state.sessionScope) } : {}), + }, + }; +} + +export function pushLocalScope(state: ExecutionState, values?: Record): void { + const next = { + ...(state.localScopes[state.localScopes.length - 1] ?? {}), + ...(values ?? {}), + }; + state.localScopes.push(next); +} + +export function popLocalScope(state: ExecutionState): void { + if (state.localScopes.length > 1) state.localScopes.pop(); +} + +export function readScopedValue(state: ExecutionState, key: string): unknown { + const local = state.localScopes[state.localScopes.length - 1]; + if (local && key in local) return local[key]; + if (key in state.renderScope) return state.renderScope[key]; + if (key in state.turnScope) return state.turnScope[key]; + if (state.sessionScope && key in state.sessionScope) return state.sessionScope[key]; + return undefined; +} + +export function writeScopedValue( + state: ExecutionState, + scope: StatementScope, + key: string, + value: unknown +): void { + if (scope === "session") { + throw runtimeError("INVALID_SCOPE", "session scope is reserved and disabled in v1", { + scope, + key, + }); + } + + if (scope === "local") { + state.localScopes[state.localScopes.length - 1][key] = value; + return; + } + if (scope === "render") { + state.renderScope[key] = value; + return; + } + state.turnScope[key] = value; +} + +export function unsetScopedValue(state: ExecutionState, scope: StatementScope, key: string): void { + if (scope === "session") { + throw runtimeError("INVALID_SCOPE", "session scope is reserved and disabled in v1", { + scope, + key, + }); + } + + if (scope === "local") { + delete state.localScopes[state.localScopes.length - 1]?.[key]; + return; + } + if (scope === "render") { + delete state.renderScope[key]; + return; + } + delete state.turnScope[key]; +} + +export function step(state: ExecutionState, label: string): void { + state.steps += 1; + state.options.debugTrace?.execution?.push(label); + if (state.steps > state.options.maxExecutionSteps) { + throw runtimeError("LIMIT_EXCEEDED", "runtime exceeded maxExecutionSteps", { + maxExecutionSteps: state.options.maxExecutionSteps, + label, + }); + } +} + +export function assertNodeLimit(count: number, state: ExecutionState, label: string): void { + if (count > state.options.maxAstNodes) { + throw runtimeError("LIMIT_EXCEEDED", `runtime exceeded maxAstNodes in ${label}`, { + count, + maxAstNodes: state.options.maxAstNodes, + }); + } +} + +export function pushMacro(state: ExecutionState, macroName: string): void { + if (state.macroStack.includes(macroName)) { + throw runtimeError("MACRO_CYCLE", `macro cycle detected for ${macroName}`, { + macroName, + stack: [...state.macroStack], + }); + } + if (state.macroStack.length >= state.options.maxMacroDepth) { + throw runtimeError("LIMIT_EXCEEDED", "runtime exceeded maxMacroDepth", { + maxMacroDepth: state.options.maxMacroDepth, + macroName, + }); + } + state.macroStack.push(macroName); +} + +export function popMacro(state: ExecutionState): void { + state.macroStack.pop(); +} diff --git a/server/src/services/template-runtime/template/compile-template.test.ts b/server/src/services/template-runtime/template/compile-template.test.ts new file mode 100644 index 00000000..8397b1a4 --- /dev/null +++ b/server/src/services/template-runtime/template/compile-template.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; + +import { compileTemplate, validateTemplate, type RuntimeError } from "../index"; + +describe("template-runtime/compileTemplate", () => { + test("parses text, output, if, and each blocks", () => { + expect(compileTemplate("Hello {{ char.name }}")).toMatchObject({ + kind: "template", + nodes: [ + { kind: "text", value: "Hello " }, + { kind: "output", expr: { kind: "path", segments: ["char", "name"] } }, + ], + }); + + expect( + compileTemplate( + "{{#if contains(lastUserMessage, \"uwu\", \"i\")}}UWU{{else}}NONE{{/if}}{{#each messages as msg}}[{{msg.role}}]{{/each}}" + ) + ).toMatchObject({ + kind: "template", + nodes: [ + { + kind: "if", + then: [{ kind: "text", value: "UWU" }], + else: [{ kind: "text", value: "NONE" }], + }, + { + kind: "each", + itemName: "msg", + }, + ], + }); + }); + + test("lowers ST aliases into canonical nodes", () => { + const ast = compileTemplate("A{{trim}}B{{outlet::default}}{{random::A::B}}{{setvar::mood::angry}}"); + + expect(ast).toMatchObject({ + kind: "template", + nodes: [ + { kind: "text", value: "A" }, + { kind: "statement", statement: { kind: "trim" } }, + { kind: "text", value: "B" }, + { kind: "output", expr: { kind: "call", name: "outlet" } }, + { kind: "output", expr: { kind: "call", name: "pickRandom" } }, + { + kind: "statement", + statement: { + kind: "set", + scope: "render", + name: "mood", + value: { kind: "literal", value: "angry" }, + }, + }, + ], + }); + }); + + test("validateTemplate accepts valid templates and rejects invalid ones", () => { + expect(() => validateTemplate("Hello {{ char.name }}")).not.toThrow(); + expect(() => validateTemplate("{{#if true}}ok{{/if}}")).not.toThrow(); + expect(() => validateTemplate("{{#if true}}")).toThrow(); + }); + + test("rejects unknown ST alias syntax", () => { + try { + compileTemplate("{{unknown::value}}"); + throw new Error("expected alias error"); + } catch (error) { + const runtimeError = error as RuntimeError; + expect(runtimeError.code).toBe("UNKNOWN_ST_ALIAS"); + } + }); +}); diff --git a/server/src/services/template-runtime/template/compiler.ts b/server/src/services/template-runtime/template/compiler.ts new file mode 100644 index 00000000..c578d44c --- /dev/null +++ b/server/src/services/template-runtime/template/compiler.ts @@ -0,0 +1,167 @@ +import { parseExpr } from "../expr/parser"; +import { runtimeError } from "../runtime/errors"; + +import parser from "./template-parser"; + +import type { + ExprNode, + RawTemplateAst, + RawTemplateNode, + StatementScope, + TemplateAst, + TemplateNode, +} from "../types"; + +const ST_ALIAS_PREFIX_RE = /^[a-zA-Z_][a-zA-Z0-9_]*::/; +const EACH_SOURCE_RE = /^(.*)\bas\s+([a-zA-Z_][a-zA-Z0-9_]*)$/s; + +export function parseTemplate(source: string): RawTemplateAst { + try { + return parser.parse(source) as RawTemplateAst; + } catch (error) { + throw runtimeError( + "TEMPLATE_PARSE_ERROR", + error instanceof Error ? error.message : String(error), + { source } + ); + } +} + +export function lowerTemplate(raw: RawTemplateAst): TemplateAst { + return { + kind: "template", + nodes: raw.nodes.map(lowerNode), + }; +} + +function lowerNode(node: RawTemplateNode): TemplateNode { + switch (node.kind) { + case "text": + return node; + case "raw_output": + return lowerOutput(node.source); + case "raw_if": + return { + kind: "if", + condition: parseExpr(node.condition), + then: node.then.map(lowerNode), + else: node.else.map(lowerNode), + }; + case "raw_each": { + const match = node.source.match(EACH_SOURCE_RE); + if (!match) { + throw runtimeError("TEMPLATE_PARSE_ERROR", `Invalid each syntax: ${node.source}`); + } + const [, source, itemName] = match; + return { + kind: "each", + source: parseExpr(source.trim()), + itemName, + body: node.body.map(lowerNode), + }; + } + } +} + +function lowerOutput(source: string): TemplateNode { + const trimmed = source.trim(); + if (trimmed === "trim") { + return { + kind: "statement", + statement: { kind: "trim" }, + }; + } + if (trimmed.startsWith("outlet::")) { + return outputCall("outlet", [literal(trimmed.slice("outlet::".length))]); + } + if (trimmed.startsWith("random::")) { + const values = trimmed + .slice("random::".length) + .split("::") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map(literal); + return outputCall("pickRandom", values); + } + if (trimmed.startsWith("setvar::")) { + const payload = trimmed.slice("setvar::".length).split("::"); + if (payload.length < 2) { + throw runtimeError("UNKNOWN_ST_ALIAS", `Unsupported ST alias: ${trimmed}`); + } + const [name, ...rawValue] = payload; + return { + kind: "statement", + statement: { + kind: "set", + scope: "render", + name, + value: literal(rawValue.join("::")), + }, + }; + } + if (ST_ALIAS_PREFIX_RE.test(trimmed)) { + throw runtimeError("UNKNOWN_ST_ALIAS", `Unsupported ST alias: ${trimmed}`); + } + + const expr = parseExpr(trimmed); + return maybeLowerStatement(expr); +} + +function maybeLowerStatement(expr: ExprNode): TemplateNode { + if (expr.kind !== "call") return { kind: "output", expr }; + if (expr.name === "set") { + const [nameNode, valueNode, scopeNode] = expr.args; + return { + kind: "statement", + statement: { + kind: "set", + scope: resolveScope(scopeNode), + name: readLiteralString(nameNode, "set"), + value: valueNode ?? literal(null), + }, + }; + } + if (expr.name === "unset") { + const [nameNode, scopeNode] = expr.args; + return { + kind: "statement", + statement: { + kind: "unset", + scope: resolveScope(scopeNode), + name: readLiteralString(nameNode, "unset"), + }, + }; + } + return { kind: "output", expr }; +} + +function resolveScope(node: ExprNode | undefined): StatementScope { + if (!node) return "render"; + const scope = readLiteralString(node, "scope"); + if (scope === "local" || scope === "render" || scope === "turn" || scope === "session") { + return scope; + } + throw runtimeError("INVALID_SCOPE", `Unsupported scope: ${scope}`, { scope }); +} + +function readLiteralString(node: ExprNode | undefined, label: string): string { + if (!node || node.kind !== "literal" || typeof node.value !== "string") { + throw runtimeError("TEMPLATE_PARSE_ERROR", `${label} requires string literal argument`); + } + return node.value; +} + +function literal(value: null | boolean | number | string): ExprNode { + return { kind: "literal", value }; +} + +function outputCall(name: string, args: ExprNode[]): TemplateNode { + return { + kind: "output", + expr: { + kind: "call", + name, + args, + }, + }; +} diff --git a/server/src/services/template-runtime/template/render-template.test.ts b/server/src/services/template-runtime/template/render-template.test.ts new file mode 100644 index 00000000..fe095576 --- /dev/null +++ b/server/src/services/template-runtime/template/render-template.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; + +import { renderTemplate, type InstructionRenderContext } from "../index"; + +function makeContext(): InstructionRenderContext { + return { + char: { name: "Alice" }, + user: { name: "User" }, + chat: { title: "Chat" }, + messages: [ + { role: "assistant", content: "A1" }, + { role: "user", content: "U1 uwu" }, + { role: "assistant", content: "A2 ara" }, + { role: "user", content: "U2 uwu ara" }, + ], + rag: {}, + art: { + note: { value: "NOTE", history: ["NOTE"] }, + }, + promptSystem: "SYS", + outlet: { + default: "OUTLET_TEXT", + }, + lastUserMessage: "U2 uwu ara", + lastAssistantMessage: "A2 ara", + now: new Date("2026-03-16T00:00:00.000Z").toISOString(), + }; +} + +describe("template-runtime/renderTemplate", () => { + test("renders text interpolation, if/else, and each", () => { + const rendered = renderTemplate({ + source: + "Hi {{ char.name }}\n{{#if contains(lastUserMessage, \"uwu\", \"i\")}}YES{{else}}NO{{/if}}\n{{#each messages as msg}}[{{msg.role}}={{msg.content}}]{{/each}}", + context: makeContext(), + }); + + expect(rendered).toBe( + "Hi Alice\nYES\n[assistant=A1][user=U1 uwu][assistant=A2 ara][user=U2 uwu ara]" + ); + }); + + test("supports set and unset statements with render scope default", () => { + const rendered = renderTemplate({ + source: + "{{set(\"mood\", \"angry\")}}Mood={{mood}}|{{unset(\"mood\")}}After={{mood}}", + context: makeContext(), + }); + + expect(rendered).toBe("Mood=angry|After="); + }); + + test("supports trim semantics around blank lines", () => { + const rendered = renderTemplate({ + source: "Start\n\n{{ wiBefore }}\n{{trim}}\n\n{{ wiAfter }}\n\nEnd", + context: { + ...makeContext(), + wiBefore: "BEFORE", + wiAfter: "AFTER", + }, + }); + + expect(rendered).toBe("Start\n\nBEFORE\nAFTER\n\nEnd"); + }); + + test("supports mixed canonical syntax and ST aliases", () => { + const rendered = renderTemplate({ + source: + "{{set(\"tone\", pickRandom(\"calm\", \"tense\"))}}T={{tone}}|O={{outlet::default}}|R={{random::A::B}}|{{setvar::mood::angry}}M={{mood}}", + context: makeContext(), + options: { + rng: () => 0, + }, + }); + + expect(rendered).toBe("T=calm|O=OUTLET_TEXT|R=A|M=angry"); + }); + + test("coerces missing values to empty string in non-strict mode and errors in strict mode", () => { + expect( + renderTemplate({ + source: "X={{missing.value}}", + context: makeContext(), + }) + ).toBe("X="); + + expect(() => + renderTemplate({ + source: "X={{missing.value}}", + context: makeContext(), + options: { + strictVariables: true, + }, + }) + ).toThrow(); + }); + + test("does not mutate input context or state", () => { + const context = makeContext(); + const messagesBefore = [...context.messages]; + const rendered = renderTemplate({ + source: "{{set(\"mood\", \"angry\")}}{{mood}}", + context, + }); + + expect(rendered).toBe("angry"); + expect(context.messages).toEqual(messagesBefore); + expect((context as unknown as Record).mood).toBeUndefined(); + }); +}); diff --git a/server/src/services/template-runtime/template/renderer.ts b/server/src/services/template-runtime/template/renderer.ts new file mode 100644 index 00000000..55cb2359 --- /dev/null +++ b/server/src/services/template-runtime/template/renderer.ts @@ -0,0 +1,234 @@ +import { evaluateExprNode } from "../expr/evaluator"; +import { parseExpr } from "../expr/parser"; +import { runtimeError } from "../runtime/errors"; +import { popLocalScope, popMacro, pushLocalScope, pushMacro, step, TRIM_SENTINEL, unsetScopedValue, writeScopedValue } from "../runtime/state"; + +import type { ExecutionState } from "../runtime/state"; +import type { CompiledMacroDefinition, InstructionRenderContext, MacroRegistry, TemplateAst, TemplateNode } from "../types"; + +function stringifyOutput(value: unknown): string { + if (value === null || typeof value === "undefined") return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function toArgRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +} + +function bindMacroParams( + macro: CompiledMacroDefinition, + providedArgs: Record, + context: InstructionRenderContext, + state: ExecutionState, + macros?: MacroRegistry +): Record { + const bound: Record = {}; + + for (const param of macro.params) { + if (param.name in providedArgs) { + bound[param.name] = providedArgs[param.name]; + continue; + } + + if (param.defaultExpr) { + bound[param.name] = evaluateExprNode({ + ast: parseExpr(param.defaultExpr), + context, + state, + macros, + }); + continue; + } + + if (param.required) { + throw runtimeError("INVALID_MACRO", `Missing required macro param: ${param.name}`, { + macro: macro.name, + param: param.name, + }); + } + + bound[param.name] = null; + } + + return bound; +} + +export function renderTemplateAst(params: { + ast: TemplateAst; + context: InstructionRenderContext; + state: ExecutionState; + macros?: MacroRegistry; +}): string { + const output = renderNodes(params.ast.nodes, params.context, params.state, params.macros).join(""); + if (output.length > params.state.options.maxOutputChars) { + throw runtimeError("LIMIT_EXCEEDED", "runtime exceeded maxOutputChars", { + maxOutputChars: params.state.options.maxOutputChars, + }); + } + return params.state.trimSentinelUsed ? stripTrimSentinel(output) : output; +} + +function renderNodes( + nodes: TemplateNode[], + context: InstructionRenderContext, + state: ExecutionState, + macros?: MacroRegistry +): string[] { + const chunks: string[] = []; + + for (const node of nodes) { + step(state, `template:${node.kind}`); + if (node.kind === "text") { + chunks.push(node.value); + continue; + } + if (node.kind === "output") { + chunks.push(renderOutput(node, context, state, macros)); + continue; + } + if (node.kind === "statement") { + const chunk = executeStatement(node, context, state, macros); + if (chunk.length > 0) chunks.push(chunk); + continue; + } + if (node.kind === "if") { + const condition = evaluateExprNode({ + ast: node.condition, + context, + state, + macros, + }); + chunks.push(...renderNodes(condition ? node.then : node.else, context, state, macros)); + continue; + } + + const iterable = evaluateExprNode({ + ast: node.source, + context, + state, + macros, + }); + if (!Array.isArray(iterable)) continue; + for (const item of iterable) { + pushLocalScope(state, { [node.itemName]: item }); + chunks.push(...renderNodes(node.body, context, state, macros)); + popLocalScope(state); + } + } + + return chunks; +} + +function renderOutput( + node: Extract, + context: InstructionRenderContext, + state: ExecutionState, + macros?: MacroRegistry +): string { + if (node.expr.kind === "call" && node.expr.name === "use") { + const args = node.expr.args.map((arg) => + evaluateExprNode({ + ast: arg, + context, + state, + macros, + }) + ); + const macroName = typeof args[0] === "string" ? args[0] : ""; + const macro = macros?.getCompiled(macroName); + if (!macro || macro.kind !== "template") { + throw runtimeError("UNKNOWN_MACRO", `Unknown template macro: ${macroName}`, { macroName }); + } + pushMacro(state, macroName); + try { + pushLocalScope(state, bindMacroParams(macro, toArgRecord(args[1]), context, state, macros)); + const rendered = renderTemplateAst({ + ast: macro.body, + context, + state, + macros, + }); + popLocalScope(state); + return rendered; + } finally { + popMacro(state); + } + } + + return stringifyOutput( + evaluateExprNode({ + ast: node.expr, + context, + state, + macros, + }) + ); +} + +function executeStatement( + node: Extract, + context: InstructionRenderContext, + state: ExecutionState, + macros?: MacroRegistry +): string { + if (node.statement.kind === "trim") { + state.trimSentinelUsed = true; + return TRIM_SENTINEL; + } + + if (node.statement.kind === "set") { + writeScopedValue( + state, + node.statement.scope, + node.statement.name, + evaluateExprNode({ + ast: node.statement.value, + context, + state, + macros, + }) + ); + return ""; + } + + unsetScopedValue(state, node.statement.scope, node.statement.name); + return ""; +} + +function isBlankLine(line: string): boolean { + return line.trim().length === 0; +} + +function stripTrimSentinel(text: string): string { + const normalized = text.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + const out: string[] = []; + let skipLeadingBlankLines = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === TRIM_SENTINEL) { + while (out.length > 0 && isBlankLine(out[out.length - 1] ?? "")) out.pop(); + skipLeadingBlankLines = true; + continue; + } + + if (line.includes(TRIM_SENTINEL)) { + const replaced = line.split(TRIM_SENTINEL).join(""); + if (!(skipLeadingBlankLines && isBlankLine(replaced))) { + out.push(replaced); + } + if (!isBlankLine(replaced)) skipLeadingBlankLines = false; + continue; + } + + if (skipLeadingBlankLines && isBlankLine(line)) continue; + out.push(line); + if (!isBlankLine(line)) skipLeadingBlankLines = false; + } + + return out.join("\n"); +} diff --git a/server/src/services/template-runtime/template/template-parser.js b/server/src/services/template-runtime/template/template-parser.js new file mode 100644 index 00000000..34bf53b3 --- /dev/null +++ b/server/src/services/template-runtime/template/template-parser.js @@ -0,0 +1,1388 @@ +/* eslint-disable */ +// @generated by Peggy 5.1.0. +// +// https://peggyjs.org/ + +"use strict"; + +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } + + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } + + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } + + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, + + any() { + return "any character"; + }, + + end() { + return "end of input"; + }, + + other(expectation) { + return expectation.description; + }, + }; + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); + + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; + } +} + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + const peg$FAILED = {}; + const peg$source = options.grammarSource; + + const peg$startRuleFunctions = { + start: peg$parsestart, + }; + let peg$startRuleFunction = peg$parsestart; + + const peg$c0 = "{{"; + const peg$c1 = "}}"; + const peg$c2 = "#if"; + const peg$c3 = "/if"; + const peg$c4 = "{{else"; + const peg$c5 = "{{ else"; + const peg$c6 = "{{/if"; + const peg$c7 = "{{ /if"; + const peg$c8 = "else"; + const peg$c9 = "#each"; + const peg$c10 = "/each"; + const peg$c11 = "{{/each"; + const peg$c12 = "{{ /each"; + + const peg$r0 = /^[ \t\r\n]/; + + const peg$e0 = peg$literalExpectation("{{", false); + const peg$e1 = peg$anyExpectation(); + const peg$e2 = peg$literalExpectation("}}", false); + const peg$e3 = peg$literalExpectation("#if", false); + const peg$e4 = peg$literalExpectation("/if", false); + const peg$e5 = peg$literalExpectation("{{else", false); + const peg$e6 = peg$literalExpectation("{{ else", false); + const peg$e7 = peg$literalExpectation("{{/if", false); + const peg$e8 = peg$literalExpectation("{{ /if", false); + const peg$e9 = peg$literalExpectation("else", false); + const peg$e10 = peg$literalExpectation("#each", false); + const peg$e11 = peg$literalExpectation("/each", false); + const peg$e12 = peg$literalExpectation("{{/each", false); + const peg$e13 = peg$literalExpectation("{{ /each", false); + const peg$e14 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false, false); + + function peg$f0(nodes) { return { kind: "template_raw", nodes }; } + function peg$f1(nodes) { return nodes; } + function peg$f2(value) { + return { kind: "text", value }; + } + function peg$f3(source) { + return { kind: "raw_output", source: (source ?? "").trim() }; + } + function peg$f4(condition, then_nodes, else_nodes) { + return { + kind: "raw_if", + condition: (condition ?? "").trim(), + then: then_nodes, + else: else_nodes ?? [], + }; + } + function peg$f5(node) { return node; } + function peg$f6(nodes) { + return nodes; + } + function peg$f7(node) { return node; } + function peg$f8(nodes) { + return nodes; + } + function peg$f9(source, body) { + return { + kind: "raw_each", + source: (source ?? "").trim(), + body, + }; + } + function peg$f10(node) { return node; } + function peg$f11(nodes) { + return nodes; + } + function peg$f12(value) { return value; } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; + + let peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos, + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text, ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description }; + } + + function peg$computePosDetails(pos) { + let details = peg$posDetailsCache[pos]; + let p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column, + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); + + const res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column, + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column, + }, + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsestart() { + let s0, s1; + + s0 = peg$currPos; + s1 = peg$parsetemplate_nodes(); + peg$savedPos = s0; + s1 = peg$f0(s1); + s0 = s1; + + return s0; + } + + function peg$parsetemplate_nodes() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parseif_block(); + if (s2 === peg$FAILED) { + s2 = peg$parseeach_block(); + if (s2 === peg$FAILED) { + s2 = peg$parseoutput_tag(); + if (s2 === peg$FAILED) { + s2 = peg$parsetext(); + } + } + } + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parseif_block(); + if (s2 === peg$FAILED) { + s2 = peg$parseeach_block(); + if (s2 === peg$FAILED) { + s2 = peg$parseoutput_tag(); + if (s2 === peg$FAILED) { + s2 = peg$parsetext(); + } + } + } + } + peg$savedPos = s0; + s1 = peg$f1(s1); + s0 = s1; + + return s0; + } + + function peg$parsetext() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$currPos; + s2 = []; + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 2) === peg$c0) { + s5 = peg$c0; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 2) === peg$c0) { + s5 = peg$c0; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s1 = input.substring(s1, peg$currPos); + } else { + s1 = s2; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f2(s1); + } + s0 = s1; + + return s0; + } + + function peg$parseoutput_tag() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + s3 = peg$parsetag_source(); + if (s3 === peg$FAILED) { + s3 = null; + } + s4 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c1) { + s5 = peg$c1; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s5 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f3(s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseif_block() { + let s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + if (input.substr(peg$currPos, 3) === peg$c2) { + s3 = peg$c2; + peg$currPos += 3; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse__(); + if (s4 !== peg$FAILED) { + s5 = peg$parsetag_source(); + if (s5 === peg$FAILED) { + s5 = null; + } + s6 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c1) { + s7 = peg$c1; + peg$currPos += 2; + } else { + s7 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s7 !== peg$FAILED) { + s8 = peg$parseif_body(); + s9 = peg$parseif_else(); + if (s9 === peg$FAILED) { + s9 = null; + } + if (input.substr(peg$currPos, 2) === peg$c0) { + s10 = peg$c0; + peg$currPos += 2; + } else { + s10 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s10 !== peg$FAILED) { + s11 = peg$parse_(); + if (input.substr(peg$currPos, 3) === peg$c3) { + s12 = peg$c3; + peg$currPos += 3; + } else { + s12 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s12 !== peg$FAILED) { + s13 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c1) { + s14 = peg$c1; + peg$currPos += 2; + } else { + s14 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s14 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f4(s5, s8, s9); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseif_body() { + let s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = []; + s2 = peg$currPos; + s3 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 6) === peg$c4) { + s4 = peg$c4; + peg$currPos += 6; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c5) { + s4 = peg$c5; + peg$currPos += 7; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 5) === peg$c6) { + s4 = peg$c6; + peg$currPos += 5; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c7) { + s4 = peg$c7; + peg$currPos += 6; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + } + } + } + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s4 = peg$parseif_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseeach_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseoutput_tag(); + if (s4 === peg$FAILED) { + s4 = peg$parsetext(); + } + } + } + if (s4 !== peg$FAILED) { + peg$savedPos = s2; + s2 = peg$f5(s4); + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$currPos; + s3 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 6) === peg$c4) { + s4 = peg$c4; + peg$currPos += 6; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c5) { + s4 = peg$c5; + peg$currPos += 7; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 5) === peg$c6) { + s4 = peg$c6; + peg$currPos += 5; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c7) { + s4 = peg$c7; + peg$currPos += 6; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + } + } + } + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s4 = peg$parseif_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseeach_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseoutput_tag(); + if (s4 === peg$FAILED) { + s4 = peg$parsetext(); + } + } + } + if (s4 !== peg$FAILED) { + peg$savedPos = s2; + s2 = peg$f5(s4); + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } + peg$savedPos = s0; + s1 = peg$f6(s1); + s0 = s1; + + return s0; + } + + function peg$parseif_else() { + let s0, s1, s2, s3, s4, s5, s6, s7, s8, s9; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + if (input.substr(peg$currPos, 4) === peg$c8) { + s3 = peg$c8; + peg$currPos += 4; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c1) { + s5 = peg$c1; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s5 !== peg$FAILED) { + s6 = []; + s7 = peg$currPos; + s8 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 5) === peg$c6) { + s9 = peg$c6; + peg$currPos += 5; + } else { + s9 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s9 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c7) { + s9 = peg$c7; + peg$currPos += 6; + } else { + s9 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + } + peg$silentFails--; + if (s9 === peg$FAILED) { + s8 = undefined; + } else { + peg$currPos = s8; + s8 = peg$FAILED; + } + if (s8 !== peg$FAILED) { + s9 = peg$parseif_block(); + if (s9 === peg$FAILED) { + s9 = peg$parseeach_block(); + if (s9 === peg$FAILED) { + s9 = peg$parseoutput_tag(); + if (s9 === peg$FAILED) { + s9 = peg$parsetext(); + } + } + } + if (s9 !== peg$FAILED) { + peg$savedPos = s7; + s7 = peg$f7(s9); + } else { + peg$currPos = s7; + s7 = peg$FAILED; + } + } else { + peg$currPos = s7; + s7 = peg$FAILED; + } + while (s7 !== peg$FAILED) { + s6.push(s7); + s7 = peg$currPos; + s8 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 5) === peg$c6) { + s9 = peg$c6; + peg$currPos += 5; + } else { + s9 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s9 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c7) { + s9 = peg$c7; + peg$currPos += 6; + } else { + s9 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + } + peg$silentFails--; + if (s9 === peg$FAILED) { + s8 = undefined; + } else { + peg$currPos = s8; + s8 = peg$FAILED; + } + if (s8 !== peg$FAILED) { + s9 = peg$parseif_block(); + if (s9 === peg$FAILED) { + s9 = peg$parseeach_block(); + if (s9 === peg$FAILED) { + s9 = peg$parseoutput_tag(); + if (s9 === peg$FAILED) { + s9 = peg$parsetext(); + } + } + } + if (s9 !== peg$FAILED) { + peg$savedPos = s7; + s7 = peg$f7(s9); + } else { + peg$currPos = s7; + s7 = peg$FAILED; + } + } else { + peg$currPos = s7; + s7 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f8(s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseeach_block() { + let s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse_(); + if (input.substr(peg$currPos, 5) === peg$c9) { + s3 = peg$c9; + peg$currPos += 5; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse__(); + if (s4 !== peg$FAILED) { + s5 = peg$parsetag_source(); + if (s5 === peg$FAILED) { + s5 = null; + } + s6 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c1) { + s7 = peg$c1; + peg$currPos += 2; + } else { + s7 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s7 !== peg$FAILED) { + s8 = peg$parseeach_body(); + if (input.substr(peg$currPos, 2) === peg$c0) { + s9 = peg$c0; + peg$currPos += 2; + } else { + s9 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s9 !== peg$FAILED) { + s10 = peg$parse_(); + if (input.substr(peg$currPos, 5) === peg$c10) { + s11 = peg$c10; + peg$currPos += 5; + } else { + s11 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e11); } + } + if (s11 !== peg$FAILED) { + s12 = peg$parse_(); + if (input.substr(peg$currPos, 2) === peg$c1) { + s13 = peg$c1; + peg$currPos += 2; + } else { + s13 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s13 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f9(s5, s8); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseeach_body() { + let s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = []; + s2 = peg$currPos; + s3 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 7) === peg$c11) { + s4 = peg$c11; + peg$currPos += 7; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c12) { + s4 = peg$c12; + peg$currPos += 8; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + } + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s4 = peg$parseif_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseeach_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseoutput_tag(); + if (s4 === peg$FAILED) { + s4 = peg$parsetext(); + } + } + } + if (s4 !== peg$FAILED) { + peg$savedPos = s2; + s2 = peg$f10(s4); + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$currPos; + s3 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 7) === peg$c11) { + s4 = peg$c11; + peg$currPos += 7; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s4 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c12) { + s4 = peg$c12; + peg$currPos += 8; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + } + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s4 = peg$parseif_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseeach_block(); + if (s4 === peg$FAILED) { + s4 = peg$parseoutput_tag(); + if (s4 === peg$FAILED) { + s4 = peg$parsetext(); + } + } + } + if (s4 !== peg$FAILED) { + peg$savedPos = s2; + s2 = peg$f10(s4); + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } + peg$savedPos = s0; + s1 = peg$f11(s1); + s0 = s1; + + return s0; + } + + function peg$parsetag_source() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$currPos; + s2 = []; + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 2) === peg$c1) { + s5 = peg$c1; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$currPos; + peg$silentFails++; + if (input.substr(peg$currPos, 2) === peg$c1) { + s5 = peg$c1; + peg$currPos += 2; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + peg$silentFails--; + if (s5 === peg$FAILED) { + s4 = undefined; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 !== peg$FAILED) { + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s1 = input.substring(s1, peg$currPos); + } else { + s1 = s2; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f12(s1); + } + s0 = s1; + + return s0; + } + + function peg$parse_() { + let s0, s1; + + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + } + + return s0; + } + + function peg$parse__() { + let s0, s1; + + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s1 !== peg$FAILED) { + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + } + } else { + s0 = peg$FAILED; + } + + return s0; + } + + peg$result = peg$startRuleFunction(); + + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } +} + +module.exports = { + StartRules: ["start"], + SyntaxError: peg$SyntaxError, + parse: peg$parse, +}; diff --git a/server/src/services/template-runtime/template/template-parser.peggy b/server/src/services/template-runtime/template/template-parser.peggy new file mode 100644 index 00000000..478e9b0f --- /dev/null +++ b/server/src/services/template-runtime/template/template-parser.peggy @@ -0,0 +1,60 @@ +start + = nodes:template_nodes { return { kind: "template_raw", nodes }; } + +template_nodes + = nodes:(if_block / each_block / output_tag / text)* { return nodes; } + +text + = value:$((!"{{" .)+) { + return { kind: "text", value }; + } + +output_tag + = "{{" _ source:tag_source? _ "}}" { + return { kind: "raw_output", source: (source ?? "").trim() }; + } + +if_block + = "{{" _ "#if" __ condition:tag_source? _ "}}" + then_nodes:if_body + else_nodes:if_else? + "{{" _ "/if" _ "}}" { + return { + kind: "raw_if", + condition: (condition ?? "").trim(), + then: then_nodes, + else: else_nodes ?? [], + }; + } + +if_body + = nodes:(!("{{else" / "{{ else" / "{{/if" / "{{ /if") node:(if_block / each_block / output_tag / text) { return node; })* { + return nodes; + } + +if_else + = "{{" _ "else" _ "}}" nodes:(!("{{/if" / "{{ /if") node:(if_block / each_block / output_tag / text) { return node; })* { + return nodes; + } + +each_block + = "{{" _ "#each" __ source:tag_source? _ "}}" + body:each_body + "{{" _ "/each" _ "}}" { + return { + kind: "raw_each", + source: (source ?? "").trim(), + body, + }; + } + +each_body + = nodes:(!("{{/each" / "{{ /each") node:(if_block / each_block / output_tag / text) { return node; })* { + return nodes; + } + +tag_source + = value:$((!"}}" .)+) { return value; } + +_ = [ \t\r\n]* +__ = [ \t\r\n]+ diff --git a/server/src/services/template-runtime/types.ts b/server/src/services/template-runtime/types.ts new file mode 100644 index 00000000..7900e882 --- /dev/null +++ b/server/src/services/template-runtime/types.ts @@ -0,0 +1,201 @@ +import type { InstructionRenderContext as BaseInstructionRenderContext } from "../chat-core/prompt-template-renderer"; + +export type InstructionRenderContext = BaseInstructionRenderContext; + +export type RuntimeErrorCode = + | "EXPR_PARSE_ERROR" + | "TEMPLATE_PARSE_ERROR" + | "UNKNOWN_ST_ALIAS" + | "UNKNOWN_HELPER" + | "UNKNOWN_MACRO" + | "HELPER_ERROR" + | "MISSING_VARIABLE" + | "INVALID_SCOPE" + | "MACRO_CYCLE" + | "LIMIT_EXCEEDED" + | "INVALID_MACRO"; + +export class RuntimeError extends Error { + code: RuntimeErrorCode; + details?: Record; + + constructor(code: RuntimeErrorCode, message: string, details?: Record) { + super(message); + this.name = "RuntimeError"; + this.code = code; + this.details = details; + } +} + +export type DebugTrace = { + parser?: unknown; + loweredAst?: unknown; + execution?: string[]; +}; + +export type RuntimeOptions = { + strictVariables?: boolean; + rng?: () => number; + maxMacroDepth?: number; + maxExecutionSteps?: number; + maxOutputChars?: number; + maxObjectNesting?: number; + maxAstNodes?: number; + debugTrace?: DebugTrace; +}; + +export type RuntimeState = { + scopes: { + local: Record; + render: Record; + turn: Record; + session?: Record; + }; +}; + +export type ExprNode = + | { kind: "literal"; value: null | boolean | number | string } + | { kind: "path"; segments: string[] } + | { kind: "call"; name: string; args: ExprNode[] } + | { kind: "array"; items: ExprNode[] } + | { kind: "object"; entries: Array<{ key: string; value: ExprNode }> } + | { kind: "unary"; op: "not"; value: ExprNode } + | { + kind: "binary"; + op: "and" | "or" | "eq" | "neq"; + left: ExprNode; + right: ExprNode; + }; + +export type ExprAst = ExprNode; + +export type StatementScope = "local" | "render" | "turn" | "session"; + +export type StatementNode = + | { kind: "trim" } + | { kind: "set"; scope: StatementScope; name: string; value: ExprNode } + | { kind: "unset"; scope: StatementScope; name: string }; + +export type TemplateNode = + | { kind: "text"; value: string } + | { kind: "output"; expr: ExprNode } + | { kind: "if"; condition: ExprNode; then: TemplateNode[]; else: TemplateNode[] } + | { kind: "each"; source: ExprNode; itemName: string; body: TemplateNode[] } + | { kind: "statement"; statement: StatementNode }; + +export type TemplateAst = { + kind: "template"; + nodes: TemplateNode[]; +}; + +export type MacroParamDefinition = { + name: string; + required: boolean; + defaultExpr?: string; +}; + +export type MacroDefinition = { + id?: string; + ownerId?: string; + name: string; + kind: "value" | "template"; + description?: string; + engine?: "native_v1"; + params: MacroParamDefinition[]; + body: string; +}; + +export type CompiledMacroDefinition = + | { + name: string; + kind: "value"; + params: MacroParamDefinition[]; + body: ExprAst; + } + | { + name: string; + kind: "template"; + params: MacroParamDefinition[]; + body: TemplateAst; + }; + +export type MacroRegistry = { + definitions: ReadonlyMap; + get(name: string): MacroDefinition | undefined; + getCompiled(name: string): CompiledMacroDefinition | undefined; +}; + +export type HelperInvocation = { + args: unknown[]; + context: InstructionRenderContext; + state: RuntimeState; + options: Required< + Pick< + RuntimeOptions, + | "strictVariables" + | "maxMacroDepth" + | "maxExecutionSteps" + | "maxOutputChars" + | "maxObjectNesting" + | "maxAstNodes" + > + > & { + rng: () => number; + debugTrace?: DebugTrace; + }; +}; + +export type HelperDefinition = (input: HelperInvocation) => unknown; + +export type RawTemplateNode = + | { kind: "text"; value: string } + | { kind: "raw_output"; source: string } + | { kind: "raw_if"; condition: string; then: RawTemplateNode[]; else: RawTemplateNode[] } + | { kind: "raw_each"; source: string; body: RawTemplateNode[] }; + +export type RawTemplateAst = { + kind: "template_raw"; + nodes: RawTemplateNode[]; +}; + +export function countExprNodes(node: ExprNode): number { + switch (node.kind) { + case "literal": + case "path": + return 1; + case "call": + return 1 + node.args.reduce((sum, arg) => sum + countExprNodes(arg), 0); + case "array": + return 1 + node.items.reduce((sum, item) => sum + countExprNodes(item), 0); + case "object": + return 1 + node.entries.reduce((sum, entry) => sum + countExprNodes(entry.value), 0); + case "unary": + return 1 + countExprNodes(node.value); + case "binary": + return 1 + countExprNodes(node.left) + countExprNodes(node.right); + } +} + +export function countTemplateNodes(nodes: TemplateNode[]): number { + return nodes.reduce((sum, node) => sum + countTemplateNode(node), 0); +} + +function countTemplateNode(node: TemplateNode): number { + switch (node.kind) { + case "text": + return 1; + case "output": + return 1 + countExprNodes(node.expr); + case "statement": + return 1 + ("value" in node.statement ? countExprNodes(node.statement.value) : 0); + case "if": + return ( + 1 + + countExprNodes(node.condition) + + countTemplateNodes(node.then) + + countTemplateNodes(node.else) + ); + case "each": + return 1 + countExprNodes(node.source) + countTemplateNodes(node.body); + } +} diff --git a/server/src/services/ui-theme/ui-theme-built-ins.test.ts b/server/src/services/ui-theme/ui-theme-built-ins.test.ts new file mode 100644 index 00000000..10b440ff --- /dev/null +++ b/server/src/services/ui-theme/ui-theme-built-ins.test.ts @@ -0,0 +1,49 @@ +import { DEFAULT_UI_THEME_PAYLOAD, UI_THEME_BUILT_IN_IDS, type UiThemePresetPayload } from "@shared/types/ui-theme"; +import { describe, expect, test } from "vitest"; + +import { resolveUiThemePresetPayload } from "./ui-theme-built-ins"; + +describe("ui theme built-ins", () => { + test("returns canonical payload for built-in presets even when stored payload is stale", () => { + const stalePayload: UiThemePresetPayload = { + ...DEFAULT_UI_THEME_PAYLOAD, + lightTokens: { + ...DEFAULT_UI_THEME_PAYLOAD.lightTokens, + "--ts-left-rail-bg": + "linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(245, 251, 255, 0.66))", + }, + darkTokens: { + ...DEFAULT_UI_THEME_PAYLOAD.darkTokens, + "--ts-left-rail-bg": + "linear-gradient(180deg, rgba(24, 28, 34, 0.9), rgba(16, 20, 25, 0.88))", + }, + }; + + const resolved = resolveUiThemePresetPayload({ + presetId: UI_THEME_BUILT_IN_IDS.default, + builtIn: true, + storedPayload: stalePayload, + }); + + expect(resolved.lightTokens["--ts-left-rail-bg"]).toBe("linear-gradient(180deg, #ffffff, #f5fbff)"); + expect(resolved.darkTokens["--ts-left-rail-bg"]).toBe("linear-gradient(180deg, #181c22, #101419)"); + }); + + test("keeps stored payload for non built-in presets", () => { + const customPayload: UiThemePresetPayload = { + ...DEFAULT_UI_THEME_PAYLOAD, + lightTokens: { + ...DEFAULT_UI_THEME_PAYLOAD.lightTokens, + "--ts-left-rail-bg": "custom-value", + }, + }; + + const resolved = resolveUiThemePresetPayload({ + presetId: "custom-theme", + builtIn: false, + storedPayload: customPayload, + }); + + expect(resolved.lightTokens["--ts-left-rail-bg"]).toBe("custom-value"); + }); +}); diff --git a/server/src/services/ui-theme/ui-theme-built-ins.ts b/server/src/services/ui-theme/ui-theme-built-ins.ts new file mode 100644 index 00000000..68cec170 --- /dev/null +++ b/server/src/services/ui-theme/ui-theme-built-ins.ts @@ -0,0 +1,28 @@ +import { BUILT_IN_UI_THEME_PRESETS, type UiThemePresetPayload } from "@shared/types/ui-theme"; + +const BUILT_IN_PAYLOAD_BY_ID = new Map( + BUILT_IN_UI_THEME_PRESETS.map((preset) => [preset.id, preset.payload]), +); + +function clonePayload(payload: UiThemePresetPayload): UiThemePresetPayload { + return { + ...payload, + lightTokens: { ...payload.lightTokens }, + darkTokens: { ...payload.darkTokens }, + typography: { ...payload.typography }, + markdown: { ...payload.markdown }, + }; +} + +export function resolveUiThemePresetPayload(params: { + presetId: string; + builtIn: boolean; + storedPayload: UiThemePresetPayload; +}): UiThemePresetPayload { + if (!params.builtIn) { + return clonePayload(params.storedPayload); + } + + const builtInPayload = BUILT_IN_PAYLOAD_BY_ID.get(params.presetId); + return builtInPayload ? clonePayload(builtInPayload) : clonePayload(params.storedPayload); +} diff --git a/server/src/services/ui-theme/ui-theme-repository.ts b/server/src/services/ui-theme/ui-theme-repository.ts index d6221be0..f5e9a385 100644 --- a/server/src/services/ui-theme/ui-theme-repository.ts +++ b/server/src/services/ui-theme/ui-theme-repository.ts @@ -1,3 +1,5 @@ +import { randomUUID as uuidv4 } from "node:crypto"; + import { BUILT_IN_UI_THEME_PRESETS, DEFAULT_UI_THEME_PAYLOAD, @@ -11,7 +13,6 @@ import { type UiThemeSettings, } from "@shared/types/ui-theme"; import { and, desc, eq, or } from "drizzle-orm"; -import { randomUUID as uuidv4 } from "node:crypto"; import { HttpError } from "@core/middleware/error-handler"; @@ -19,6 +20,7 @@ import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; import { uiThemePresets, uiThemeSettings } from "../../db/schema"; +import { resolveUiThemePresetPayload } from "./ui-theme-built-ins"; import { validateUiThemePayload } from "./ui-theme-validator"; const DEFAULT_OWNER_ID = "global"; @@ -47,7 +49,7 @@ function rowToPreset(row: UiThemePresetRow): UiThemePreset { const validatedPayload = validateUiThemePayload( safeJsonParse(row.payloadJson, BUILT_IN_UI_THEME_PRESETS[0].payload) ); - const payload: UiThemePresetPayload = { + const mergedPayload: UiThemePresetPayload = { ...validatedPayload, lightTokens: { ...DEFAULT_UI_THEME_PAYLOAD.lightTokens, @@ -58,6 +60,11 @@ function rowToPreset(row: UiThemePresetRow): UiThemePreset { ...validatedPayload.darkTokens, }, }; + const payload = resolveUiThemePresetPayload({ + presetId: row.id, + builtIn: row.builtIn, + storedPayload: mergedPayload, + }); return { presetId: row.id, ownerId: row.ownerId, diff --git a/server/src/services/world-info/world-info-bindings.test.ts b/server/src/services/world-info/world-info-bindings.test.ts new file mode 100644 index 00000000..6dc25054 --- /dev/null +++ b/server/src/services/world-info/world-info-bindings.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveActiveWorldInfoBooks } from "./world-info-bindings"; +import { + getWorldInfoBooksByIds, + listWorldInfoBindings, +} from "./world-info-repositories"; + +import type { WorldInfoBindingDto, WorldInfoBookDto } from "./world-info-types"; + +vi.mock("./world-info-repositories", () => ({ + listWorldInfoBindings: vi.fn(), + getWorldInfoBooksByIds: vi.fn(), +})); + +function makeBinding( + id: string, + patch: Partial +): WorldInfoBindingDto { + return { + id, + ownerId: "global", + scope: "global", + scopeId: null, + bookId: `${id}-book`, + bindingRole: "additional", + displayOrder: 0, + enabled: true, + meta: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...patch, + }; +} + +function makeBook(id: string): WorldInfoBookDto { + return { + id, + ownerId: "global", + slug: id, + name: id, + description: null, + data: { entries: {}, extensions: {} }, + extensions: null, + source: "native", + version: 1, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + deletedAt: null, + }; +} + +describe("resolveActiveWorldInfoBooks", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getWorldInfoBooksByIds).mockImplementation(async ({ ids }) => + ids.map((id) => makeBook(id)) + ); + }); + + it("orders active bindings by global, entity, persona, then chat", async () => { + vi.mocked(listWorldInfoBindings).mockImplementation(async ({ scope }) => { + if (scope === "global") { + return [ + makeBinding("global-2", { + scope: "global", + bookId: "book-global-2", + displayOrder: 1, + }), + makeBinding("global-1", { + scope: "global", + bookId: "book-global-1", + displayOrder: 0, + }), + ]; + } + + if (scope === "entity_profile") { + return [ + makeBinding("entity-1", { + scope: "entity_profile", + scopeId: "entity-1", + bookId: "book-entity-1", + }), + ]; + } + + if (scope === "persona") { + return [ + makeBinding("persona-1", { + scope: "persona", + scopeId: "persona-1", + bookId: "book-persona-1", + }), + ]; + } + + if (scope === "chat") { + return [ + makeBinding("chat-1", { + scope: "chat", + scopeId: "chat-1", + bookId: "book-chat-1", + }), + ]; + } + + return []; + }); + + const result = await resolveActiveWorldInfoBooks({ + ownerId: "global", + chatId: "chat-1", + entityProfileId: "entity-1", + personaId: "persona-1", + settings: { insertionStrategy: 2, characterStrategy: 0 }, + }); + + expect(result.orderedBindings.map((item) => item.bookId)).toEqual([ + "book-global-1", + "book-global-2", + "book-entity-1", + "book-persona-1", + "book-chat-1", + ]); + expect(vi.mocked(getWorldInfoBooksByIds)).toHaveBeenCalledWith({ + ownerId: "global", + ids: [ + "book-global-1", + "book-global-2", + "book-entity-1", + "book-persona-1", + "book-chat-1", + ], + }); + expect(result.orderedBooks.map((item) => item.id)).toEqual([ + "book-global-1", + "book-global-2", + "book-entity-1", + "book-persona-1", + "book-chat-1", + ]); + }); + + it("filters disabled bindings and sorts legacy multi-bindings deterministically", async () => { + vi.mocked(listWorldInfoBindings).mockImplementation(async ({ scope }) => { + if (scope === "global") { + return [ + makeBinding("late", { + scope: "global", + bookId: "book-late", + displayOrder: 3, + }), + makeBinding("same-order-b", { + scope: "global", + bookId: "book-same-order-b", + displayOrder: 1, + createdAt: new Date("2026-01-03T00:00:00.000Z"), + }), + makeBinding("disabled", { + scope: "global", + bookId: "book-disabled", + displayOrder: 0, + enabled: false, + }), + makeBinding("same-order-a", { + scope: "global", + bookId: "book-same-order-a", + displayOrder: 1, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }), + makeBinding("same-order-a-z", { + scope: "global", + bookId: "book-same-order-a-z", + displayOrder: 1, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + id: "z-id", + }), + makeBinding("same-order-a-a", { + scope: "global", + bookId: "book-same-order-a-a", + displayOrder: 1, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + id: "a-id", + }), + ]; + } + + return []; + }); + + const result = await resolveActiveWorldInfoBooks({ + ownerId: "global", + chatId: "chat-1", + entityProfileId: "entity-1", + personaId: null, + settings: { insertionStrategy: 1, characterStrategy: 2 }, + }); + + expect(result.orderedBindings.map((item) => item.bookId)).toEqual([ + "book-same-order-a-a", + "book-same-order-a", + "book-same-order-a-z", + "book-same-order-b", + "book-late", + ]); + }); +}); diff --git a/server/src/services/world-info/world-info-bindings.ts b/server/src/services/world-info/world-info-bindings.ts index 0d5e8193..4d3876b9 100644 --- a/server/src/services/world-info/world-info-bindings.ts +++ b/server/src/services/world-info/world-info-bindings.ts @@ -20,16 +20,6 @@ function sortBindings(items: WorldInfoBindingDto[]): WorldInfoBindingDto[] { }); } -function interleaveBindings(a: WorldInfoBindingDto[], b: WorldInfoBindingDto[]): WorldInfoBindingDto[] { - const result: WorldInfoBindingDto[] = []; - const maxLen = Math.max(a.length, b.length); - for (let i = 0; i < maxLen; i += 1) { - if (a[i]) result.push(a[i]); - if (b[i]) result.push(b[i]); - } - return result; -} - async function loadBindingsForScope(params: { ownerId: string; scope: WorldInfoScope; @@ -71,18 +61,12 @@ export async function resolveActiveWorldInfoBooks(params: { const personaSorted = sortBindings(personaBindings); const globalSorted = sortBindings(globalBindings); const entitySorted = sortBindings(entityBindings); - - let mixedGlobalEntity: WorldInfoBindingDto[] = []; - const insertionStrategy = params.settings.insertionStrategy ?? params.settings.characterStrategy; - if (insertionStrategy === 2) { - mixedGlobalEntity = [...globalSorted, ...entitySorted]; - } else if (insertionStrategy === 1) { - mixedGlobalEntity = [...entitySorted, ...globalSorted]; - } else { - mixedGlobalEntity = interleaveBindings(entitySorted, globalSorted); - } - - const orderedBindings = [...chatSorted, ...personaSorted, ...mixedGlobalEntity]; + const orderedBindings = [ + ...globalSorted, + ...entitySorted, + ...personaSorted, + ...chatSorted, + ]; const dedupedBindings: WorldInfoBindingDto[] = []; const seenBookIds = new Set(); for (const binding of orderedBindings) { diff --git a/server/src/services/world-info/world-info-repositories.ts b/server/src/services/world-info/world-info-repositories.ts index 88d85dff..70423a62 100644 --- a/server/src/services/world-info/world-info-repositories.ts +++ b/server/src/services/world-info/world-info-repositories.ts @@ -1,6 +1,7 @@ -import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; import { randomUUID as uuidv4 } from "node:crypto"; +import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; + import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; import { diff --git a/server/tsconfig.json b/server/tsconfig.json index aeca149a..e01df50c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -22,5 +22,5 @@ "typeRoots": ["./node_modules/@types", "../node_modules/@types", "./types"] }, "include": ["src/**/*.ts", "../shared/**/*.ts"], - "exclude": ["node_modules", "dist", "../dist", "src/legacy"] -} \ No newline at end of file + "exclude": ["node_modules", "dist", "../dist"] +} diff --git a/server/yarn.lock b/server/yarn.lock index 9ea2cbdf..02787958 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -708,6 +708,13 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@peggyjs/from-mem@3.1.3": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@peggyjs/from-mem/-/from-mem-3.1.3.tgz#2956c3c68ec2e08e55178049a327009ee61541b8" + integrity sha512-LLlgtfXIaeYXoOYovOI0spLM8ZXaqkAlmcRRrLzHJzLMqkU6Sw0R4KMoCoHx1PjaP815pSCBlS+BN6aD8t1Jgg== + dependencies: + semver "7.7.4" + "@rollup/rollup-android-arm-eabi@4.57.1": version "4.57.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" @@ -1687,6 +1694,11 @@ commander@^10.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== +commander@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + commander@^9.0.0: version "9.5.0" resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" @@ -3435,6 +3447,15 @@ pathval@^2.0.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== +peggy@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/peggy/-/peggy-5.1.0.tgz#8e2a681128cf776648417f60394a40b4a712f503" + integrity sha512-IEo5aYRZ2kXH4Qby06cjtL114PZnwLoTiA41vUmg2vPZgANn+c87m5BUurhuDr5/cu758ZlpgsAfBVx+hhO5+w== + dependencies: + "@peggyjs/from-mem" "3.1.3" + commander "^14.0.3" + source-map-generator "2.0.6" + picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -3735,6 +3756,11 @@ safe-regex-test@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +semver@7.7.4: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -3925,6 +3951,11 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +source-map-generator@2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/source-map-generator/-/source-map-generator-2.0.6.tgz#284776dff4bd5efdefead67aaed0c0f85f5a42ca" + integrity sha512-IlassDs1Ve8nV6uyQZXF9kdkJpVKnMte2JZQXu13M0A5zwc+vu6+LNHfmxsHBMDtoZE21RHiKI0/xvpecZRCNg== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" diff --git a/shared/types/app-background.ts b/shared/types/app-background.ts new file mode 100644 index 00000000..c4170a87 --- /dev/null +++ b/shared/types/app-background.ts @@ -0,0 +1,18 @@ +export type AppBackgroundSource = "builtin" | "uploaded"; + +export interface AppBackgroundAsset { + id: string; + name: string; + source: AppBackgroundSource; + imageUrl: string; + deletable: boolean; +} + +export interface AppBackgroundCatalog { + items: AppBackgroundAsset[]; + activeBackgroundId: string | null; +} + +export interface AppBackgroundActiveSelection { + activeBackgroundId: string | null; +} diff --git a/shared/types/app-settings.ts b/shared/types/app-settings.ts index 3cc35226..12b97d8e 100644 --- a/shared/types/app-settings.ts +++ b/shared/types/app-settings.ts @@ -2,6 +2,7 @@ export interface AppSettings { language: "ru" | "en"; openLastChat: boolean; autoSelectCurrentPersona: boolean; + bindChatCompletionPresetToConnection: boolean; } export interface AppSettingsResponse { diff --git a/shared/types/bundles.ts b/shared/types/bundles.ts new file mode 100644 index 00000000..43c7666b --- /dev/null +++ b/shared/types/bundles.ts @@ -0,0 +1,418 @@ +import type { InstructionMeta, StBaseConfig } from "./instructions"; +import type { OperationExecutionMode, OperationInProfile } from "./operation-profiles"; +import type { SamplerItemSettingsType } from "./samplers"; +import type { UiThemePresetPayload } from "./ui-theme"; + +export const TALESPINNER_BUNDLE_TYPE = "talespinner.bundle"; +export const TALESPINNER_BUNDLE_VERSION = 1 as const; +export const TALESPINNER_BUNDLE_ARCHIVE_MEDIA_TYPE = "application/x-talespinner-bundle"; +export const TALESPINNER_BUNDLE_ARCHIVE_EXTENSION = ".tsbundle"; + +export type TaleSpinnerBundleContainer = "json" | "archive"; +export type TaleSpinnerBundleResourceRole = "primary" | "related" | "dependency"; +export type TaleSpinnerBundleResourceKind = + | "instruction" + | "operation_block" + | "operation_profile" + | "world_info_book" + | "entity_profile" + | "ui_theme_preset" + | "sampler_preset"; + +export type BundleFileDescriptor = { + path: string; + fileName: string; + mediaType: string; + size?: number; + sha256?: string; +}; + +export type InstructionBundlePayload = + | { + name: string; + kind: "basic"; + engine: "liquidjs"; + templateText: string; + meta?: InstructionMeta; + } + | { + name: string; + kind: "st_base"; + engine: "liquidjs"; + stBase: StBaseConfig; + meta?: InstructionMeta; + }; + +export type OperationBlockBundlePayload = { + name: string; + description?: string; + enabled: boolean; + operations: OperationInProfile[]; + meta?: unknown; +}; + +export type OperationProfileBundlePayload = { + name: string; + description?: string; + enabled: boolean; + executionMode: OperationExecutionMode; + operationProfileSessionId: string; + blockRefs: Array<{ + resourceId: string; + enabled: boolean; + order: number; + }>; + meta?: unknown; +}; + +export type WorldInfoBookBundlePayload = { + name: string; + slug?: string; + description?: string | null; + data: unknown; + extensions?: unknown | null; + source?: "native" | "imported" | "converted"; +}; + +export type EntityProfileBundlePayload = { + name: string; + kind: "CharSpec"; + spec: unknown; + meta?: unknown; + isFavorite: boolean; + avatarFile?: BundleFileDescriptor; +}; + +export type UiThemePresetBundlePayload = { + name: string; + description?: string; + payload: UiThemePresetPayload; +}; + +export type SamplerPresetBundlePayload = { + name: string; + settings: SamplerItemSettingsType; +}; + +type BundleResourceBase = { + resourceId: string; + kind: TKind; + schemaVersion: 1; + role: TaleSpinnerBundleResourceRole; + title: string; + payload: TPayload; +}; + +export type InstructionBundleResource = BundleResourceBase<"instruction", InstructionBundlePayload>; +export type OperationBlockBundleResource = BundleResourceBase<"operation_block", OperationBlockBundlePayload>; +export type OperationProfileBundleResource = BundleResourceBase<"operation_profile", OperationProfileBundlePayload>; +export type WorldInfoBookBundleResource = BundleResourceBase<"world_info_book", WorldInfoBookBundlePayload>; +export type EntityProfileBundleResource = BundleResourceBase<"entity_profile", EntityProfileBundlePayload>; +export type UiThemePresetBundleResource = BundleResourceBase<"ui_theme_preset", UiThemePresetBundlePayload>; +export type SamplerPresetBundleResource = BundleResourceBase<"sampler_preset", SamplerPresetBundlePayload>; + +export type TaleSpinnerBundleResource = + | InstructionBundleResource + | OperationBlockBundleResource + | OperationProfileBundleResource + | WorldInfoBookBundleResource + | EntityProfileBundleResource + | UiThemePresetBundleResource + | SamplerPresetBundleResource; + +export type TaleSpinnerBundle = { + type: typeof TALESPINNER_BUNDLE_TYPE; + version: typeof TALESPINNER_BUNDLE_VERSION; + bundleId: string; + createdAt: string; + sourceResourceId?: string; + container: TaleSpinnerBundleContainer; + resources: TaleSpinnerBundleResource[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertString(value: unknown, message: string): asserts value is string { + assert(typeof value === "string" && value.trim().length > 0, message); +} + +function assertBoolean(value: unknown, message: string): asserts value is boolean { + assert(typeof value === "boolean", message); +} + +function assertFiniteNumber(value: unknown, message: string): asserts value is number { + assert(typeof value === "number" && Number.isFinite(value), message); +} + +function parseInstructionPayload(payload: unknown): InstructionBundlePayload { + assert(isRecord(payload), "Instruction payload must be an object."); + assertString(payload.name, "Instruction payload requires name."); + assert(payload.engine === "liquidjs", "Instruction payload requires engine=liquidjs."); + if (payload.kind === "basic") { + assertString(payload.templateText, "Basic instruction payload requires templateText."); + return { + name: payload.name, + kind: "basic", + engine: "liquidjs", + templateText: payload.templateText, + meta: isRecord(payload.meta) ? payload.meta : undefined, + }; + } + assert(payload.kind === "st_base", "Instruction payload requires a supported kind."); + assert(typeof payload.stBase !== "undefined", "st_base instruction payload requires stBase."); + return { + name: payload.name, + kind: "st_base", + engine: "liquidjs", + stBase: payload.stBase as StBaseConfig, + meta: isRecord(payload.meta) ? payload.meta : undefined, + }; +} + +function parseOperationBlockPayload(payload: unknown): OperationBlockBundlePayload { + assert(isRecord(payload), "Operation block payload must be an object."); + assertString(payload.name, "Operation block payload requires name."); + assertBoolean(payload.enabled, "Operation block payload requires enabled."); + assert(Array.isArray(payload.operations), "Operation block payload requires operations array."); + return { + name: payload.name, + description: typeof payload.description === "string" ? payload.description : undefined, + enabled: payload.enabled, + operations: payload.operations as OperationInProfile[], + meta: payload.meta, + }; +} + +function parseOperationProfilePayload(payload: unknown): OperationProfileBundlePayload { + assert(isRecord(payload), "Operation profile payload must be an object."); + assertString(payload.name, "Operation profile payload requires name."); + assertBoolean(payload.enabled, "Operation profile payload requires enabled."); + assert( + payload.executionMode === "concurrent" || payload.executionMode === "sequential", + "Operation profile payload requires a supported executionMode." + ); + assertString(payload.operationProfileSessionId, "Operation profile payload requires operationProfileSessionId."); + assert(Array.isArray(payload.blockRefs), "Operation profile payload requires blockRefs array."); + return { + name: payload.name, + description: typeof payload.description === "string" ? payload.description : undefined, + enabled: payload.enabled, + executionMode: payload.executionMode, + operationProfileSessionId: payload.operationProfileSessionId, + blockRefs: payload.blockRefs.map((ref) => { + assert(isRecord(ref), "Operation profile blockRef must be an object."); + assertString(ref.resourceId, "Operation profile blockRef requires resourceId."); + assertBoolean(ref.enabled, "Operation profile blockRef requires enabled."); + assertFiniteNumber(ref.order, "Operation profile blockRef requires finite order."); + return { + resourceId: ref.resourceId, + enabled: ref.enabled, + order: ref.order, + }; + }), + meta: payload.meta, + }; +} + +function parseBundleFileDescriptor(value: unknown): BundleFileDescriptor { + assert(isRecord(value), "Bundle file descriptor must be an object."); + assertString(value.path, "Bundle file descriptor requires path."); + assertString(value.fileName, "Bundle file descriptor requires fileName."); + assertString(value.mediaType, "Bundle file descriptor requires mediaType."); + return { + path: value.path, + fileName: value.fileName, + mediaType: value.mediaType, + size: typeof value.size === "number" ? value.size : undefined, + sha256: typeof value.sha256 === "string" ? value.sha256 : undefined, + }; +} + +function parseWorldInfoBookPayload(payload: unknown): WorldInfoBookBundlePayload { + assert(isRecord(payload), "World info payload must be an object."); + assertString(payload.name, "World info payload requires name."); + return { + name: payload.name, + slug: typeof payload.slug === "string" ? payload.slug : undefined, + description: + typeof payload.description === "string" || payload.description === null + ? payload.description + : undefined, + data: payload.data, + extensions: payload.extensions, + source: + payload.source === "native" || payload.source === "imported" || payload.source === "converted" + ? payload.source + : undefined, + }; +} + +function parseEntityProfilePayload(payload: unknown): EntityProfileBundlePayload { + assert(isRecord(payload), "Entity profile payload must be an object."); + assertString(payload.name, "Entity profile payload requires name."); + assert(payload.kind === "CharSpec", "Entity profile payload requires kind=CharSpec."); + assertBoolean(payload.isFavorite, "Entity profile payload requires isFavorite."); + return { + name: payload.name, + kind: "CharSpec", + spec: payload.spec, + meta: payload.meta, + isFavorite: payload.isFavorite, + avatarFile: typeof payload.avatarFile === "undefined" ? undefined : parseBundleFileDescriptor(payload.avatarFile), + }; +} + +function parseUiThemePresetPayload(payload: unknown): UiThemePresetBundlePayload { + assert(isRecord(payload), "UI theme preset payload must be an object."); + assertString(payload.name, "UI theme preset payload requires name."); + return { + name: payload.name, + description: typeof payload.description === "string" ? payload.description : undefined, + payload: payload.payload as UiThemePresetPayload, + }; +} + +function parseSamplerPresetPayload(payload: unknown): SamplerPresetBundlePayload { + assert(isRecord(payload), "Sampler preset payload must be an object."); + assertString(payload.name, "Sampler preset payload requires name."); + assert(isRecord(payload.settings), "Sampler preset payload requires settings object."); + return { + name: payload.name, + settings: payload.settings as SamplerItemSettingsType, + }; +} + +function parseResource(input: unknown): TaleSpinnerBundleResource { + assert(isRecord(input), "Bundle resource must be an object."); + assertString(input.resourceId, "Bundle resource requires resourceId."); + assertString(input.title, "Bundle resource requires title."); + assert(input.schemaVersion === 1, "Bundle resource requires schemaVersion=1."); + assert( + input.role === "primary" || input.role === "related" || input.role === "dependency", + "Bundle resource requires a supported role." + ); + + if (input.kind === "instruction") { + return { + resourceId: input.resourceId, + kind: "instruction", + schemaVersion: 1, + role: input.role, + title: input.title, + payload: parseInstructionPayload(input.payload), + }; + } + if (input.kind === "operation_block") { + return { + resourceId: input.resourceId, + kind: "operation_block", + schemaVersion: 1, + role: input.role, + title: input.title, + payload: parseOperationBlockPayload(input.payload), + }; + } + if (input.kind === "operation_profile") { + return { + resourceId: input.resourceId, + kind: "operation_profile", + schemaVersion: 1, + role: input.role, + title: input.title, + payload: parseOperationProfilePayload(input.payload), + }; + } + if (input.kind === "world_info_book") { + return { + resourceId: input.resourceId, + kind: "world_info_book", + schemaVersion: 1, + role: input.role, + title: input.title, + payload: parseWorldInfoBookPayload(input.payload), + }; + } + if (input.kind === "entity_profile") { + return { + resourceId: input.resourceId, + kind: "entity_profile", + schemaVersion: 1, + role: input.role, + title: input.title, + payload: parseEntityProfilePayload(input.payload), + }; + } + if (input.kind === "ui_theme_preset") { + return { + resourceId: input.resourceId, + kind: "ui_theme_preset", + schemaVersion: 1, + role: input.role, + title: input.title, + payload: parseUiThemePresetPayload(input.payload), + }; + } + if (input.kind === "sampler_preset") { + return { + resourceId: input.resourceId, + kind: "sampler_preset", + schemaVersion: 1, + role: input.role, + title: input.title, + payload: parseSamplerPresetPayload(input.payload), + }; + } + + throw new Error("Bundle resource requires a supported kind."); +} + +export function createBundleResourceId(kind: TaleSpinnerBundleResourceKind, seed: string): string { + const normalized = seed.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return `${kind}:${normalized || "resource"}`; +} + +export function parseTaleSpinnerBundle(input: unknown): TaleSpinnerBundle { + assert(isRecord(input), "Bundle must be an object."); + assert(input.type === TALESPINNER_BUNDLE_TYPE, `Bundle type must be ${TALESPINNER_BUNDLE_TYPE}.`); + assert(input.version === TALESPINNER_BUNDLE_VERSION, `Bundle version must be ${TALESPINNER_BUNDLE_VERSION}.`); + assertString(input.bundleId, "Bundle requires bundleId."); + assertString(input.createdAt, "Bundle requires createdAt."); + assert(input.container === "json" || input.container === "archive", "Bundle requires a supported container."); + assert(Array.isArray(input.resources) && input.resources.length > 0, "Bundle requires resources."); + if (typeof input.sourceResourceId !== "undefined") { + assertString(input.sourceResourceId, "sourceResourceId must be a non-empty string."); + } + + return { + type: TALESPINNER_BUNDLE_TYPE, + version: TALESPINNER_BUNDLE_VERSION, + bundleId: input.bundleId, + createdAt: input.createdAt, + sourceResourceId: typeof input.sourceResourceId === "string" ? input.sourceResourceId : undefined, + container: input.container, + resources: input.resources.map(parseResource), + }; +} + +export function validateBundleResourceGraph(bundle: TaleSpinnerBundle): void { + const resourcesById = new Map(bundle.resources.map((resource) => [resource.resourceId, resource])); + if (bundle.sourceResourceId && !resourcesById.has(bundle.sourceResourceId)) { + throw new Error(`Unknown sourceResourceId: ${bundle.sourceResourceId}`); + } + + bundle.resources.forEach((resource) => { + if (resource.kind !== "operation_profile") return; + resource.payload.blockRefs.forEach((ref) => { + const target = resourcesById.get(ref.resourceId); + if (!target || target.kind !== "operation_block") { + throw new Error(`Unknown operation_block resourceId: ${ref.resourceId}`); + } + }); + }); +} diff --git a/shared/types/chat-knowledge.ts b/shared/types/chat-knowledge.ts new file mode 100644 index 00000000..96ec81d6 --- /dev/null +++ b/shared/types/chat-knowledge.ts @@ -0,0 +1,262 @@ +export const knowledgeScopes = ["chat", "branch"] as const; +export type KnowledgeScope = (typeof knowledgeScopes)[number]; + +export const knowledgeCollectionStatuses = ["active", "archived", "deleted"] as const; +export type KnowledgeCollectionStatus = (typeof knowledgeCollectionStatuses)[number]; + +export const knowledgeRecordStatuses = ["active", "archived", "deleted"] as const; +export type KnowledgeRecordStatus = (typeof knowledgeRecordStatuses)[number]; + +export const knowledgeOrigins = ["import", "author", "system_seed", "user", "llm"] as const; +export type KnowledgeOrigin = (typeof knowledgeOrigins)[number]; + +export const knowledgeLayers = ["baseline", "runtime"] as const; +export type KnowledgeLayer = (typeof knowledgeLayers)[number]; + +export const knowledgeAccessModes = ["public", "discoverable", "hidden", "internal"] as const; +export type KnowledgeAccessMode = (typeof knowledgeAccessModes)[number]; + +export const knowledgeDiscoverStates = ["hidden", "discoverable", "visible"] as const; +export type KnowledgeDiscoverState = (typeof knowledgeDiscoverStates)[number]; + +export const knowledgeReadStates = ["blocked", "partial", "full"] as const; +export type KnowledgeReadState = (typeof knowledgeReadStates)[number]; + +export const knowledgePromptStates = ["blocked", "allowed"] as const; +export type KnowledgePromptState = (typeof knowledgePromptStates)[number]; + +export const knowledgeRevealStates = ["hidden", "revealed"] as const; +export type KnowledgeRevealState = (typeof knowledgeRevealStates)[number]; + +export const knowledgeRevealActors = ["system", "user", "llm", "import"] as const; +export type KnowledgeRevealActor = (typeof knowledgeRevealActors)[number]; + +export const knowledgeExportModes = [ + "baseline_only", + "runtime_only", + "baseline_plus_runtime", + "baseline_with_reveals", +] as const; +export type KnowledgeExportMode = (typeof knowledgeExportModes)[number]; + +export type KnowledgeGatePredicate = + | { + type: "flag_equals"; + key: string; + value: unknown; + } + | { + type: "record_revealed"; + recordId?: string; + recordKey?: string; + } + | { + type: "record_state"; + recordId?: string; + recordKey?: string; + revealState?: KnowledgeRevealState; + readState?: KnowledgeReadState; + promptState?: KnowledgePromptState; + } + | { + type: "counter_gte"; + key: string; + value: number; + } + | { + type: "manual_unlock"; + } + | { + type: "branch_only"; + branchId?: string; + }; + +export type KnowledgeGateExpression = + | { + all: KnowledgeGateNode[]; + } + | { + any: KnowledgeGateNode[]; + } + | { + not: KnowledgeGateNode; + }; + +export type KnowledgeGateNode = KnowledgeGatePredicate | KnowledgeGateExpression; + +export type KnowledgeGatePolicy = { + discover?: { mode?: "always" } | KnowledgeGateExpression; + read?: KnowledgeGateExpression; + prompt?: KnowledgeGateExpression; +}; + +export type KnowledgeCollectionDto = { + id: string; + ownerId: string; + chatId: string; + branchId: string | null; + scope: KnowledgeScope; + name: string; + kind: string | null; + description: string | null; + status: KnowledgeCollectionStatus; + origin: KnowledgeOrigin; + layer: KnowledgeLayer; + meta: unknown | null; + createdAt: Date; + updatedAt: Date; +}; + +export type KnowledgeRecordDto = { + id: string; + ownerId: string; + chatId: string; + branchId: string | null; + collectionId: string; + recordType: string; + key: string; + title: string; + aliases: string[]; + tags: string[]; + summary: string | null; + content: unknown; + searchText: string; + accessMode: KnowledgeAccessMode; + origin: KnowledgeOrigin; + layer: KnowledgeLayer; + derivedFromRecordId: string | null; + sourceMessageId: string | null; + sourceOperationId: string | null; + status: KnowledgeRecordStatus; + gatePolicy: KnowledgeGatePolicy | null; + meta: unknown | null; + createdAt: Date; + updatedAt: Date; +}; + +export type KnowledgeRecordLinkDto = { + id: string; + ownerId: string; + chatId: string; + branchId: string | null; + fromRecordId: string; + relationType: string; + toRecordId: string; + meta: unknown | null; + createdAt: Date; + updatedAt: Date; +}; + +export type KnowledgeRecordAccessStateDto = { + id: string; + ownerId: string; + chatId: string; + branchId: string | null; + recordId: string; + discoverState: KnowledgeDiscoverState; + readState: KnowledgeReadState; + promptState: KnowledgePromptState; + revealState: KnowledgeRevealState; + revealedAt: Date | null; + revealedBy: KnowledgeRevealActor | null; + revealReason: string | null; + flags: Record; + updatedAt: Date; +}; + +export type KnowledgeRuntimeContext = { + flags?: Record; + counters?: Record; + manualUnlock?: boolean; +}; + +export type KnowledgeSearchRequest = { + textQuery?: string; + keys?: string[]; + titles?: string[]; + aliases?: string[]; + tags?: string[]; + recordTypes?: string[]; + collectionIds?: string[]; + includeHiddenCandidates?: boolean; + limit?: number; + minScore?: number; + minimumShouldMatch?: number; + context?: KnowledgeRuntimeContext; +}; + +export type KnowledgeRecordPreview = { + title: string; + summary: string | null; + aliases: string[]; + tags: string[]; + recordType: string; +}; + +export type KnowledgeSearchHit = { + recordId: string; + score: number; + matchReasons: string[]; + visibility: "preview" | "full"; + preview: KnowledgeRecordPreview; + record: KnowledgeRecordDto | null; +}; + +export type KnowledgeSearchResult = { + hits: KnowledgeSearchHit[]; +}; + +export type KnowledgeRevealRequest = { + recordIds?: string[]; + recordKeys?: string[]; + reason?: string; + revealedBy?: KnowledgeRevealActor; + context?: KnowledgeRuntimeContext; +}; + +export type KnowledgeRevealItemResult = { + recordId: string; + status: "revealed" | "blocked" | "not_found"; + reason: string | null; +}; + +export type KnowledgeRevealResult = { + results: KnowledgeRevealItemResult[]; +}; + +export type KnowledgeCollectionExportPayload = { + version: 1; + collection: Omit & { + sourceCollectionId?: string; + }; + records: KnowledgeRecordDto[]; + links: KnowledgeRecordLinkDto[]; + accessState: KnowledgeRecordAccessStateDto[]; + mode: KnowledgeExportMode; +}; + +export type KnowledgeCollectionImportResult = { + collection: KnowledgeCollectionDto; + records: KnowledgeRecordDto[]; + links: KnowledgeRecordLinkDto[]; + accessState: KnowledgeRecordAccessStateDto[]; +}; + +export type KnowledgeRequestSource = + | { + mode: "inline"; + requestTemplate: string; + strictVariables?: boolean; + } + | { + mode: "artifact"; + artifactTag: string; + }; + +export type KnowledgeSearchOperationParams = { + source: KnowledgeRequestSource; +}; + +export type KnowledgeRevealOperationParams = { + source: KnowledgeRequestSource; +}; diff --git a/shared/types/instructions.ts b/shared/types/instructions.ts index da6c76ed..0ac3a2ea 100644 --- a/shared/types/instructions.ts +++ b/shared/types/instructions.ts @@ -1,26 +1,43 @@ -export type InstructionMode = "basic" | "st_advanced"; +export type InstructionKind = "basic" | "st_base"; -export type StPromptRole = "system" | "user" | "assistant"; +export type InstructionMeta = { + [key: string]: unknown; +}; + +export type StBasePromptRole = "system" | "user" | "assistant"; +export type StBasePromptInjectionPosition = 0 | 1; + +export const ST_PROMPT_INJECTION_POSITION = { + RELATIVE: 0, + IN_CHAT: 1, +} as const satisfies Record; -export type StPrompt = { +export const ST_PROMPT_DEFAULT_DEPTH = 4; +export const ST_PROMPT_DEFAULT_ORDER = 100; + +export type StBasePrompt = { identifier: string; name?: string; - role?: StPromptRole; + role?: StBasePromptRole; content?: string; system_prompt?: boolean; + marker?: boolean; + injection_position?: StBasePromptInjectionPosition; + injection_depth?: number; + injection_order?: number; }; -export type StPromptOrderEntry = { +export type StBasePromptOrderEntry = { identifier: string; enabled: boolean; }; -export type StPromptOrder = { +export type StBasePromptOrder = { character_id: number; - order: StPromptOrderEntry[]; + order: StBasePromptOrderEntry[]; }; -export type StAdvancedResponseConfig = { +export type StBaseResponseConfig = { temperature?: number; top_p?: number; top_k?: number; @@ -38,27 +55,30 @@ export type StAdvancedResponseConfig = { stream_openai?: boolean; }; -export type StAdvancedImportInfo = { +export type StBaseImportInfo = { source: "sillytavern"; fileName: string; importedAt: string; }; -export type StAdvancedConfig = { +export type StBaseConfig = { rawPreset: Record; - prompts: StPrompt[]; - promptOrder: StPromptOrder[]; - responseConfig: StAdvancedResponseConfig; - importInfo: StAdvancedImportInfo; + prompts: StBasePrompt[]; + promptOrder: StBasePromptOrder[]; + responseConfig: StBaseResponseConfig; + importInfo: StBaseImportInfo; }; -export type TsInstructionMetaV1 = { - version: 1; - mode: InstructionMode; - stAdvanced?: StAdvancedConfig | null; +export type BasicInstruction = { + kind: "basic"; + templateText: string; + meta?: InstructionMeta | null; }; -export type InstructionMeta = { - tsInstruction?: TsInstructionMetaV1; - [key: string]: unknown; +export type StBaseInstruction = { + kind: "st_base"; + stBase: StBaseConfig; + meta?: InstructionMeta | null; }; + +export type InstructionDefinition = BasicInstruction | StBaseInstruction; diff --git a/shared/types/operation-profiles.ts b/shared/types/operation-profiles.ts index c1fa0fa3..22f9dc4a 100644 --- a/shared/types/operation-profiles.ts +++ b/shared/types/operation-profiles.ts @@ -1,9 +1,16 @@ +import type { + KnowledgeRevealOperationParams, + KnowledgeSearchOperationParams, +} from "./chat-knowledge"; + export type OperationHook = "before_main_llm" | "after_main_llm"; export type OperationTrigger = "generate" | "regenerate"; export type OperationExecutionMode = "concurrent" | "sequential"; +export type ArtifactFormat = "text" | "markdown" | "json"; export type ArtifactPersistence = "persisted" | "run_only"; +export type ArtifactWriteMode = "replace" | "append"; export type ArtifactUsage = "prompt_only" | "ui_only" | "prompt+ui" | "internal"; export type ArtifactSemantics = | "state" @@ -15,22 +22,85 @@ export type ArtifactSemantics = export type OperationKind = | "template" | "llm" + | "guard" + | "knowledge_search" + | "knowledge_reveal" | "rag" | "tool" | "compute" - | "transform" - | "legacy"; + | "transform"; export type PromptTimeMessageRole = "system" | "user" | "assistant"; -export type PromptTimeEffect = +export type PromptPartExposure = { + type: "prompt_part"; + target: "system"; + mode: "prepend" | "append" | "replace"; + source?: string; +}; + +export type PromptMessageExposure = + | { + type: "prompt_message"; + role: PromptTimeMessageRole; + anchor: "after_last_user"; + source?: string; + } + | { + type: "prompt_message"; + role: PromptTimeMessageRole; + anchor: "depth_from_end"; + depthFromEnd: number; + source?: string; + }; + +export type TurnRewriteExposure = { + type: "turn_rewrite"; + target: "current_user_main" | "assistant_output_main"; + mode: "replace"; +}; + +export type UiInlineExposure = + | { + type: "ui_inline"; + role: PromptTimeMessageRole; + anchor: "after_last_user"; + source?: string; + } + | { + type: "ui_inline"; + role: PromptTimeMessageRole; + anchor: "depth_from_end"; + depthFromEnd: number; + source?: string; + }; + +export type ArtifactExposure = + | PromptPartExposure + | PromptMessageExposure + | TurnRewriteExposure + | UiInlineExposure; + +export type OperationArtifactConfig = { + artifactId: string; + tag: string; + title: string; + description?: string; + format: ArtifactFormat; + persistence: ArtifactPersistence; + writeMode: ArtifactWriteMode; + history: { + enabled: boolean; + maxItems: number; + }; + semantics?: ArtifactSemantics; + exposures: ArtifactExposure[]; +}; + +export type LegacyPromptTimeEffect = | { kind: "append_after_last_user"; role: PromptTimeMessageRole; - /** - * Optional human-readable label to help debug where the injected message came from, - * e.g. "art.world_state" or "template_output". - */ source?: string; } | { @@ -40,59 +110,47 @@ export type PromptTimeEffect = } | { kind: "insert_at_depth"; - /** - * 0 = insert at tail; N = insert N messages from the end (closer to tail). - */ depthFromEnd: number; role: PromptTimeMessageRole; source?: string; }; -export type TurnCanonicalizationEffect = { - /** - * Minimal v2 UI placeholder for canonicalization. - * Current implementation is intentionally narrow and may expand with `effects` contract docs. - */ +export type LegacyTurnCanonicalizationEffect = { kind: "replace_text"; - /** - * What part of the current turn to rewrite. - * - user: current user message (before_main_llm also allowed) - * - assistant: selected assistant variant (after_main_llm only) - */ target: "user" | "assistant"; }; -export type ArtifactWriteTarget = { - /** - * Invariant: to validate single-writer-per-tag at save-time, every operation must - * explicitly declare where it writes its output. - * - * `tag` is stored without the `art.` prefix. - */ +export type LegacyArtifactWriteTarget = { tag: string; persistence: ArtifactPersistence; usage: ArtifactUsage; semantics: ArtifactSemantics; }; -export type OperationOutput = +export type LegacyOperationOutput = | { type: "artifacts"; - writeArtifact: ArtifactWriteTarget; + writeArtifact: LegacyArtifactWriteTarget; } | { type: "prompt_time"; - promptTime: PromptTimeEffect; + promptTime: LegacyPromptTimeEffect; } | { type: "turn_canonicalization"; - canonicalization: TurnCanonicalizationEffect; + canonicalization: LegacyTurnCanonicalizationEffect; }; export type OperationTemplateParams = { template: string; strictVariables?: boolean; - output: OperationOutput; + artifact: OperationArtifactConfig; +}; + +export type OperationTemplateLegacyParams = { + template: string; + strictVariables?: boolean; + output: LegacyOperationOutput; }; export type LlmOperationRetryOn = "timeout" | "provider_error" | "rate_limit"; @@ -145,16 +203,71 @@ export type LlmOperationParams = { retry?: LlmOperationRetry; }; +export type GuardEngine = "liquid" | "aux_llm"; + +export type GuardOutputDefinition = { + key: string; + title: string; + description?: string; +}; + +export type GuardOutputContract = GuardOutputDefinition[]; + +export type GuardLiquidParams = { + engine: "liquid"; + outputContract: GuardOutputContract; + template: string; + strictVariables?: boolean; + artifact: OperationArtifactConfig; +}; + +export type GuardAuxLlmParams = { + engine: "aux_llm"; + outputContract: GuardOutputContract; + providerId: "openrouter" | "openai_compatible"; + credentialRef: string; + model?: string; + system?: string; + prompt: string; + strictVariables?: boolean; + samplers?: LlmOperationSamplers; + timeoutMs?: number; + retry?: LlmOperationRetry; + artifact: OperationArtifactConfig; +}; + +export type GuardOperationParams = GuardLiquidParams | GuardAuxLlmParams; + export type OperationOtherKindParams = Record> = { - /** - * Draft UI for non-template operations: - * kind-specific params live here as a plain JSON object. - */ params: TParams; - output: OperationOutput; + artifact: OperationArtifactConfig; }; -export type OperationParams = OperationTemplateParams | OperationOtherKindParams; +export type OperationOtherKindLegacyParams = Record> = { + params: TParams; + output: LegacyOperationOutput; +}; + +export type OperationRunCondition = + | { + type: "guard_output"; + sourceOpId: string; + outputKey: string; + operator: "is_true"; + } + | { + type: "guard_output"; + sourceOpId: string; + outputKey: string; + operator: "is_false"; + }; + +export type OperationParams = + | OperationTemplateParams + | OperationGuardOperationParamsCompat + | OperationOtherKindParams; + +type OperationGuardOperationParamsCompat = GuardOperationParams; export type OperationActivationConfig = { everyNTurns?: number; @@ -168,12 +281,13 @@ export type OperationConfig = triggers?: OperationTrigger[]; activation?: OperationActivationConfig; order: number; - dependsOn?: string[]; // list of opId + dependsOn?: string[]; + runConditions?: OperationRunCondition[]; params: TParams; }; export type TemplateOperationInProfile = { - opId: string; // UUID + opId: string; name: string; description?: string; kind: "template"; @@ -181,29 +295,59 @@ export type TemplateOperationInProfile = { }; export type LlmOperationInProfile = { - opId: string; // UUID + opId: string; name: string; description?: string; kind: "llm"; config: OperationConfig>; }; +export type GuardOperationInProfile = { + opId: string; + name: string; + description?: string; + kind: "guard"; + config: OperationConfig; +}; + +export type KnowledgeSearchOperationInProfile = { + opId: string; + name: string; + description?: string; + kind: "knowledge_search"; + config: OperationConfig>; +}; + +export type KnowledgeRevealOperationInProfile = { + opId: string; + name: string; + description?: string; + kind: "knowledge_reveal"; + config: OperationConfig>; +}; + export type GenericNonTemplateOperationInProfile = { - opId: string; // UUID + opId: string; name: string; description?: string; - kind: Exclude; + kind: Exclude< + OperationKind, + "template" | "llm" | "guard" | "knowledge_search" | "knowledge_reveal" + >; config: OperationConfig; }; export type NonTemplateOperationInProfile = | LlmOperationInProfile + | GuardOperationInProfile + | KnowledgeSearchOperationInProfile + | KnowledgeRevealOperationInProfile | GenericNonTemplateOperationInProfile; export type OperationInProfile = TemplateOperationInProfile | NonTemplateOperationInProfile; export type OperationBlock = { - blockId: string; // UUID + blockId: string; ownerId: string; name: string; description?: string; @@ -233,24 +377,20 @@ export type OperationBlockExport = { }; export type OperationProfileBlockRef = { - blockId: string; // UUID + blockId: string; enabled: boolean; order: number; }; export type OperationProfile = { - profileId: string; // UUID + profileId: string; ownerId: string; name: string; description?: string; enabled: boolean; executionMode: OperationExecutionMode; - operationProfileSessionId: string; // UUID (resettable) + operationProfileSessionId: string; version: number; - /** - * Runtime-only flattened operations snapshot. - * Persisted profile data uses `blockRefs`. - */ operations?: OperationInProfile[]; blockRefs: OperationProfileBlockRef[]; meta: unknown | null; @@ -287,7 +427,6 @@ export type OperationProfileExportV2 = { }; export type OperationProfileLegacyExportV1 = { - // `profileId` is intentionally optional on import. profileId?: string; name: string; description?: string; @@ -305,3 +444,317 @@ export type OperationProfileSettings = { updatedAt: Date; }; +const DEFAULT_ARTIFACT_HISTORY_MAX_ITEMS = 20; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizePositiveInteger(value: unknown, fallback: number): number { + const parsed = asFiniteNumber(value); + if (typeof parsed === "undefined") return fallback; + return Math.max(1, Math.floor(parsed)); +} + +function normalizeNonNegativeInteger(value: unknown, fallback = 0): number { + const parsed = asFiniteNumber(value); + if (typeof parsed === "undefined") return fallback; + return Math.max(0, Math.floor(Math.abs(parsed))); +} + +function normalizePromptRole(value: unknown): PromptTimeMessageRole { + if (value === "system" || value === "user" || value === "assistant") return value; + if (value === "developer") return "system"; + return "system"; +} + +export function buildOperationArtifactId(opId: string): string { + return `artifact:${opId}`; +} + +function sanitizeArtifactTagSegment(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); +} + +export function buildDefaultArtifactTag(params: { opId: string; title?: string }): string { + const fromTitle = typeof params.title === "string" ? sanitizeArtifactTagSegment(params.title) : ""; + if (/^[a-z][a-z0-9_]*$/.test(fromTitle)) return fromTitle; + + const suffix = params.opId.replace(/[^a-zA-Z0-9]/g, "").toLowerCase().slice(0, 8) || "item"; + return `artifact_${suffix}`; +} + +function normalizeArtifactTag(value: unknown, fallback: string): string { + const normalized = typeof value === "string" ? sanitizeArtifactTagSegment(value) : ""; + return /^[a-z][a-z0-9_]*$/.test(normalized) ? normalized : fallback; +} + +export function inferDefaultArtifactFormat(params: { + kind: OperationKind; + llmOutputMode?: "text" | "json"; +}): ArtifactFormat { + if (params.kind === "guard") return "json"; + if (params.kind === "llm" && params.llmOutputMode === "json") return "json"; + return "markdown"; +} + +export function makeDefaultOperationArtifactConfig(params: { + opId: string; + kind: OperationKind; + llmOutputMode?: "text" | "json"; + title?: string; +}): OperationArtifactConfig { + return { + artifactId: buildOperationArtifactId(params.opId), + tag: buildDefaultArtifactTag({ opId: params.opId, title: params.title }), + title: params.title?.trim() || `Artifact ${params.opId.slice(0, 8)}`, + format: inferDefaultArtifactFormat({ + kind: params.kind, + llmOutputMode: params.llmOutputMode, + }), + persistence: "run_only", + writeMode: "replace", + history: { + enabled: true, + maxItems: DEFAULT_ARTIFACT_HISTORY_MAX_ITEMS, + }, + exposures: [], + }; +} + +function normalizeExposure(raw: unknown): ArtifactExposure | null { + if (!isRecord(raw) || typeof raw.type !== "string") return null; + + if (raw.type === "prompt_part") { + const mode = + raw.mode === "prepend" || raw.mode === "append" || raw.mode === "replace" + ? raw.mode + : "append"; + return { + type: "prompt_part", + target: "system", + mode, + ...(asString(raw.source)?.trim() ? { source: asString(raw.source)!.trim() } : {}), + }; + } + + if (raw.type === "prompt_message" || raw.type === "ui_inline") { + const anchor = + raw.anchor === "depth_from_end" || raw.anchor === "after_last_user" + ? raw.anchor + : "after_last_user"; + const base = { + type: raw.type, + role: normalizePromptRole(raw.role), + ...(asString(raw.source)?.trim() ? { source: asString(raw.source)!.trim() } : {}), + } as const; + if (anchor === "depth_from_end") { + return { + ...base, + anchor, + depthFromEnd: normalizeNonNegativeInteger(raw.depthFromEnd), + } as ArtifactExposure; + } + return { + ...base, + anchor, + } as ArtifactExposure; + } + + if (raw.type === "turn_rewrite") { + return { + type: "turn_rewrite", + target: + raw.target === "assistant_output_main" ? "assistant_output_main" : "current_user_main", + mode: "replace", + }; + } + + return null; +} + +function legacyOutputToArtifactConfig(params: { + opId: string; + kind: OperationKind; + title?: string; + llmOutputMode?: "text" | "json"; + output: LegacyOperationOutput; +}): OperationArtifactConfig { + const base = makeDefaultOperationArtifactConfig({ + opId: params.opId, + kind: params.kind, + llmOutputMode: params.llmOutputMode, + title: params.title, + }); + + if (params.output.type === "artifacts") { + return { + ...base, + tag: normalizeArtifactTag(params.output.writeArtifact.tag, base.tag), + persistence: params.output.writeArtifact.persistence, + semantics: params.output.writeArtifact.semantics, + }; + } + + if (params.output.type === "prompt_time") { + if (params.output.promptTime.kind === "system_update") { + return { + ...base, + exposures: [ + { + type: "prompt_part", + target: "system", + mode: params.output.promptTime.mode, + ...(params.output.promptTime.source + ? { source: params.output.promptTime.source } + : {}), + }, + ], + }; + } + + if (params.output.promptTime.kind === "append_after_last_user") { + return { + ...base, + exposures: [ + { + type: "prompt_message", + role: params.output.promptTime.role, + anchor: "after_last_user", + ...(params.output.promptTime.source + ? { source: params.output.promptTime.source } + : {}), + }, + ], + }; + } + + return { + ...base, + exposures: [ + { + type: "prompt_message", + role: params.output.promptTime.role, + anchor: "depth_from_end", + depthFromEnd: normalizeNonNegativeInteger(params.output.promptTime.depthFromEnd), + ...(params.output.promptTime.source ? { source: params.output.promptTime.source } : {}), + }, + ], + }; + } + + return { + ...base, + exposures: [ + { + type: "turn_rewrite", + target: + params.output.canonicalization.target === "assistant" + ? "assistant_output_main" + : "current_user_main", + mode: "replace", + }, + ], + }; +} + +export function normalizeOperationArtifactConfig(params: { + opId: string; + kind: OperationKind; + title?: string; + rawParams: unknown; +}): OperationArtifactConfig { + const llmOutputMode = + isRecord(params.rawParams) && + isRecord(params.rawParams.params) && + params.rawParams.params.outputMode === "json" + ? "json" + : "text"; + const fallback = makeDefaultOperationArtifactConfig({ + opId: params.opId, + kind: params.kind, + llmOutputMode, + title: params.title, + }); + + if (!isRecord(params.rawParams)) { + return fallback; + } + + if (isRecord(params.rawParams.artifact)) { + const artifact = params.rawParams.artifact; + const exposures = Array.isArray(artifact.exposures) + ? artifact.exposures + .map((item) => normalizeExposure(item)) + .filter((item): item is ArtifactExposure => Boolean(item)) + : []; + const rawArtifactId = asString(artifact.artifactId)?.trim(); + const rawTag = asString(artifact.tag)?.trim(); + const migratedLegacyTag = !rawTag && rawArtifactId && !rawArtifactId.startsWith("artifact:") ? rawArtifactId : undefined; + return { + artifactId: + rawArtifactId && !migratedLegacyTag ? rawArtifactId : fallback.artifactId, + tag: normalizeArtifactTag(rawTag ?? migratedLegacyTag, fallback.tag), + title: asString(artifact.title)?.trim() || fallback.title, + description: asString(artifact.description)?.trim() || undefined, + format: + artifact.format === "text" || artifact.format === "markdown" || artifact.format === "json" + ? artifact.format + : fallback.format, + persistence: + artifact.persistence === "persisted" || artifact.persistence === "run_only" + ? artifact.persistence + : fallback.persistence, + writeMode: + artifact.writeMode === "append" || artifact.writeMode === "replace" + ? artifact.writeMode + : fallback.writeMode, + history: { + enabled: asBoolean(isRecord(artifact.history) ? artifact.history.enabled : undefined) ?? true, + maxItems: normalizePositiveInteger( + isRecord(artifact.history) ? artifact.history.maxItems : undefined, + fallback.history.maxItems + ), + }, + semantics: asString(artifact.semantics)?.trim() || undefined, + exposures, + }; + } + + if (isRecord(params.rawParams.output) && typeof params.rawParams.output.type === "string") { + return legacyOutputToArtifactConfig({ + opId: params.opId, + kind: params.kind, + title: params.title, + llmOutputMode, + output: params.rawParams.output as LegacyOperationOutput, + }); + } + + if (isRecord(params.rawParams.writeArtifact)) { + return legacyOutputToArtifactConfig({ + opId: params.opId, + kind: params.kind, + title: params.title, + llmOutputMode, + output: { + type: "artifacts", + writeArtifact: params.rawParams.writeArtifact as LegacyArtifactWriteTarget, + }, + }); + } + + return fallback; +} diff --git a/shared/types/ui-theme.ts b/shared/types/ui-theme.ts index 1249b133..abeaf4fd 100644 --- a/shared/types/ui-theme.ts +++ b/shared/types/ui-theme.ts @@ -81,7 +81,7 @@ const LIGHT_TOKENS_BASE: UiThemeTokenMap = { "--ts-shadow-strong": "0 18px 36px rgba(6, 23, 34, 0.28)", "--ts-body-bg-grad-a": "rgba(20, 69, 84, 0.18)", "--ts-body-bg-grad-b": "rgba(18, 56, 71, 0.22)", - "--ts-left-rail-bg": "linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(245, 251, 255, 0.66))", + "--ts-left-rail-bg": "linear-gradient(180deg, #ffffff, #f5fbff)", "--ts-left-rail-inset-shadow": "inset -1px 0 0 rgba(255, 255, 255, 0.3)", "--ts-chat-window-overlay-top": "rgba(9, 24, 34, 0.56)", "--ts-chat-window-overlay-mid": "rgba(8, 18, 27, 0.36)", @@ -141,7 +141,7 @@ const DARK_TOKENS_BASE: UiThemeTokenMap = { "--ts-shadow-strong": "0 18px 40px rgba(0, 0, 0, 0.46)", "--ts-body-bg-grad-a": "rgba(55, 68, 84, 0.18)", "--ts-body-bg-grad-b": "rgba(39, 52, 66, 0.2)", - "--ts-left-rail-bg": "linear-gradient(180deg, rgba(24, 28, 34, 0.9), rgba(16, 20, 25, 0.88))", + "--ts-left-rail-bg": "linear-gradient(180deg, #181c22, #101419)", "--ts-left-rail-inset-shadow": "inset -1px 0 0 rgba(255, 255, 255, 0.05)", "--ts-chat-window-overlay-top": "rgba(10, 13, 18, 0.72)", "--ts-chat-window-overlay-mid": "rgba(9, 12, 17, 0.56)", diff --git a/shared/utils/sillytavern-preset.ts b/shared/utils/sillytavern-preset.ts new file mode 100644 index 00000000..175afdb1 --- /dev/null +++ b/shared/utils/sillytavern-preset.ts @@ -0,0 +1,87 @@ +const SILLY_TAVERN_ROOT_HINT_KEYS = [ + "chat_completion_source", + "openai_model", + "claude_model", + "openrouter_model", + "google_model", + "mistralai_model", + "custom_model", + "custom_url", + "temperature", + "openai_max_tokens", +] as const; + +export const SILLY_TAVERN_PROMPT_IDENTIFIER_PATTERN = /^[A-Za-z0-9._-]+$/; + +export const SILLY_TAVERN_PREFERRED_CHARACTER_ID = 100001; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn( + value: Record, + key: string +): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isFiniteInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && Number.isFinite(value); +} + +function isValidPromptObject(value: unknown): boolean { + return isPlainObject(value) && isNonEmptyString(value.identifier); +} + +function isValidPromptOrderObject(value: unknown): boolean { + return isPlainObject(value) && isFiniteInteger(value.character_id); +} + +export function getSillyTavernPresetValidationError(input: unknown): string | null { + if (!isPlainObject(input)) { + return "Preset must be a plain JSON object."; + } + + if (input.type === "talespinner.instruction") { + return "TaleSpinner instruction export is not a SillyTavern preset."; + } + + if (!Array.isArray(input.prompts)) { + return 'SillyTavern preset must contain a "prompts" array.'; + } + + if (!Array.isArray(input.prompt_order)) { + return 'SillyTavern preset must contain a "prompt_order" array.'; + } + + if (!input.prompts.some(isValidPromptObject)) { + return 'SillyTavern preset must contain at least one prompt with a non-empty "identifier".'; + } + + if (!input.prompt_order.some(isValidPromptOrderObject)) { + return 'SillyTavern preset must contain at least one prompt_order item with an integer "character_id".'; + } + + const hasRootHint = SILLY_TAVERN_ROOT_HINT_KEYS.some((key) => hasOwn(input, key)); + if (!hasRootHint) { + return "SillyTavern preset must contain at least one known root hint field."; + } + + return null; +} + +export function isSillyTavernPreset( + input: unknown +): input is Record { + return getSillyTavernPresetValidationError(input) === null; +} + +export function isValidSillyTavernPromptIdentifier(identifier: string): boolean { + return SILLY_TAVERN_PROMPT_IDENTIFIER_PATTERN.test(identifier); +} + diff --git a/shared/utils/st-prompts.ts b/shared/utils/st-prompts.ts new file mode 100644 index 00000000..cc2180b7 --- /dev/null +++ b/shared/utils/st-prompts.ts @@ -0,0 +1,156 @@ +import { + ST_PROMPT_DEFAULT_DEPTH, + ST_PROMPT_DEFAULT_ORDER, + ST_PROMPT_INJECTION_POSITION, +} from "../types/instructions"; + +import type { + StBasePrompt, + StBasePromptInjectionPosition, + StBasePromptOrder, + StBasePromptRole, +} from "../types/instructions"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toNonNegativeInteger( + value: unknown, + fallback: number +): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return fallback; + } + return Math.floor(value); +} + +export function normalizeStPromptRole( + value: unknown +): StBasePromptRole | undefined { + if (value === "system" || value === "user" || value === "assistant") { + return value; + } + return undefined; +} + +export function normalizeStPromptInjectionPosition( + value: unknown +): StBasePromptInjectionPosition { + return value === ST_PROMPT_INJECTION_POSITION.IN_CHAT + ? ST_PROMPT_INJECTION_POSITION.IN_CHAT + : ST_PROMPT_INJECTION_POSITION.RELATIVE; +} + +export function normalizeStPrompt( + value: unknown +): StBasePrompt | null { + if (!isRecord(value)) return null; + const identifier = asString(value.identifier); + if (!identifier) return null; + + const prompt: StBasePrompt = { + identifier, + injection_position: normalizeStPromptInjectionPosition( + value.injection_position + ), + injection_depth: toNonNegativeInteger( + value.injection_depth, + ST_PROMPT_DEFAULT_DEPTH + ), + injection_order: toNonNegativeInteger( + value.injection_order, + ST_PROMPT_DEFAULT_ORDER + ), + }; + + const name = asString(value.name); + if (name) prompt.name = name; + + const role = normalizeStPromptRole(value.role); + if (role) prompt.role = role; + + if (typeof value.content === "string") prompt.content = value.content; + if (typeof value.system_prompt === "boolean") { + prompt.system_prompt = value.system_prompt; + } + if (typeof value.marker === "boolean") { + prompt.marker = value.marker; + } + + return prompt; +} + +export function normalizeStPrompts(input: unknown): StBasePrompt[] { + if (!Array.isArray(input)) return []; + return input + .map(normalizeStPrompt) + .filter((item): item is StBasePrompt => Boolean(item)); +} + +export function cloneStPromptWithDefaults(prompt: StBasePrompt): StBasePrompt { + return { + ...prompt, + injection_position: normalizeStPromptInjectionPosition( + prompt.injection_position + ), + injection_depth: toNonNegativeInteger( + prompt.injection_depth, + ST_PROMPT_DEFAULT_DEPTH + ), + injection_order: toNonNegativeInteger( + prompt.injection_order, + ST_PROMPT_DEFAULT_ORDER + ), + }; +} + +export function normalizeStPromptOrderEntry( + value: unknown +): { identifier: string; enabled: boolean } | null { + if (!isRecord(value)) return null; + const identifier = asString(value.identifier); + if (!identifier) return null; + return { + identifier, + enabled: typeof value.enabled === "boolean" ? value.enabled : true, + }; +} + +export function normalizeStPromptOrderItem( + value: unknown +): StBasePromptOrder | null { + if (!isRecord(value)) return null; + const rawCharacterId = value.character_id; + const characterId = + typeof rawCharacterId === "number" && Number.isFinite(rawCharacterId) + ? Math.floor(rawCharacterId) + : null; + if (characterId === null) return null; + + const rawOrder = Array.isArray(value.order) ? value.order : []; + const order = rawOrder + .map(normalizeStPromptOrderEntry) + .filter( + (entry): entry is { identifier: string; enabled: boolean } => + Boolean(entry) + ); + + return { + character_id: characterId, + order, + }; +} + +export function normalizeStPromptOrder(input: unknown): StBasePromptOrder[] { + if (!Array.isArray(input)) return []; + return input + .map(normalizeStPromptOrderItem) + .filter((item): item is StBasePromptOrder => Boolean(item)); +} diff --git a/web/package.json b/web/package.json index 173f0bd7..e5c71d7f 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,8 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --no-warnings ./node_modules/vitest/vitest.mjs run --silent=passed-only --reporter=dot", + "test:watch": "node --no-warnings ./node_modules/vitest/vitest.mjs", "typecheck": "tsc -b", "build": "tsc -b && vite build", "lint": "eslint .", @@ -41,6 +43,7 @@ "dayjs": "^1.11.19", "effector": "^23.4.4", "effector-react": "^23.3.0", + "elkjs": "^0.11.1", "embla-carousel": "8.5.2", "embla-carousel-react": "8.5.2", "handlebars": "^4.7.8", @@ -80,6 +83,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.53.0", "vite": "^7.3.1", - "vite-tsconfig-paths": "^6.0.4" + "vite-tsconfig-paths": "^6.0.4", + "vitest": "3" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 02aa3a52..17c3c3f6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { Box, Button, Flex, Stack, Text } from '@mantine/core'; import { useUnit } from 'effector-react'; import { useTranslation } from 'react-i18next'; +import { $activeAppBackgroundUrl } from '@model/app-backgrounds'; import { $appInitError, $appInitPending, $isAppReady, appStarted } from '@model/app-init'; import { $currentEntityProfile, createEntityProfileFx } from '@model/chat-core'; @@ -11,7 +12,7 @@ import { LeftBar } from './features/sidebars/left-bar'; function App() { const { t } = useTranslation(); - const currentProfile = useUnit($currentEntityProfile); + const [currentProfile, activeBackgroundUrl] = useUnit([$currentEntityProfile, $activeAppBackgroundUrl]); const [isAppReady, isAppInitPending, appInitError, retryInit] = useUnit([ $isAppReady, $appInitPending, @@ -47,7 +48,10 @@ function App() { return ( <> - + diff --git a/web/src/api/api-error.test.ts b/web/src/api/api-error.test.ts new file mode 100644 index 00000000..6c9ba1de --- /dev/null +++ b/web/src/api/api-error.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import i18n from '../i18n'; + +import { getApiErrorMessage } from './api-error'; + +describe('getApiErrorMessage', () => { + beforeEach(async () => { + await i18n.changeLanguage('en'); + }); + + it('formats flat zod issues into a readable message', () => { + const message = getApiErrorMessage( + { + error: { + message: 'Validation error', + code: 'VALIDATION_ERROR', + details: { + issues: [ + { + path: ['operations', 0, 'config', 'activation'], + message: 'activation must include at least one interval', + }, + ], + }, + }, + }, + 400, + ); + + expect(message).toBe('Operation #1 activation: must include at least one interval'); + }); + + it('formats operation validation issues with readable operation fields and keeps all issues', () => { + const message = getApiErrorMessage( + { + error: { + message: 'Validation error', + code: 'VALIDATION_ERROR', + details: { + issues: [ + { + path: ['operations', 0, 'config', 'params', 'params', 'credentialRef'], + message: 'Too small: expected string to have >=1 characters', + }, + { + path: ['operations', 0, 'config', 'params', 'params', 'prompt'], + message: 'Too small: expected string to have >=1 characters', + }, + { + path: ['operations', 0, 'config', 'params', 'artifact', 'tag'], + message: 'tag must match ^[a-z][a-z0-9_]*$', + }, + { + path: ['operations', 0, 'config', 'params', 'artifact', 'title'], + message: 'Too small: expected string to have >=1 characters', + }, + ], + }, + }, + }, + 400, + ); + + expect(message).toBe( + 'Operation #1 LLM token: required; Operation #1 prompt: required; Operation #1 artifact tag: must match ^[a-z][a-z0-9_]*$; Operation #1 artifact title: required', + ); + }); + + it('localizes readable operation validation labels', async () => { + await i18n.changeLanguage('ru'); + + const message = getApiErrorMessage( + { + error: { + message: 'Validation error', + code: 'VALIDATION_ERROR', + details: { + issues: [ + { + path: ['operations', 1, 'config', 'params', 'params', 'credentialRef'], + message: 'Too small: expected string to have >=1 characters', + }, + ], + }, + }, + }, + 400, + ); + + expect(message).toBe('Операция #2 LLM токен: обязательное поле'); + }); + + it('formats request validation issues grouped by source', () => { + const message = getApiErrorMessage( + { + error: { + message: 'Validation error', + code: 'VALIDATION_ERROR', + details: { + issues: [ + { + source: 'body', + issues: [ + { + path: ['input', 'name'], + message: 'Too small: expected string to have >=1 characters', + }, + ], + }, + ], + }, + }, + }, + 400, + ); + + expect(message).toBe('body.input.name: required'); + }); + + it('keeps specific non-validation messages', () => { + const message = getApiErrorMessage( + { + error: { + message: 'Unknown blockId in profile', + code: 'VALIDATION_ERROR', + details: { + blockId: 'missing-block', + }, + }, + }, + 400, + ); + + expect(message).toBe('Unknown blockId in profile'); + }); +}); diff --git a/web/src/api/api-error.ts b/web/src/api/api-error.ts new file mode 100644 index 00000000..cffb43c2 --- /dev/null +++ b/web/src/api/api-error.ts @@ -0,0 +1,204 @@ +import i18n from '../i18n'; + +type ApiErrorEnvelope = { + error?: { + message?: string; + code?: string; + details?: unknown; + }; +}; + +type ApiIssue = { + path?: unknown[]; + message?: string; +}; + +type ApiIssueGroup = { + source?: string; + issues?: ApiIssue[]; +}; + +const GENERIC_VALIDATION_MESSAGE = 'validation error'; +const REQUIRED_STRING_MESSAGE = 'Too small: expected string to have >=1 characters'; + +export function getApiErrorMessage(body: ApiErrorEnvelope | undefined, status: number): string { + const fallbackMessage = body?.error?.message ?? `HTTP error ${status}`; + const validationDetails = formatValidationDetails(body?.error?.details); + + if (!validationDetails) { + return fallbackMessage; + } + + const serverMessage = body?.error?.message?.trim(); + if (!serverMessage || serverMessage.toLowerCase() === GENERIC_VALIDATION_MESSAGE) { + return validationDetails; + } + + if (serverMessage.includes(validationDetails)) { + return serverMessage; + } + + return `${serverMessage}: ${validationDetails}`; +} + +function formatValidationDetails(details: unknown): string | null { + if (!isRecord(details) || !Array.isArray(details.issues) || details.issues.length === 0) { + return null; + } + + const formattedIssues = details.issues + .flatMap((issue) => { + if (isIssueGroup(issue)) { + return (issue.issues ?? []).map((nestedIssue) => formatIssue(nestedIssue, issue.source)); + } + + if (isIssue(issue)) { + return [formatIssue(issue)]; + } + + return []; + }) + .filter((issue): issue is string => typeof issue === 'string' && issue.length > 0); + + if (formattedIssues.length === 0) { + return null; + } + + return formattedIssues.join('; '); +} + +function formatIssue(issue: ApiIssue, source?: string): string | null { + const message = formatIssueMessage(issue); + if (!message) { + return null; + } + + const path = formatIssuePath(issue.path, source); + return path ? `${path}: ${message}` : message; +} + +function formatIssuePath(path: unknown[] | undefined, source?: string): string | null { + const operationPath = formatOperationIssuePath(path); + if (operationPath) return operationPath; + + let result = typeof source === 'string' && source.length > 0 ? source : ''; + + for (const segment of path ?? []) { + if (typeof segment === 'number' && Number.isInteger(segment)) { + result += `[${segment}]`; + continue; + } + + if (typeof segment === 'string' && segment.length > 0) { + result += result ? `.${segment}` : segment; + } + } + + return result || null; +} + +function formatOperationIssuePath(path: unknown[] | undefined): string | null { + if (!Array.isArray(path)) return null; + const operationsIndex = path.findIndex((segment) => segment === 'operations'); + const operationNumber = path[operationsIndex + 1]; + if (operationsIndex < 0 || typeof operationNumber !== 'number' || !Number.isInteger(operationNumber)) { + return null; + } + + const fieldPath = path.slice(operationsIndex + 2); + const fieldLabel = formatOperationFieldPath(fieldPath); + const operationLabel = t('operation', 'Operation #{{number}}', { number: operationNumber + 1 }); + return fieldLabel ? `${operationLabel} ${fieldLabel}` : operationLabel; +} + +function formatOperationFieldPath(path: unknown[]): string { + const normalized = normalizeOperationFieldPath(path); + const key = normalized.join('.'); + const label = OPERATION_FIELD_LABELS[key]; + if (label) return t(`fields.${label.key}`, label.fallback); + return normalized.map(formatFieldSegment).join(' '); +} + +function normalizeOperationFieldPath(path: unknown[]): string[] { + const result: string[] = []; + for (let index = 0; index < path.length; index += 1) { + const segment = path[index]; + if (typeof segment === 'number' && Number.isInteger(segment)) { + result.push(`#${segment + 1}`); + continue; + } + if (typeof segment !== 'string' || segment.length === 0) continue; + if (segment === 'config') continue; + if (segment === 'params') continue; + result.push(segment); + } + return result; +} + +function formatFieldSegment(segment: string): string { + if (segment.startsWith('#')) return segment; + return segment + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .toLowerCase(); +} + +function formatIssueMessage(issue: ApiIssue): string { + const raw = typeof issue.message === 'string' ? issue.message.trim() : ''; + if (!raw) return ''; + if (raw === REQUIRED_STRING_MESSAGE) return t('required', 'required'); + + const leaf = Array.isArray(issue.path) ? issue.path[issue.path.length - 1] : undefined; + if (typeof leaf === 'string' && raw.toLowerCase().startsWith(`${formatFieldSegment(leaf)} must `)) { + return raw.slice(formatFieldSegment(leaf).length + 1); + } + return raw; +} + +function t(key: string, fallback: string, options?: Record): string { + return i18n.t(`operationProfiles.validation.${key}`, { + defaultValue: fallback, + ...(options ?? {}), + }); +} + +const OPERATION_FIELD_LABELS: Record = { + activation: { key: 'activation', fallback: 'activation' }, + dependsOn: { key: 'dependsOn', fallback: 'dependencies' }, + hooks: { key: 'hooks', fallback: 'hooks' }, + name: { key: 'name', fallback: 'name' }, + order: { key: 'order', fallback: 'order' }, + required: { key: 'required', fallback: 'required flag' }, + runConditions: { key: 'runConditions', fallback: 'run conditions' }, + triggers: { key: 'triggers', fallback: 'triggers' }, + credentialRef: { key: 'credentialRef', fallback: 'LLM token' }, + model: { key: 'model', fallback: 'model' }, + prompt: { key: 'prompt', fallback: 'prompt' }, + system: { key: 'system', fallback: 'system prompt' }, + jsonSchema: { key: 'jsonSchema', fallback: 'JSON schema' }, + jsonCustomPattern: { key: 'jsonCustomPattern', fallback: 'JSON regex pattern' }, + jsonCustomFlags: { key: 'jsonCustomFlags', fallback: 'JSON regex flags' }, + 'artifact.artifactId': { key: 'artifactId', fallback: 'artifact id' }, + 'artifact.description': { key: 'artifactDescription', fallback: 'artifact description' }, + 'artifact.exposures': { key: 'artifactExposures', fallback: 'artifact exposures' }, + 'artifact.format': { key: 'artifactFormat', fallback: 'artifact format' }, + 'artifact.history.enabled': { key: 'artifactHistoryEnabled', fallback: 'artifact history enabled' }, + 'artifact.history.maxItems': { key: 'artifactHistoryMaxItems', fallback: 'artifact history limit' }, + 'artifact.persistence': { key: 'artifactPersistence', fallback: 'artifact persistence' }, + 'artifact.semantics': { key: 'artifactSemantics', fallback: 'artifact semantics' }, + 'artifact.tag': { key: 'artifactTag', fallback: 'artifact tag' }, + 'artifact.title': { key: 'artifactTitle', fallback: 'artifact title' }, + 'artifact.writeMode': { key: 'artifactWriteMode', fallback: 'artifact write mode' }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isIssue(value: unknown): value is ApiIssue { + return isRecord(value); +} + +function isIssueGroup(value: unknown): value is ApiIssueGroup { + return isRecord(value) && Array.isArray(value.issues); +} diff --git a/web/src/api/app-backgrounds.ts b/web/src/api/app-backgrounds.ts new file mode 100644 index 00000000..c202ad2b --- /dev/null +++ b/web/src/api/app-backgrounds.ts @@ -0,0 +1,61 @@ +import { BASE_URL } from "../const"; + +import { apiJson } from "./api-json"; + +import type { + AppBackgroundActiveSelection, + AppBackgroundAsset, + AppBackgroundCatalog, +} from "@shared/types/app-background"; + +type ApiEnvelope = { data: T; error?: unknown }; + +export const APP_BACKGROUND_BACKEND_ORIGIN = BASE_URL.replace(/\/api\/?$/, ""); + +export async function listAppBackgrounds(): Promise { + return apiJson("/app-backgrounds"); +} + +export async function importAppBackground(file: File): Promise { + const form = new FormData(); + form.append("image", file); + + const response = await fetch(`${BASE_URL}/app-backgrounds/import`, { + method: "POST", + body: form, + }); + + const body = (await response.json().catch(() => ({}))) as Partial> & { + error?: { message?: string }; + }; + + if (!response.ok) { + throw new Error(body?.error?.message ?? `HTTP error ${response.status}`); + } + + return body.data as AppBackgroundAsset; +} + +export async function setActiveAppBackground( + activeBackgroundId: string | null +): Promise { + return apiJson("/app-backgrounds/active", { + method: "PUT", + body: JSON.stringify({ activeBackgroundId }), + }); +} + +export async function deleteAppBackground( + id: string +): Promise { + return apiJson( + `/app-backgrounds/${encodeURIComponent(id)}`, + { + method: "DELETE", + } + ); +} + +export function toAbsoluteAppBackgroundUrl(imageUrl: string): string { + return imageUrl.startsWith("http") ? imageUrl : `${APP_BACKGROUND_BACKEND_ORIGIN}${imageUrl}`; +} diff --git a/web/src/api/bundles.ts b/web/src/api/bundles.ts new file mode 100644 index 00000000..4d201f2d --- /dev/null +++ b/web/src/api/bundles.ts @@ -0,0 +1,107 @@ +import { BASE_URL } from "../const"; + +import type { TaleSpinnerBundleResourceKind } from "@shared/types/bundles"; + +type ApiEnvelope = { data: T; error?: unknown }; + +export type BundleSelectionHandle = { + kind: TaleSpinnerBundleResourceKind; + id: string; +}; + +export type BundleImportResult = { + sourceResourceId?: string; + created: { + instructions: Array<{ resourceId: string; id: string; name: string }>; + operationBlocks: Array<{ resourceId: string; blockId: string; name: string }>; + operationProfiles: Array<{ resourceId: string; profileId: string; name: string }>; + worldInfoBooks: Array<{ resourceId: string; id: string; name: string }>; + entityProfiles: Array<{ resourceId: string; id: string; name: string }>; + uiThemePresets: Array<{ resourceId: string; presetId: string; name: string }>; + samplerPresets: Array<{ resourceId: string; presetId: string; name: string }>; + }; + applied: { + instructionId: string | null; + operationProfileId: string | null; + uiThemePresetId: string | null; + samplerPresetId: string | null; + entityProfileId: string | null; + worldInfoBookId: string | null; + }; + skippedApply: Array<{ + kind: "instruction" | "operation_profile" | "ui_theme_preset" | "sampler_preset" | "entity_profile" | "world_info_book"; + reason: "ambiguous"; + message: string; + }>; + warnings: string[]; +}; + +function sanitizeFileName(input: string): string { + return input.replace(/[\\/:*?"<>|]+/g, "_").trim(); +} + +export async function exportBundle(input: { + ownerId?: string; + source: BundleSelectionHandle; + selections: BundleSelectionHandle[]; + format?: "json" | "archive" | "auto"; +}): Promise<{ blob: Blob; filename: string; contentType: string }> { + const res = await fetch(`${BASE_URL}/bundles/export`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as Partial> & { + error?: { message?: string }; + }; + throw new Error(body?.error?.message ?? `HTTP error ${res.status}`); + } + + const blob = await res.blob(); + const contentType = res.headers.get("content-type") ?? blob.type ?? "application/octet-stream"; + const disposition = res.headers.get("content-disposition") ?? ""; + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + const plainMatch = disposition.match(/filename="?([^"]+)"?/i); + const rawName = utf8Match?.[1] + ? decodeURIComponent(utf8Match[1]) + : plainMatch?.[1] ?? `talespinner-bundle.${contentType.includes("json") ? "json" : "tsbundle"}`; + + return { + blob, + filename: sanitizeFileName(rawName), + contentType, + }; +} + +export async function importBundle(file: File): Promise { + const form = new FormData(); + form.append("file", file); + + const res = await fetch(`${BASE_URL}/bundles/import`, { + method: "POST", + body: form, + }); + + const body = (await res.json().catch(() => ({}))) as Partial> & { + error?: { message?: string }; + }; + if (!res.ok) { + throw new Error(body?.error?.message ?? `HTTP error ${res.status}`); + } + return body.data as BundleImportResult; +} + +export function downloadBlobFile(filename: string, blob: Blob): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/web/src/api/chat-core.ts b/web/src/api/chat-core.ts index 6860ac95..d2fe3ca4 100644 --- a/web/src/api/chat-core.ts +++ b/web/src/api/chat-core.ts @@ -1,5 +1,7 @@ import { BASE_URL } from '../const'; +import { getApiErrorMessage } from './api-error'; + import type { OperationBlock, OperationBlockExport, @@ -24,11 +26,11 @@ async function apiJson(path: string, init?: RequestInit): Promise { }); const body = (await res.json().catch(() => ({}))) as Partial> & { - error?: { message?: string }; + error?: { message?: string; code?: string; details?: unknown }; }; if (!res.ok) { - const message = body?.error?.message ?? `HTTP error ${res.status}`; + const message = getApiErrorMessage(body, res.status); throw new Error(message); } @@ -43,11 +45,11 @@ async function apiForm(path: string, form: FormData, init?: Omit ({}))) as Partial> & { - error?: { message?: string }; + error?: { message?: string; code?: string; details?: unknown }; }; if (!res.ok) { - const message = body?.error?.message ?? `HTTP error ${res.status}`; + const message = getApiErrorMessage(body, res.status); throw new Error(message); } diff --git a/web/src/api/chat-entry-parts.ts b/web/src/api/chat-entry-parts.ts index 62ad2ea8..897f919e 100644 --- a/web/src/api/chat-entry-parts.ts +++ b/web/src/api/chat-entry-parts.ts @@ -1,8 +1,8 @@ import { BASE_URL } from '../const'; import type { SseEnvelope } from './chat-core'; -import type { ChatOperationRuntimeStateDto } from '@shared/types/chat-runtime-state'; import type { Variant, Entry } from '@shared/types/chat-entry-parts'; +import type { ChatOperationRuntimeStateDto } from '@shared/types/chat-runtime-state'; type ApiEnvelope = { data: T; error?: unknown }; export type ApiHttpError = Error & { status?: number }; @@ -84,6 +84,7 @@ export type EntriesPageInfo = { export type ListChatEntriesResponse = { branchId: string; currentTurn: number; + lastSelectedPersonaId: string | null; entries: ChatEntryWithVariantDto[]; pageInfo: EntriesPageInfo; }; @@ -367,8 +368,12 @@ export type BatchUpdateEntryPartsRequest = { mainPartId: string; orderedPartIds: string[]; parts: Array<{ - partId: string; - deleted: boolean; + partId?: string; + clientPartId?: string; + deleted?: boolean; + channel?: 'main' | 'reasoning' | 'aux' | 'trace'; + payloadFormat?: 'text' | 'markdown' | 'json'; + label?: string; visibility: { ui: 'always' | 'never'; prompt: boolean }; payload: string | object | number | boolean | null; }>; @@ -380,6 +385,7 @@ export type BatchUpdateEntryPartsResponse = { mainPartId: string; updatedPartIds: string[]; deletedPartIds: string[]; + createdParts: Array<{ clientPartId: string; partId: string }>; }; export async function batchUpdateEntryParts( diff --git a/web/src/api/instructions.ts b/web/src/api/instructions.ts index 77e650bd..5efdfdaf 100644 --- a/web/src/api/instructions.ts +++ b/web/src/api/instructions.ts @@ -1,18 +1,38 @@ import { apiJson } from './api-json'; -import type { InstructionMeta } from '@shared/types/instructions'; +import type { + InstructionKind, + InstructionMeta, + StBaseConfig, +} from '@shared/types/instructions'; -export type InstructionDto = { +type InstructionDtoBase = { id: string; ownerId: string; name: string; engine: 'liquidjs'; - templateText: string; meta: InstructionMeta | null; createdAt: string; updatedAt: string; }; +export type BasicInstructionDto = InstructionDtoBase & { + kind: 'basic'; + templateText: string; +}; + +export type StBaseInstructionDto = InstructionDtoBase & { + kind: 'st_base'; + stBase: StBaseConfig; +}; + +export type InstructionDto = BasicInstructionDto | StBaseInstructionDto; + +export type DefaultStPresetDto = { + fileName: string; + preset: Record; +}; + export async function listInstructions(params?: { ownerId?: string }): Promise { const query = new URLSearchParams(); if (typeof params?.ownerId === 'string') query.set('ownerId', params.ownerId); @@ -20,22 +40,25 @@ export async function listInstructions(params?: { ownerId?: string }): Promise(`/instructions${suffix}`); } +export async function getDefaultStPreset(): Promise { + return apiJson('/instructions/default-st-preset'); +} + export async function createInstruction(params: { name: string; engine?: 'liquidjs'; - templateText: string; - meta?: InstructionMeta; ownerId?: string; -}): Promise { + meta?: InstructionMeta; +} & ({ + kind: 'basic'; + templateText: string; +} | { + kind: 'st_base'; + stBase: StBaseConfig; +})): Promise { return apiJson('/instructions', { method: 'POST', - body: JSON.stringify({ - ownerId: params.ownerId, - name: params.name, - engine: params.engine ?? 'liquidjs', - templateText: params.templateText, - meta: params.meta, - }), + body: JSON.stringify(params), }); } @@ -43,20 +66,39 @@ export async function updateInstruction(params: { id: string; name?: string; engine?: 'liquidjs'; - templateText?: string; meta?: InstructionMeta; -}): Promise { - return apiJson(`/instructions/${encodeURIComponent(params.id)}`, { +} & ({ + kind: 'basic'; + templateText?: string; +} | { + kind: 'st_base'; + stBase?: StBaseConfig; +})): Promise { + const { id, ...body } = params; + return apiJson(`/instructions/${encodeURIComponent(id)}`, { method: 'PUT', - body: JSON.stringify({ - name: params.name, - engine: params.engine, - templateText: params.templateText, - meta: params.meta, - }), + body: JSON.stringify(body), }); } +export type CreateInstructionDraft = + | { + kind: 'basic'; + name: string; + templateText: string; + meta?: InstructionMeta; + } + | { + kind: 'st_base'; + name: string; + stBase: StBaseConfig; + meta?: InstructionMeta; + }; + +export function getInstructionKindLabel(kind: InstructionKind): string { + return kind === 'st_base' ? 'SillyTavern-like' : 'Simple'; +} + export async function deleteInstruction(id: string): Promise<{ id: string }> { return apiJson<{ id: string }>(`/instructions/${encodeURIComponent(id)}`, { method: 'DELETE' }); } diff --git a/web/src/features/chat-window/index.tsx b/web/src/features/chat-window/index.tsx index 61019bb1..1b99089d 100644 --- a/web/src/features/chat-window/index.tsx +++ b/web/src/features/chat-window/index.tsx @@ -18,8 +18,6 @@ import { resetUnseenMessages, } from '@model/chat-entry-parts'; -import BGImages from '../../assets/bg.png'; - import { AvatarPreviewPanel, type ChatAvatarPreview } from './avatar-preview-panel'; import { MessageInput } from './input'; import { MessageActionModals } from './message/message-action-modals'; @@ -156,7 +154,7 @@ export const ChatWindow: React.FC = () => { }, [clearUnseenMessages]); return ( - + diff --git a/web/src/features/chat-window/input/chat-management-menu.tsx b/web/src/features/chat-window/input/chat-management-menu.tsx index a519ceb8..26b7a697 100644 --- a/web/src/features/chat-window/input/chat-management-menu.tsx +++ b/web/src/features/chat-window/input/chat-management-menu.tsx @@ -2,7 +2,7 @@ import { ActionIcon, Badge, Box, Button, Checkbox, Group, Menu, Paper, Stack, Te import { useUnit } from 'effector-react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { LuBookOpenText, LuCheck, LuFolderGit2, LuMessageSquare, LuPencil, LuPlus, LuSettings2, LuTrash2, LuX } from 'react-icons/lu'; +import { LuBookMarked, LuBookOpenText, LuCheck, LuFolderGit2, LuMessageSquare, LuPencil, LuPlus, LuSettings2, LuTrash2, LuX } from 'react-icons/lu'; import { $branches, @@ -28,6 +28,8 @@ import { Z_INDEX } from '@ui/z-index'; import { getLatestWorldInfoActivations, type LatestWorldInfoActivationsResponse } from '../../../api/chat-entry-parts'; +import { ChatWorldInfoBindingDialog } from './chat-world-info-binding-dialog'; + function normalizeName(value: string): string { return value.trim(); } @@ -52,6 +54,7 @@ export const ChatManagementMenu = () => { const [deleteCurrentOnQuickCreate, setDeleteCurrentOnQuickCreate] = useState(false); const [chatsModalOpen, setChatsModalOpen] = useState(false); const [branchesModalOpen, setBranchesModalOpen] = useState(false); + const [chatWorldInfoModalOpen, setChatWorldInfoModalOpen] = useState(false); const [worldInfoModalOpen, setWorldInfoModalOpen] = useState(false); const [worldInfoLoading, setWorldInfoLoading] = useState(false); const [worldInfoError, setWorldInfoError] = useState(null); @@ -179,12 +182,26 @@ export const ChatManagementMenu = () => { > {t('chat.management.bulkDelete')} + } + disabled={!currentChatId} + onClick={() => setChatWorldInfoModalOpen(true)} + > + {t('chat.management.worldInfoBinding')} + } disabled={!currentChatId} onClick={() => void loadLatestWorldInfoActivations()}> {t('chat.management.latestWorldInfoActivations')} + + { + it('prepends the unbound option before world info books', () => { + expect( + buildChatWorldInfoBindingOptions({ + noneLabel: 'Not linked', + books: [ + { id: 'book-1', name: 'Alpha' }, + { id: 'book-2', name: 'Beta' }, + ], + }), + ).toEqual([ + { value: '__none__', label: 'Not linked' }, + { value: 'book-1', label: 'Alpha' }, + { value: 'book-2', label: 'Beta' }, + ]); + }); + + it('enables searchable dropdown props and renders it above the dialog', () => { + expect(createChatWorldInfoBindingSelectProps('No matches')).toEqual({ + searchable: true, + nothingFoundMessage: 'No matches', + maxDropdownHeight: 320, + comboboxProps: { + withinPortal: true, + zIndex: 4200, + }, + }); + }); +}); diff --git a/web/src/features/chat-window/input/chat-world-info-binding-dialog.model.ts b/web/src/features/chat-window/input/chat-world-info-binding-dialog.model.ts new file mode 100644 index 00000000..6287d276 --- /dev/null +++ b/web/src/features/chat-window/input/chat-world-info-binding-dialog.model.ts @@ -0,0 +1,28 @@ +import { Z_INDEX } from '@ui/z-index'; + +type WorldInfoBookOptionSource = { + id: string; + name: string; +}; + +export function buildChatWorldInfoBindingOptions(params: { + noneLabel: string; + books: WorldInfoBookOptionSource[]; +}) { + return [ + { value: '__none__', label: params.noneLabel }, + ...params.books.map((book) => ({ value: book.id, label: book.name })), + ]; +} + +export function createChatWorldInfoBindingSelectProps(nothingFoundMessage: string) { + return { + searchable: true, + nothingFoundMessage, + maxDropdownHeight: 320, + comboboxProps: { + withinPortal: true, + zIndex: Z_INDEX.overlay.popup, + }, + } as const; +} diff --git a/web/src/features/chat-window/input/chat-world-info-binding-dialog.tsx b/web/src/features/chat-window/input/chat-world-info-binding-dialog.tsx new file mode 100644 index 00000000..eebf35c3 --- /dev/null +++ b/web/src/features/chat-window/input/chat-world-info-binding-dialog.tsx @@ -0,0 +1,109 @@ +import { Button, Select, Stack, Text } from '@mantine/core'; +import { useUnit } from 'effector-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { toggleSidebarOpen } from '@model/sidebars'; +import { + $worldInfoBooks, + $worldInfoCurrentChatBookId, + setWorldInfoBookBoundToCurrentChatFx, + setWorldInfoBookBoundToCurrentChatRequested, + worldInfoEditorOpenRequested, +} from '@model/world-info'; +import { Dialog } from '@ui/dialog'; + +import { + buildChatWorldInfoBindingOptions, + createChatWorldInfoBindingSelectProps, +} from './chat-world-info-binding-dialog.model'; + +type Props = { + opened: boolean; + onOpenChange: (open: boolean) => void; + chatId: string | null; + chatTitle: string | null; +}; + +export const ChatWorldInfoBindingDialog = ({ opened, onOpenChange, chatId, chatTitle }: Props) => { + const { t } = useTranslation(); + const [books, currentBookId, bindingPending, bindBookToCurrentChat] = useUnit([ + $worldInfoBooks, + $worldInfoCurrentChatBookId, + setWorldInfoBookBoundToCurrentChatFx.pending, + setWorldInfoBookBoundToCurrentChatRequested, + ]); + + const options = useMemo( + () => + buildChatWorldInfoBindingOptions({ + noneLabel: t('chat.management.worldInfoBindingNone'), + books, + }), + [books, t], + ); + const currentBook = useMemo( + () => books.find((book) => book.id === currentBookId) ?? null, + [books, currentBookId], + ); + const selectProps = useMemo( + () => createChatWorldInfoBindingSelectProps(t('chat.management.worldInfoBindingNothingFound')), + [t], + ); + + return ( + } + > + + + {chatTitle + ? t('chat.management.worldInfoBindingForChat', { name: chatTitle }) + : t('chat.management.selectChatFirst')} + + { + if (!value) return; + updateDraftPart(part.id, (prev) => ({ ...prev, channel: value as Part['channel'] })); + }} + /> +