From 428de84c8fe41969cace1f3b6991d60c24b76447 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sat, 21 Feb 2026 23:01:14 +0300 Subject: [PATCH 01/54] fix: up --- knowledge-base/Artifacts v1.0.md | 715 ++++++++++++++++++ .../artifacts-v1-migration-plan-2026-02-21.md | 238 ++++++ 2 files changed, 953 insertions(+) create mode 100644 knowledge-base/Artifacts v1.0.md create mode 100644 knowledge-base/artifacts-v1-migration-plan-2026-02-21.md 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-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. Все обязательные проверки сервера и веба проходят. From 9ed5fa9e8112d5fa78d85265ce7f38f45b7e5e6b Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sat, 7 Mar 2026 18:34:25 +0300 Subject: [PATCH 02/54] fix: up --- .../artifacts-concept-v2-draft-2026-03-07.md | 536 ++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 knowledge-base/artifacts-concept-v2-draft-2026-03-07.md 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..016905da --- /dev/null +++ b/knowledge-base/artifacts-concept-v2-draft-2026-03-07.md @@ -0,0 +1,536 @@ +# 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 = { + tag: string; + title: string; + description?: string; + + format: ArtifactFormat; + persistence: ArtifactPersistence; + writeMode: ArtifactWriteMode; + + history: { + enabled: boolean; + maxItems: number; + }; + + exposures: ArtifactExposure[]; +}; +``` + +```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` быть глобально уникален в profile или вычисляться автоматически из operation id. +2. Нужен ли пользователю editable `tag`, или лучше иметь стабильный internal id и отдельный display name. +3. Должен ли `turn_rewrite` всегда персиститься в parts. +4. Нужен ли `append` как write mode в первой версии, или пока достаточно только `replace`. +5. Нужен ли `ui_panel` уже в первой версии, или его лучше отложить. +6. Насколько exposure-конфиг должен быть low-level, а насколько через пресеты. + From 4551ee158ec740fe3ce02780d1b88e36f3189d44 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 8 Mar 2026 21:49:54 +0300 Subject: [PATCH 03/54] fix: liquidjs templates --- .../prompt-template-renderer.test.ts | 84 ++++++ .../chat-core/prompt-template-renderer.ts | 208 +++++++++++--- web/src/i18n/resources/en/dialogs.ts | 19 ++ web/src/i18n/resources/ru/dialogs.ts | 19 ++ web/src/ui/liquid-template-docs-config.ts | 43 +++ web/src/ui/liquid-template-docs.tsx | 263 ++++++++++++------ 6 files changed, 522 insertions(+), 114 deletions(-) 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..039c7c51 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,7 @@ 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("{{ recentMessages(2) | size }}")).not.toThrow(); expect(() => validateLiquidTemplate("{{ broken ")).toThrow(); }); @@ -164,4 +165,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..2e5c35a1 100644 --- a/server/src/services/chat-core/prompt-template-renderer.ts +++ b/server/src/services/chat-core/prompt-template-renderer.ts @@ -37,17 +37,139 @@ export interface InstructionRenderContext { lastAssistantMessage?: string; } -const engine = new Liquid({ - cache: true, - strictFilters: false, - strictVariables: false, -}); +type RenderableMessage = { role: string; content: string }; + +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 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 sanitizeRenderableMessages(messages: unknown): RenderableMessage[] { + return Array.isArray(messages) + ? messages.filter( + (item): item is RenderableMessage => + typeof item?.role === "string" && typeof item?.content === "string" + ) + : []; +} + +function isConversationRole(role: string): role is "user" | "assistant" { + return role === "user" || role === "assistant"; +} + +function getConversationalMessages(messages: RenderableMessage[]): RenderableMessage[] { + return messages.filter((message) => isConversationRole(message.role)); +} + +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 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; +} + +function formatMessagesAsText(messages: RenderableMessage[]): string { + return messages.map((message) => `${message.role}: ${message.content}`).join("\n"); +} + +function selectRecentMessages( + messages: RenderableMessage[], + count: number +): RenderableMessage[] { + if (count <= 0) return []; + return messages.slice(-count); +} + +function selectRecentMessagesByTokenLimit( + messages: RenderableMessage[], + tokenLimit: number +): RenderableMessage[] { + if (tokenLimit <= 0) return []; + const selected: RenderableMessage[] = []; + let accumulatedTokens = 0; + + 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; + } + + selected.reverse(); + return selected; +} + +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; + } + + 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 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); + } + ); + return liquid; +} + +const engine = registerInternalFilters( + new Liquid({ + cache: true, + strictFilters: false, + strictVariables: false, + }) +); + function sanitizeOutletKey(value: string): string { // Keep keys printable and stable for object lookup. return value.trim().replace(/\\/g, "\\\\").replace(/'/g, "\\'"); @@ -80,6 +202,26 @@ function resolveRandomMacro(rawMacroBody: string, rng: () => number): string | n 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 preprocessSillyTavernTemplateSyntax( templateText: string, options?: { rng?: () => number } @@ -89,31 +231,34 @@ function preprocessSillyTavernTemplateSyntax( } { 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; + 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 === "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("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 %}`; + } - 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; } - - return full; - }); + ); return { text, hasTrimSentinel }; } @@ -174,12 +319,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. @@ -230,7 +370,9 @@ export async function renderLiquidTemplate(params: { }; }): 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; diff --git a/web/src/i18n/resources/en/dialogs.ts b/web/src/i18n/resources/en/dialogs.ts index 9ac1fa19..6053c637 100644 --- a/web/src/i18n/resources/en/dialogs.ts +++ b/web/src/i18n/resources/en/dialogs.ts @@ -9,9 +9,12 @@ }, liquidDocs: { open: 'Open Liquid docs', + searchPlaceholder: 'Search tokens, descriptions, and examples', + noSearchResults: 'No Liquid docs entries match your search', sections: { usage: 'Usage', variables: 'Variables', + methods: 'Methods', macros: 'Macros', examples: 'Examples', }, @@ -77,6 +80,16 @@ art: 'Operation artifacts map by tag.', artValue: 'Artifact value by tag, for example art.note.value.', }, + methods: { + recentMessages: + 'Returns the last N conversational messages with user/assistant roles in chronological order.', + recentMessagesText: + 'Returns the last N conversational messages as text in `role: content` format separated by newlines.', + recentMessagesByContextTokens: + 'Returns the last user/assistant messages until their approximate size reaches tokenLimit. Token counting is approximate and uses the current app heuristic `ceil(chars / 4)` while rounding upward on the last included message.', + recentMessagesByContextTokensText: + 'Same as recentMessagesByContextTokens(tokenLimit), but formatted as newline-separated `role: content` text. Token counting is approximate and uses the current app heuristic `ceil(chars / 4)`.', + }, macros: { trim: 'Removes surrounding blank lines around macro location.', outlet: 'Shortcut to outlet map lookup by key.', @@ -119,6 +132,12 @@ chatManualEditHistory: { title: 'Manual edit history-aware template', }, + recentMessagesCount: { + title: 'Recent message helpers by count', + }, + recentMessagesTokens: { + title: 'Recent message helpers by token budget', + }, }, }, }; diff --git a/web/src/i18n/resources/ru/dialogs.ts b/web/src/i18n/resources/ru/dialogs.ts index 48a9bca5..c77aa55b 100644 --- a/web/src/i18n/resources/ru/dialogs.ts +++ b/web/src/i18n/resources/ru/dialogs.ts @@ -9,9 +9,12 @@ }, liquidDocs: { open: 'Открыть документацию Liquid', + searchPlaceholder: 'Поиск по токенам, описаниям и примерам', + noSearchResults: 'По вашему запросу ничего не найдено', sections: { usage: 'Где используется', variables: 'Переменные', + methods: 'Методы', macros: 'Макросы', examples: 'Примеры', }, @@ -77,6 +80,16 @@ art: 'Map артефактов операций по тегам.', artValue: 'Значение артефакта по тегу, например art.note.value.', }, + methods: { + recentMessages: + 'Возвращает массив последних N сообщений диалога с ролями user/assistant в хронологическом порядке.', + recentMessagesText: + 'Возвращает последние N сообщений диалога как текст в формате `role: content`, разделенный переводами строк.', + recentMessagesByContextTokens: + 'Возвращает массив последних сообщений user/assistant, пока их примерный размер не достигнет tokenLimit. Подсчет токенов приблизительный и использует текущую эвристику приложения `ceil(chars / 4)` с округлением вверх по последнему сообщению.', + recentMessagesByContextTokensText: + 'То же, что recentMessagesByContextTokens(tokenLimit), но в виде текста `role: content` с переводами строк. Подсчет токенов приблизительный и использует текущую эвристику приложения `ceil(chars / 4)`.', + }, macros: { trim: 'Удаляет лишние пустые строки вокруг позиции макроса.', outlet: 'Короткая форма доступа к outlet по ключу.', @@ -119,6 +132,12 @@ chatManualEditHistory: { title: 'Шаблон с учетом истории при ручном редактировании', }, + recentMessagesCount: { + title: 'Методы последних сообщений по количеству', + }, + recentMessagesTokens: { + title: 'Методы последних сообщений по токен-бюджету', + }, }, }, }; diff --git a/web/src/ui/liquid-template-docs-config.ts b/web/src/ui/liquid-template-docs-config.ts index 6a6f3f2c..fd7bbc09 100644 --- a/web/src/ui/liquid-template-docs-config.ts +++ b/web/src/ui/liquid-template-docs-config.ts @@ -16,6 +16,11 @@ export type MacroDoc = { descriptionKey: string; }; +export type MethodDoc = { + token: string; + descriptionKey: string; +}; + export type ExampleDoc = { titleKey: string; template: string; @@ -25,6 +30,7 @@ export type LiquidDocsModel = { titleKey: string; usageKey: string; variables: VariableDoc[]; + methods: MethodDoc[]; macros: MacroDoc[]; examples: ExampleDoc[]; }; @@ -74,11 +80,37 @@ const COMMON_MACROS: MacroDoc[] = [ { token: '{{random::A::B::C}}', descriptionKey: 'dialogs.liquidDocs.macros.random' }, ]; +const COMMON_METHODS: MethodDoc[] = [ + { token: '{{recentMessages(3)}}', descriptionKey: 'dialogs.liquidDocs.methods.recentMessages' }, + { token: '{{recentMessagesText(3)}}', descriptionKey: 'dialogs.liquidDocs.methods.recentMessagesText' }, + { + token: '{{recentMessagesByContextTokens(256)}}', + descriptionKey: 'dialogs.liquidDocs.methods.recentMessagesByContextTokens', + }, + { + token: '{{recentMessagesByContextTokensText(256)}}', + descriptionKey: 'dialogs.liquidDocs.methods.recentMessagesByContextTokensText', + }, +]; + +const MESSAGE_HELPER_EXAMPLES: ExampleDoc[] = [ + { + titleKey: 'dialogs.liquidDocs.examples.recentMessagesCount.title', + template: + '{% assign recent = recentMessages(3) %}\nRecent count: {{recent | size}}\n{% for msg in recent %}{{msg.role}}: {{msg.content}}\n{% endfor %}', + }, + { + titleKey: 'dialogs.liquidDocs.examples.recentMessagesTokens.title', + template: 'Recent text by token budget:\n{{recentMessagesByContextTokensText(256)}}', + }, +]; + export const LIQUID_DOCS_BY_CONTEXT: Record = { instruction: { titleKey: 'dialogs.liquidDocs.contexts.instruction.title', usageKey: 'dialogs.liquidDocs.contexts.instruction.usage', variables: BASE_VARIABLES, + methods: COMMON_METHODS, macros: COMMON_MACROS, examples: [ { @@ -90,12 +122,14 @@ export const LIQUID_DOCS_BY_CONTEXT: Record = ({ context, open, onOpenChange }) => { const { t } = useTranslation(); + const [search, setSearch] = useState(''); const model = LIQUID_DOCS_BY_CONTEXT[context]; if (!model) return null; + useEffect(() => { + if (!open) setSearch(''); + }, [open]); + + const normalizedQuery = search.trim().toLocaleLowerCase(); + const usageMatches = + normalizedQuery.length === 0 || + matchesSearch(t(model.titleKey), normalizedQuery) || + matchesSearch(t(model.usageKey), normalizedQuery); + const filteredVariables = + normalizedQuery.length === 0 + ? model.variables + : model.variables.filter( + (item) => + matchesSearch(item.token, normalizedQuery) || + matchesSearch(t(item.descriptionKey), normalizedQuery) + ); + const filteredMethods = + normalizedQuery.length === 0 + ? model.methods + : model.methods.filter( + (item) => + matchesSearch(item.token, normalizedQuery) || + matchesSearch(t(item.descriptionKey), normalizedQuery) + ); + const filteredMacros = + normalizedQuery.length === 0 + ? model.macros + : model.macros.filter( + (item) => + matchesSearch(item.token, normalizedQuery) || + matchesSearch(t(item.descriptionKey), normalizedQuery) + ); + const filteredExamples = + normalizedQuery.length === 0 + ? model.examples + : model.examples.filter( + (item) => + matchesSearch(t(item.titleKey), normalizedQuery) || + matchesSearch(item.template, normalizedQuery) + ); + const hasResults = + usageMatches || + filteredVariables.length > 0 || + filteredMethods.length > 0 || + filteredMacros.length > 0 || + filteredExamples.length > 0; + return ( onOpenChange(false)}> @@ -35,92 +88,140 @@ export const LiquidDocsDialog: React.FC = ({ context, ope } > - - - {t('dialogs.liquidDocs.sections.usage')} - + setSearch(event.currentTarget.value)} + placeholder={t('dialogs.liquidDocs.searchPlaceholder')} + /> + + {!hasResults ? ( - {t(model.usageKey)} + {t('dialogs.liquidDocs.noSearchResults')} - + ) : null} - - - {t('dialogs.liquidDocs.sections.variables')} - - - {model.variables.map((item) => ( - - - {item.token} - - - {t(item.descriptionKey)} - - - ))} + {usageMatches ? ( + + + {t('dialogs.liquidDocs.sections.usage')} + + + {t(model.usageKey)} + - + ) : null} - - - {t('dialogs.liquidDocs.sections.macros')} - - - {model.macros.map((item) => ( - - - {item.token} - - - {t(item.descriptionKey)} - - - ))} + {filteredVariables.length > 0 ? ( + + + {t('dialogs.liquidDocs.sections.variables')} + + + {filteredVariables.map((item) => ( + + + {item.token} + + + {t(item.descriptionKey)} + + + ))} + - + ) : null} - - - {t('dialogs.liquidDocs.sections.examples')} - - - {model.examples.map((item) => ( - - - {t(item.titleKey)} - - - {item.template} - - - ))} + {filteredMethods.length > 0 ? ( + + + {t('dialogs.liquidDocs.sections.methods')} + + + {filteredMethods.map((item) => ( + + + {item.token} + + + {t(item.descriptionKey)} + + + ))} + + + ) : null} + + {filteredMacros.length > 0 ? ( + + + {t('dialogs.liquidDocs.sections.macros')} + + + {filteredMacros.map((item) => ( + + + {item.token} + + + {t(item.descriptionKey)} + + + ))} + + + ) : null} + + {filteredExamples.length > 0 ? ( + + + {t('dialogs.liquidDocs.sections.examples')} + + + {filteredExamples.map((item) => ( + + + {t(item.titleKey)} + + + {item.template} + + + ))} + - + ) : null} ); From d409b788ab981905017f9f1378e0d66a830d1267 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 8 Mar 2026 22:02:31 +0300 Subject: [PATCH 04/54] fix: up --- server/src/api/legacy-route-wrappers.test.ts | 18 ++++++++++--- web/src/i18n/resources/en/dialogs.ts | 28 +------------------- web/src/i18n/resources/ru/dialogs.ts | 28 +------------------- web/src/ui/liquid-template-docs-config.ts | 14 ---------- web/src/ui/liquid-template-docs.tsx | 18 +------------ 5 files changed, 18 insertions(+), 88 deletions(-) diff --git a/server/src/api/legacy-route-wrappers.test.ts b/server/src/api/legacy-route-wrappers.test.ts index 4141750e..3fa3f71b 100644 --- a/server/src/api/legacy-route-wrappers.test.ts +++ b/server/src/api/legacy-route-wrappers.test.ts @@ -1,10 +1,20 @@ +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 +25,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/web/src/i18n/resources/en/dialogs.ts b/web/src/i18n/resources/en/dialogs.ts index 6053c637..0cbc5e58 100644 --- a/web/src/i18n/resources/en/dialogs.ts +++ b/web/src/i18n/resources/en/dialogs.ts @@ -8,42 +8,16 @@ }, }, liquidDocs: { + title: 'Liquid reference', open: 'Open Liquid docs', searchPlaceholder: 'Search tokens, descriptions, and examples', noSearchResults: 'No Liquid docs entries match your search', sections: { - usage: 'Usage', variables: 'Variables', methods: 'Methods', macros: 'Macros', examples: 'Examples', }, - contexts: { - instruction: { - title: 'Instruction Liquid docs', - usage: 'Used when rendering chat instructions before generation.', - }, - operationTemplate: { - title: 'Template operation Liquid docs', - usage: 'Used by operation kind=template for rendered effect payloads.', - }, - operationLlm: { - title: 'LLM operation Liquid docs', - usage: 'Used by operation kind=llm for system and user prompt rendering.', - }, - entityProfile: { - title: 'Entity profile Liquid docs', - usage: 'Liquid can be resolved in profile text fields directly and through multi-pass usage in other templates.', - }, - worldInfoEntry: { - title: 'World Info Liquid docs', - usage: 'Used when rendering World Info entry content in runtime context.', - }, - chatManualEdit: { - title: 'Manual edit Liquid docs', - usage: 'Used when chat message part is edited manually and rendered via Liquid.', - }, - }, variables: { char: 'Character object alias. Works as string and as object.', charName: 'Character name from current entity profile.', diff --git a/web/src/i18n/resources/ru/dialogs.ts b/web/src/i18n/resources/ru/dialogs.ts index c77aa55b..1dc4c858 100644 --- a/web/src/i18n/resources/ru/dialogs.ts +++ b/web/src/i18n/resources/ru/dialogs.ts @@ -8,42 +8,16 @@ }, }, liquidDocs: { + title: 'Справка по Liquid', open: 'Открыть документацию Liquid', searchPlaceholder: 'Поиск по токенам, описаниям и примерам', noSearchResults: 'По вашему запросу ничего не найдено', sections: { - usage: 'Где используется', variables: 'Переменные', methods: 'Методы', macros: 'Макросы', examples: 'Примеры', }, - contexts: { - instruction: { - title: 'Liquid для инструкций', - usage: 'Используется при рендере инструкций чата перед генерацией.', - }, - operationTemplate: { - title: 'Liquid для template-операции', - usage: 'Используется в operation kind=template для рендера payload эффектов.', - }, - operationLlm: { - title: 'Liquid для llm-операции', - usage: 'Используется в operation kind=llm для рендера system и user prompt.', - }, - entityProfile: { - title: 'Liquid для полей профиля', - usage: 'Liquid может раскрываться в текстовых полях профиля напрямую и косвенно через multi-pass в других шаблонах.', - }, - worldInfoEntry: { - title: 'Liquid для World Info', - usage: 'Используется при рендере content записей World Info в runtime-контексте.', - }, - chatManualEdit: { - title: 'Liquid для ручного редактирования', - usage: 'Используется при ручном редактировании части сообщения и рендере через Liquid.', - }, - }, variables: { char: 'Алиас объекта персонажа. Работает как строка и как объект.', charName: 'Имя персонажа из текущего entity profile.', diff --git a/web/src/ui/liquid-template-docs-config.ts b/web/src/ui/liquid-template-docs-config.ts index fd7bbc09..d5754a88 100644 --- a/web/src/ui/liquid-template-docs-config.ts +++ b/web/src/ui/liquid-template-docs-config.ts @@ -27,8 +27,6 @@ export type ExampleDoc = { }; export type LiquidDocsModel = { - titleKey: string; - usageKey: string; variables: VariableDoc[]; methods: MethodDoc[]; macros: MacroDoc[]; @@ -107,8 +105,6 @@ const MESSAGE_HELPER_EXAMPLES: ExampleDoc[] = [ export const LIQUID_DOCS_BY_CONTEXT: Record = { instruction: { - titleKey: 'dialogs.liquidDocs.contexts.instruction.title', - usageKey: 'dialogs.liquidDocs.contexts.instruction.usage', variables: BASE_VARIABLES, methods: COMMON_METHODS, macros: COMMON_MACROS, @@ -126,8 +122,6 @@ export const LIQUID_DOCS_BY_CONTEXT: Record = ({ context, ope }, [open]); const normalizedQuery = search.trim().toLocaleLowerCase(); - const usageMatches = - normalizedQuery.length === 0 || - matchesSearch(t(model.titleKey), normalizedQuery) || - matchesSearch(t(model.usageKey), normalizedQuery); const filteredVariables = normalizedQuery.length === 0 ? model.variables @@ -68,7 +64,6 @@ export const LiquidDocsDialog: React.FC = ({ context, ope matchesSearch(item.template, normalizedQuery) ); const hasResults = - usageMatches || filteredVariables.length > 0 || filteredMethods.length > 0 || filteredMacros.length > 0 || @@ -78,7 +73,7 @@ export const LiquidDocsDialog: React.FC = ({ context, ope = ({ context, ope ) : null} - {usageMatches ? ( - - - {t('dialogs.liquidDocs.sections.usage')} - - - {t(model.usageKey)} - - - ) : null} - {filteredVariables.length > 0 ? ( From 623e7fdbbe59f1d79a134cb41af0b29457d18ea6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 9 Mar 2026 01:45:52 +0300 Subject: [PATCH 05/54] fix: st template --- ...completion-presets-implementation-notes.md | 23 + ...8_ui_app_settings_bind_chat_completion.sql | 2 + server/drizzle/meta/_journal.json | 7 + server/src/db/schema/ui.ts | 6 + server/src/routes/app-settings-routes.test.ts | 1 + server/src/routes/app-settings-routes.ts | 1 + .../app-settings-repository.test.ts | 101 +-- .../app-settings/app-settings-repository.ts | 19 + shared/types/app-settings.ts | 1 + shared/types/instructions.ts | 1 + .../features/sidebars/instructions/index.tsx | 622 ++++++++++++++---- .../instructions/instruction-editor.tsx | 150 +++-- web/src/i18n/resources/en/instructions.ts | 141 ++-- web/src/i18n/resources/en/provider.ts | 179 +++-- web/src/i18n/resources/ru/instructions.ts | 141 ++-- web/src/i18n/resources/ru/provider.ts | 179 +++-- web/src/model/app-settings/index.ts | 1 + web/src/model/instructions/index.ts | 12 + web/src/model/instructions/st-preset.ts | 213 +++++- 19 files changed, 1264 insertions(+), 536 deletions(-) create mode 100644 knowledge-base/st-chat-completion-presets-implementation-notes.md create mode 100644 server/drizzle/0028_ui_app_settings_bind_chat_completion.sql 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/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/meta/_journal.json b/server/drizzle/meta/_journal.json index e49a6a36..050f76c6 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "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 } ] } diff --git a/server/src/db/schema/ui.ts b/server/src/db/schema/ui.ts index 582b1798..07269154 100644 --- a/server/src/db/schema/ui.ts +++ b/server/src/db/schema/ui.ts @@ -13,6 +13,12 @@ export const uiAppSettings = sqliteTable("ui_app_settings", { autoSelectCurrentPersona: integer("auto_select_current_persona", { mode: "boolean" }) .notNull() .default(false), + 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/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/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/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/instructions.ts b/shared/types/instructions.ts index da6c76ed..91ba7e1f 100644 --- a/shared/types/instructions.ts +++ b/shared/types/instructions.ts @@ -8,6 +8,7 @@ export type StPrompt = { role?: StPromptRole; content?: string; system_prompt?: boolean; + marker?: boolean; }; export type StPromptOrderEntry = { diff --git a/web/src/features/sidebars/instructions/index.tsx b/web/src/features/sidebars/instructions/index.tsx index 89a56c9f..9dcaff63 100644 --- a/web/src/features/sidebars/instructions/index.tsx +++ b/web/src/features/sidebars/instructions/index.tsx @@ -1,26 +1,38 @@ -import { Group, Select, Stack } from '@mantine/core'; +import { Button, Group, Modal, Select, Stack, Text, TextInput } from '@mantine/core'; import { useUnit } from 'effector-react'; -import { useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { LuCopy, LuDownload, LuPlus, LuTrash2, LuUpload } from 'react-icons/lu'; +import { + LuDownload, + LuFilePlus2, + LuLink2, + LuLink2Off, + LuPencil, + LuSave, + LuTrash2, + LuUpload, +} from 'react-icons/lu'; +import { getRuntime, patchRuntime } from '../../../api/llm'; +import { $appSettings, updateAppSettings } from '@model/app-settings'; import { + $instructionEditorDraft, $instructions, $selectedInstructionId, - createInstructionRequested, - deleteInstructionRequested, - duplicateInstructionRequested, - importInstructionRequested, + createInstructionFx, + deleteInstructionFx, instructionSelected, + updateInstructionFx, } from '@model/instructions'; import { buildStPresetFromAdvanced, + createBestEffortLlmBindingPlan, createStAdvancedConfigFromPreset, + createTsInstructionMeta, detectStChatCompletionPreset, deriveInstructionTemplateText, getTsInstructionMeta, hasSensitivePresetFields, - withTsInstructionMeta, } from '@model/instructions/st-preset'; import { Drawer } from '@ui/drawer'; import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; @@ -28,8 +40,22 @@ import { toaster } from '@ui/toaster'; import { InstructionEditor } from './instruction-editor'; +import type { InstructionDto } from '../../../api/instructions'; import type { InstructionMeta } from '@shared/types/instructions'; +type NameDialogMode = 'rename' | 'saveAs'; + +type NameDialogState = { + mode: NameDialogMode; + value: string; + title: string; + submitLabel: string; +}; + +type PendingSensitiveImport = { + fileName: string; + json: Record; +}; function downloadJson(params: { fileName: string; data: unknown }): void { const blob = new Blob([JSON.stringify(params.data, null, 2)], { type: 'application/json' }); @@ -53,50 +79,272 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function createInstructionPayloadFromDraft(params: { + name: string; + templateText: string; + meta?: InstructionMeta; +}) { + return { + name: params.name.trim(), + templateText: params.templateText, + meta: params.meta, + }; +} + +function resolveCopyName(originalName: string, usedNames: Set): string { + const firstCopy = `${originalName} (copy)`; + if (!usedNames.has(firstCopy)) return firstCopy; + let index = 2; + while (usedNames.has(`${originalName} (copy ${index})`)) { + index += 1; + } + return `${originalName} (copy ${index})`; +} + +function resolveUniqueName(name: string, usedNames: Set): string { + const trimmed = name.trim(); + if (!usedNames.has(trimmed)) return trimmed; + return resolveCopyName(trimmed, usedNames); +} + +function areInstructionValuesEqual( + selectedInstruction: InstructionDto | null, + draft: { sourceInstructionId: string; name: string; templateText: string; meta?: InstructionMeta } | null, +): boolean { + if (!selectedInstruction || !draft) return true; + if (draft.sourceInstructionId !== selectedInstruction.id) return false; + return ( + draft.name === selectedInstruction.name && + draft.templateText === selectedInstruction.templateText && + JSON.stringify(draft.meta ?? null) === JSON.stringify(selectedInstruction.meta ?? null) + ); +} + export const InstructionsSidebar = () => { const { t } = useTranslation(); const fileInputRef = useRef(null); - const [items, selectedId] = useUnit([$instructions, $selectedInstructionId]); - const onImport = useUnit(importInstructionRequested); + const [items, selectedId, draft, appSettings, onCreateInstruction, onUpdateInstruction, onDeleteInstruction] = useUnit([ + $instructions, + $selectedInstructionId, + $instructionEditorDraft, + $appSettings, + createInstructionFx, + updateInstructionFx, + deleteInstructionFx, + ]); + const [nameDialog, setNameDialog] = useState(null); + const [deleteOpened, setDeleteOpened] = useState(false); + const [discardOpened, setDiscardOpened] = useState(false); + const [pendingSelectionId, setPendingSelectionId] = useState(null); + const [pendingSensitiveImport, setPendingSensitiveImport] = useState(null); + + const selectedInstruction = items.find((item) => item.id === selectedId) ?? null; + const selectedValue = selectedInstruction?.id ?? null; + const usedNames = useMemo(() => new Set(items.map((item) => item.name)), [items]); const options = items .filter((item) => typeof item.id === 'string' && item.id.trim().length > 0) .map((item) => ({ value: item.id, label: item.name || item.id })); - const selectedInstruction = items.find((item) => item.id === selectedId) ?? null; - const selectedValue = options.some((item) => item.value === selectedId) ? selectedId : null; - const doExport = () => { - if (!selectedInstruction) { - toaster.error({ title: t('instructions.toasts.exportNotPossibleTitle'), description: t('instructions.toasts.selectForExport') }); - return; + const currentValues = useMemo(() => { + if (draft && selectedInstruction && draft.sourceInstructionId === selectedInstruction.id) { + return { + name: draft.name, + templateText: draft.templateText, + meta: draft.meta, + }; } - const tsInstruction = getTsInstructionMeta(selectedInstruction.meta); + if (selectedInstruction) { + return { + name: selectedInstruction.name, + templateText: selectedInstruction.templateText, + meta: selectedInstruction.meta ?? undefined, + }; + } + + return null; + }, [draft, selectedInstruction]); + + const hasUnsavedChanges = useMemo( + () => !areInstructionValuesEqual(selectedInstruction, draft), + [selectedInstruction, draft], + ); + + const selectedTsInstruction = getTsInstructionMeta(currentValues?.meta); + const selectedStAdvanced = + selectedTsInstruction?.mode === 'st_advanced' ? selectedTsInstruction.stAdvanced : null; + + const applyBestEffortBinding = async (instruction: InstructionDto) => { + if (!appSettings.bindChatCompletionPresetToConnection) return; + + const tsInstruction = getTsInstructionMeta(instruction.meta); const stAdvanced = tsInstruction?.mode === 'st_advanced' ? tsInstruction.stAdvanced : null; - const exportStCompatible = Boolean(stAdvanced) && window.confirm(t('instructions.confirm.exportStPreset')); + if (!stAdvanced) return; + + const plan = createBestEffortLlmBindingPlan(stAdvanced); + try { + if (plan.runtimePatch?.activeProviderId || typeof plan.runtimePatch?.activeModel !== 'undefined') { + const currentRuntime = await getRuntime({ scope: 'global', scopeId: 'global' }); + await patchRuntime({ + scope: 'global', + scopeId: 'global', + activeProviderId: plan.runtimePatch.activeProviderId ?? currentRuntime.activeProviderId, + activeTokenId: currentRuntime.activeTokenId, + activeModel: + typeof plan.runtimePatch.activeModel === 'undefined' + ? currentRuntime.activeModel + : plan.runtimePatch.activeModel, + }); + } + } catch (error) { + toaster.warning({ + title: t('instructions.toasts.bindWarningTitle'), + description: error instanceof Error ? error.message : String(error), + }); + } - if (exportStCompatible && stAdvanced) { - const preset = buildStPresetFromAdvanced(stAdvanced); - downloadJson({ - fileName: `${selectedInstruction.name}.json`, - data: preset, + if (plan.warnings.length > 0) { + toaster.warning({ + title: t('instructions.toasts.bindWarningTitle'), + description: plan.warnings.join(' '), }); + } + }; + + const applySelection = async (nextId: string) => { + instructionSelected(nextId); + const nextInstruction = items.find((item) => item.id === nextId); + if (nextInstruction) { + await applyBestEffortBinding(nextInstruction); + } + }; + + const promptForRename = () => { + if (!currentValues) return; + setNameDialog({ + mode: 'rename', + value: currentValues.name, + title: t('instructions.dialogs.renameTitle'), + submitLabel: t('instructions.actions.rename'), + }); + }; + + const promptForSaveAs = () => { + const fallbackName = currentValues?.name?.trim() ? `${currentValues.name} copy` : t('instructions.defaults.newPreset'); + setNameDialog({ + mode: 'saveAs', + value: fallbackName, + title: t('instructions.dialogs.saveAsTitle'), + submitLabel: t('instructions.actions.saveAs'), + }); + }; + + const handleNameDialogSubmit = async () => { + if (!nameDialog || !currentValues || !selectedInstruction) return; + const nextName = nameDialog.value.trim(); + if (!nextName) return; + const resolvedName = + nameDialog.mode === 'rename' + ? nextName + : resolveUniqueName(nextName, usedNames); + + try { + if (nameDialog.mode === 'rename') { + await onUpdateInstruction({ + id: selectedInstruction.id, + ...createInstructionPayloadFromDraft({ + name: resolvedName, + templateText: currentValues.templateText, + meta: currentValues.meta, + }), + }); + toaster.success({ title: t('instructions.toasts.savedTitle'), description: resolvedName }); + } else { + const created = await onCreateInstruction( + createInstructionPayloadFromDraft({ + name: resolvedName, + templateText: currentValues.templateText, + meta: currentValues.meta, + }), + ); + await applyBestEffortBinding(created); + toaster.success({ title: t('instructions.toasts.createdTitle'), description: created.name }); + } + setNameDialog(null); + } catch { + // global effect watchers already show the error toast + } + }; + + const handleUpdateCurrent = async () => { + if (!currentValues || !selectedInstruction) return; + try { + await onUpdateInstruction({ + id: selectedInstruction.id, + ...createInstructionPayloadFromDraft(currentValues), + }); + toaster.success({ title: t('instructions.toasts.savedTitle'), description: currentValues.name }); + } catch { + // handled by model watcher + } + }; + + const handleDelete = async () => { + if (!selectedInstruction) return; + try { + await onDeleteInstruction({ id: selectedInstruction.id }); + setDeleteOpened(false); + } catch { + // handled by model watcher + } + }; + + const handleExport = () => { + if (!currentValues) { + toaster.error({ title: t('instructions.toasts.exportNotPossibleTitle'), description: t('instructions.toasts.selectForExport') }); + return; + } + + const tsInstruction = getTsInstructionMeta(currentValues.meta); + const stAdvanced = tsInstruction?.mode === 'st_advanced' ? tsInstruction.stAdvanced : null; + if (!stAdvanced) { + toaster.error({ title: t('instructions.toasts.exportNotPossibleTitle'), description: t('instructions.toasts.stPresetOnlyExport') }); return; } downloadJson({ - fileName: `instruction-${selectedInstruction.name}.json`, - data: { - type: 'talespinner.instruction', - version: 1, - instruction: { - name: selectedInstruction.name, - engine: selectedInstruction.engine, - templateText: selectedInstruction.templateText, - meta: selectedInstruction.meta ?? null, - }, - }, + fileName: `${currentValues.name}.json`, + data: buildStPresetFromAdvanced(stAdvanced), + }); + }; + + const importStPreset = (fileName: string, json: Record, sensitiveImportMode: 'remove' | 'keep') => { + const stAdvanced = createStAdvancedConfigFromPreset({ + preset: json, + fileName, + sensitiveImportMode, }); + const templateText = deriveInstructionTemplateText(stAdvanced); + void onCreateInstruction({ + name: resolveUniqueName( + getBasename(fileName).trim() || t('instructions.defaults.importedInstruction'), + usedNames, + ), + templateText, + meta: createTsInstructionMeta({ + meta: null, + mode: 'st_advanced', + stAdvanced, + }), + }) + .then((created) => { + void applyBestEffortBinding(created); + toaster.success({ title: t('instructions.toasts.importSuccessTitle'), description: created.name }); + }) + .catch(() => { + // handled by model watcher + }); }; const handleFileChange = (event: React.ChangeEvent) => { @@ -108,7 +356,6 @@ export const InstructionsSidebar = () => { try { const content = String(readEvent.target?.result ?? ''); const json = JSON.parse(content) as unknown; - const fileName = getBasename(file.name).trim() || t('instructions.defaults.importedInstruction'); if ( isRecord(json) && @@ -117,10 +364,7 @@ export const InstructionsSidebar = () => { ) { const name = typeof json.instruction.name === 'string' ? json.instruction.name : t('instructions.defaults.importedInstruction'); const templateText = typeof json.instruction.templateText === 'string' ? json.instruction.templateText : ''; - const meta = - isRecord(json.instruction.meta) - ? (json.instruction.meta as InstructionMeta) - : undefined; + const meta = isRecord(json.instruction.meta) ? (json.instruction.meta as InstructionMeta) : undefined; if (!templateText.trim()) { toaster.error({ @@ -130,44 +374,21 @@ export const InstructionsSidebar = () => { return; } - onImport({ name, templateText, meta }); - toaster.success({ title: t('instructions.toasts.importSuccessTitle'), description: name }); + void onCreateInstruction({ name, templateText, meta }) + .then((created) => toaster.success({ title: t('instructions.toasts.importSuccessTitle'), description: created.name })) + .catch(() => { + // handled by model watcher + }); return; } if (detectStChatCompletionPreset(json)) { - let sensitiveImportMode: 'remove' | 'keep' = 'keep'; if (hasSensitivePresetFields(json)) { - const removeSensitive = window.confirm(t('instructions.confirm.sensitiveRemove')); - if (removeSensitive) { - sensitiveImportMode = 'remove'; - } else { - const importAsIs = window.confirm(t('instructions.confirm.sensitiveImportAsIs')); - if (!importAsIs) return; - } + setPendingSensitiveImport({ fileName: file.name, json }); + return; } - const stAdvanced = createStAdvancedConfigFromPreset({ - preset: json, - fileName: file.name, - sensitiveImportMode, - }); - const templateText = deriveInstructionTemplateText(stAdvanced); - const meta = withTsInstructionMeta({ - meta: null, - tsInstruction: { - version: 1, - mode: 'st_advanced', - stAdvanced, - }, - }); - - onImport({ - name: fileName, - templateText, - meta, - }); - toaster.success({ title: t('instructions.toasts.importSuccessTitle'), description: fileName }); + importStPreset(file.name, json, 'keep'); return; } @@ -190,66 +411,215 @@ export const InstructionsSidebar = () => { return ( - - - - + + setNameDialog(null)} + title={nameDialog?.title ?? ''} + centered + > + + + setNameDialog((current) => + current ? { ...current, value: event.currentTarget.value } : current, + ) + } + placeholder={t('instructions.placeholders.name')} + autoFocus /> - } - aria-label={t('common.delete')} - color="red" - variant="outline" - disabled={!selectedId} - onClick={() => { - if (!selectedId) return; - if (!window.confirm(t('instructions.confirm.deleteInstruction'))) return; - deleteInstructionRequested({ id: selectedId }); + + + + + + + + setDeleteOpened(false)} title={t('instructions.dialogs.deleteTitle')} centered> + + {t('instructions.confirm.deleteInstruction')} + + + + + + + + { + setDiscardOpened(false); + setPendingSelectionId(null); + }} + title={t('instructions.dialogs.discardTitle')} + centered + > + + {t('instructions.confirm.discardChanges')} + + + + + + + + setPendingSensitiveImport(null)} + title={t('instructions.dialogs.sensitiveImportTitle')} + centered + > + + {t('instructions.confirm.sensitiveImportChoice')} + + + + + + + + + + + {t('instructions.presets.title')} + + + ) : ( + + ) + } + aria-label={t('instructions.presets.actions.bind')} + variant={appSettings.bindChatCompletionPresetToConnection ? 'solid' : 'subtle'} + onClick={() => + updateAppSettings({ + bindChatCompletionPresetToConnection: !appSettings.bindChatCompletionPresetToConnection, + }) + } + /> + } + aria-label={t('instructions.presets.actions.import')} + onClick={() => fileInputRef.current?.click()} + /> + } + aria-label={t('instructions.presets.actions.export')} + disabled={!selectedStAdvanced} + onClick={handleExport} + /> + } + aria-label={t('instructions.presets.actions.delete')} + color="red" + variant="outline" + disabled={!selectedInstruction} + onClick={() => setDeleteOpened(true)} + /> + + + + +