From 775f5497aa2dfb2497656dd95280a1964cb3bb11 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sat, 2 May 2026 00:42:56 +0300 Subject: [PATCH 01/14] feat: add SillyTavern import tab and PNG card parsing --- server/src/api/_routes_.ts | 2 + server/src/api/sillytavern-import.api.ts | 66 +++ server/src/chat-core/charspec/png.test.ts | 32 ++ server/src/chat-core/charspec/png.ts | 138 +++-- .../sillytavern-import-scanner.ts | 313 +++++++++++ .../sillytavern-import-utils.ts | 70 +++ .../sillytavern-import.test.ts | 214 ++++++++ .../sillytavern-importer.ts | 484 ++++++++++++++++++ shared/types/sillytavern-import.ts | 79 +++ web/src/api/sillytavern-import.ts | 22 + .../features/sidebars/app-settings/index.tsx | 5 + .../app-settings/sillytavern-import-tab.tsx | 217 ++++++++ .../features/sidebars/app-settings/types.ts | 2 +- web/src/i18n/resources/en/appSettings.ts | 29 ++ web/src/i18n/resources/ru/appSettings.ts | 29 ++ .../model/sillytavern-import/index.test.ts | 78 +++ web/src/model/sillytavern-import/index.ts | 185 +++++++ 17 files changed, 1924 insertions(+), 41 deletions(-) create mode 100644 server/src/api/sillytavern-import.api.ts create mode 100644 server/src/chat-core/charspec/png.test.ts create mode 100644 server/src/services/sillytavern-import/sillytavern-import-scanner.ts create mode 100644 server/src/services/sillytavern-import/sillytavern-import-utils.ts create mode 100644 server/src/services/sillytavern-import/sillytavern-import.test.ts create mode 100644 server/src/services/sillytavern-import/sillytavern-importer.ts create mode 100644 shared/types/sillytavern-import.ts create mode 100644 web/src/api/sillytavern-import.ts create mode 100644 web/src/features/sidebars/app-settings/sillytavern-import-tab.tsx create mode 100644 web/src/model/sillytavern-import/index.test.ts create mode 100644 web/src/model/sillytavern-import/index.ts diff --git a/server/src/api/_routes_.ts b/server/src/api/_routes_.ts index 6ac243f7..44e45b2b 100644 --- a/server/src/api/_routes_.ts +++ b/server/src/api/_routes_.ts @@ -20,6 +20,7 @@ import ragRoutes from "./rag.api"; import samplersRoutes from "./samplers.api"; import settingsRoutes from "./settings.api"; import sidebarsRoutes from "./sidebars.api"; +import sillytavernImportRoutes from "./sillytavern-import.api"; import uiThemeCoreRoutes from "./ui-theme.core.api"; import userPersonsCoreRoutes from "./user-persons.core.api"; import worldInfoCoreRoutes from "./world-info.core.api"; @@ -43,6 +44,7 @@ export const routes = [ appBackgroundsRoutes, generateRoutes, sidebarsRoutes, + sillytavernImportRoutes, ragRoutes, ragChromaRoutes, llmPresetsRoutes, diff --git a/server/src/api/sillytavern-import.api.ts b/server/src/api/sillytavern-import.api.ts new file mode 100644 index 00000000..4b6b10cc --- /dev/null +++ b/server/src/api/sillytavern-import.api.ts @@ -0,0 +1,66 @@ +import express, { type Request } from "express"; +import { z } from "zod"; + +import { asyncHandler } from "@core/middleware/async-handler"; +import { HttpError } from "@core/middleware/error-handler"; +import { validate } from "@core/middleware/validate"; + +import { getRequestOwnerId } from "../core/request-context/request-context"; +import { scanSillyTavernImportRoot } from "../services/sillytavern-import/sillytavern-import-scanner"; +import { importSillyTavernSelection } from "../services/sillytavern-import/sillytavern-importer"; + +const router = express.Router(); + +const scanBodySchema = z.object({ + rootPath: z.string().min(1), +}); + +const importBodySchema = z.object({ + rootPath: z.string().min(1), + ownerId: z.string().min(1).optional(), + selection: z.object({ + itemIds: z.array(z.string().min(1)).max(10000), + }), +}); + +function mapImportError(error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("ENOENT") || message.includes("no such file")) { + throw new HttpError(400, "SillyTavern data directory was not found.", "VALIDATION_ERROR"); + } + throw error; +} + +router.post( + "/sillytavern-import/scan", + validate({ body: scanBodySchema }), + asyncHandler(async (req: Request) => { + const body = scanBodySchema.parse(req.body); + try { + return { data: await scanSillyTavernImportRoot(body.rootPath) }; + } catch (error) { + mapImportError(error); + } + }) +); + +router.post( + "/sillytavern-import/import", + validate({ body: importBodySchema }), + asyncHandler(async (req: Request) => { + const body = importBodySchema.parse(req.body); + try { + return { + data: await importSillyTavernSelection({ + rootPath: body.rootPath, + ownerId: getRequestOwnerId(req, body.ownerId), + selection: body.selection, + }), + }; + } catch (error) { + mapImportError(error); + } + }) +); + +export default router; diff --git a/server/src/chat-core/charspec/png.test.ts b/server/src/chat-core/charspec/png.test.ts new file mode 100644 index 00000000..83f762ae --- /dev/null +++ b/server/src/chat-core/charspec/png.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; + +import { extractCharSpecFromPngBuffer } from "./png"; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +function makeChunk(type: string, data: Buffer): Buffer { + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + chunk.write(type, 4, 4, "ascii"); + data.copy(chunk, 8); + return chunk; +} + +function makeTextPng(keyword: string, value: string): Buffer { + return Buffer.concat([ + PNG_SIGNATURE, + makeChunk("tEXt", Buffer.concat([Buffer.from(keyword, "latin1"), Buffer.from([0]), Buffer.from(value, "utf8")])), + makeChunk("IEND", Buffer.alloc(0)), + ]); +} + +describe("extractCharSpecFromPngBuffer", () => { + test("extracts SillyTavern chara tEXt payload", async () => { + const card = { spec: "chara_card_v2", data: { name: "Alice" }, name: "Alice" }; + const png = makeTextPng("chara", Buffer.from(JSON.stringify(card), "utf8").toString("base64")); + + await expect(extractCharSpecFromPngBuffer(png)).resolves.toEqual(card); + }); +}); diff --git a/server/src/chat-core/charspec/png.ts b/server/src/chat-core/charspec/png.ts index 7961a886..b5ec9975 100644 --- a/server/src/chat-core/charspec/png.ts +++ b/server/src/chat-core/charspec/png.ts @@ -1,30 +1,98 @@ -import sharp from "sharp"; - -type PngTextChunk = { keyword: string; text: string }; - -function isTextChunk(val: unknown): val is PngTextChunk { - return ( - typeof val === "object" && - val !== null && - "keyword" in val && - "text" in val && - typeof (val as { keyword?: unknown }).keyword === "string" && - typeof (val as { text?: unknown }).text === "string" - ); -} +import { inflateSync } from "node:zlib"; + +type PngTextChunk = { keyword: string; value: string }; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +function getTextChunks(buffer: Buffer): PngTextChunk[] { + if (buffer.length < 8 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new Error("Not a PNG file."); + } + + const chunks: PngTextChunk[] = []; + let offset = 8; + while (offset + 12 <= buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString("ascii", offset + 4, offset + 8); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > buffer.length) break; + const data = buffer.subarray(dataStart, dataEnd); -function findEmbeddedCardText(metadata: sharp.Metadata): string | null { - const comments = metadata.comments; - if (!Array.isArray(comments)) return null; + if (type === "tEXt") { + const separator = data.indexOf(0x00); + if (separator > 0) { + chunks.push({ + keyword: data.subarray(0, separator).toString("latin1"), + value: data.subarray(separator + 1).toString("utf8"), + }); + } + } else if (type === "zTXt") { + const separator = data.indexOf(0x00); + if (separator > 0 && separator + 2 <= data.length && data[separator + 1] === 0) { + try { + chunks.push({ + keyword: data.subarray(0, separator).toString("latin1"), + value: inflateSync(data.subarray(separator + 2)).toString("utf8"), + }); + } catch { + // Ignore invalid compressed text chunks. + } + } + } else if (type === "iTXt") { + const firstNull = data.indexOf(0x00); + if (firstNull > 0 && firstNull + 5 <= data.length) { + const keyword = data.subarray(0, firstNull).toString("latin1"); + let cursor = firstNull + 1; + const compressionFlag = data[cursor]; + cursor += 1; + const compressionMethod = data[cursor]; + cursor += 1; + const langEnd = data.indexOf(0x00, cursor); + if (langEnd >= 0) { + cursor = langEnd + 1; + const translatedEnd = data.indexOf(0x00, cursor); + if (translatedEnd >= 0) { + const payload = data.subarray(translatedEnd + 1); + try { + chunks.push({ + keyword, + value: + compressionFlag === 1 && compressionMethod === 0 + ? inflateSync(payload).toString("utf8") + : payload.toString("utf8"), + }); + } catch { + // Ignore invalid international text chunks. + } + } + } + } + } - for (const entry of comments) { - if (!isTextChunk(entry)) continue; - const keyword = entry.keyword.toLowerCase(); - if (keyword !== "chara" && keyword !== "ccv3") continue; - if (!entry.text) continue; - return entry.text; + offset = dataEnd + 4; } + return chunks; +} + +function parsePossibleJsonPayload(value: string): unknown | null { + const trimmed = value.trim(); + if (!trimmed) return null; + for (const candidate of [trimmed, trimmed.replace(/\s+/g, "")]) { + try { + return JSON.parse(candidate) as unknown; + } catch { + // Try base64 below. + } + try { + return JSON.parse(Buffer.from(candidate, "base64").toString("utf8")) as unknown; + } catch { + // Continue with next candidate. + } + } return null; } @@ -34,24 +102,14 @@ function findEmbeddedCardText(metadata: sharp.Metadata): string | null { * where the text payload is base64-encoded JSON. */ export async function extractCharSpecFromPngBuffer(buffer: Buffer): Promise { - const image = sharp(buffer); - const metadata = await image.metadata(); - const payloadBase64 = findEmbeddedCardText(metadata); - if (!payloadBase64) { - throw new Error("PNG файл не содержит данных character-карточки (tEXt:chara/ccv3)."); + const chunks = getTextChunks(buffer); + for (const key of ["chara", "ccv3"]) { + const hit = chunks.find((entry) => entry.keyword.toLowerCase() === key); + if (!hit) continue; + const payload = parsePossibleJsonPayload(hit.value); + if (payload !== null) return payload; } - let decoded: string; - try { - decoded = Buffer.from(payloadBase64, "base64").toString("utf-8"); - } catch { - throw new Error("Не удалось декодировать base64 payload из PNG character-карточки."); - } - - try { - return JSON.parse(decoded) as unknown; - } catch { - throw new Error("Не удалось распарсить JSON из PNG character-карточки."); - } + throw new Error("PNG файл не содержит данных character-карточки (tEXt:chara/ccv3)."); } diff --git a/server/src/services/sillytavern-import/sillytavern-import-scanner.ts b/server/src/services/sillytavern-import/sillytavern-import-scanner.ts new file mode 100644 index 00000000..0d934070 --- /dev/null +++ b/server/src/services/sillytavern-import/sillytavern-import-scanner.ts @@ -0,0 +1,313 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { isSillyTavernPreset } from "@shared/utils/sillytavern-preset"; + +import { normalizeCharSpec } from "../../chat-core/charspec/normalize"; +import { extractCharSpecFromPngBuffer } from "../../chat-core/charspec/png"; + +import { + asString, + asStringRecord, + createImportItemId, + isRecord, + normalizePathForMeta, + readJsonFile, + sha256File, + sha256Text, + toErrorMessage, +} from "./sillytavern-import-utils"; + +import type { + SillyTavernImportKind, + SillyTavernImportScanItem, + SillyTavernImportScanProfile, + SillyTavernImportScanResult, +} from "@shared/types/sillytavern-import"; + +const PROFILE_EXCLUDE = new Set(["_cache", "_storage", "_uploads", "_webpack"]); +const IMPORT_KINDS: SillyTavernImportKind[] = [ + "character", + "persona", + "world_info", + "instruction", + "sampler", + "chat", +]; + +const SAMPLER_KEYS = [ + "temperature", + "temp", + "top_p", + "top_k", + "top_a", + "min_p", + "rep_pen", + "repetition_penalty", + "frequency_penalty", + "presence_penalty", + "openai_max_tokens", + "max_new_tokens", + "seed", +]; + +function emptyItems(): Record { + return { + character: [], + persona: [], + world_info: [], + instruction: [], + sampler: [], + chat: [], + }; +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function listFiles(dir: string, extensions?: string[]): Promise { + if (!(await pathExists(dir))) return []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => !extensions || extensions.includes(path.extname(name).toLowerCase())) + .sort((a, b) => a.localeCompare(b)); +} + +async function listChatFiles(chatsDir: string): Promise> { + if (!(await pathExists(chatsDir))) return []; + const folders = await fs.readdir(chatsDir, { withFileTypes: true }); + const result: Array<{ characterBase: string; fileName: string }> = []; + for (const folder of folders.filter((entry) => entry.isDirectory())) { + const files = await listFiles(path.join(chatsDir, folder.name), [".jsonl"]); + result.push(...files.map((fileName) => ({ characterBase: folder.name, fileName }))); + } + return result.sort((a, b) => `${a.characterBase}/${a.fileName}`.localeCompare(`${b.characterBase}/${b.fileName}`)); +} + +async function makeFileItem(params: { + kind: SillyTavernImportKind; + profileHandle: string; + profileRoot: string; + relativePath: string; + name: string; + details?: string; + dependencyIds?: string[]; +}): Promise { + const filePath = path.join(params.profileRoot, params.relativePath); + const stat = await fs.stat(filePath); + const normalizedRelativePath = normalizePathForMeta(params.relativePath); + const contentHash = await sha256File(filePath); + return { + id: createImportItemId({ + kind: params.kind, + profileHandle: params.profileHandle, + relativePath: normalizedRelativePath, + }), + source: "sillytavern", + kind: params.kind, + profileHandle: params.profileHandle, + relativePath: normalizedRelativePath, + contentHash, + size: stat.size, + mtimeMs: stat.mtimeMs, + name: params.name, + details: params.details, + dependencyIds: params.dependencyIds, + }; +} + +async function scanCharacter(params: { + profileHandle: string; + profileRoot: string; + fileName: string; +}): Promise { + const relativePath = path.join("characters", params.fileName); + const filePath = path.join(params.profileRoot, relativePath); + try { + const ext = path.extname(params.fileName).toLowerCase(); + const raw = + ext === ".png" + ? await extractCharSpecFromPngBuffer(await fs.readFile(filePath)) + : await readJsonFile(filePath); + const normalized = normalizeCharSpec(raw); + return makeFileItem({ + kind: "character", + profileHandle: params.profileHandle, + profileRoot: params.profileRoot, + relativePath, + name: normalized.name || path.parse(params.fileName).name || params.fileName, + details: params.fileName, + }); + } catch (error) { + return makeFileItem({ + kind: "character", + profileHandle: params.profileHandle, + profileRoot: params.profileRoot, + relativePath, + name: path.parse(params.fileName).name || params.fileName, + details: `Parse warning: ${toErrorMessage(error)}`, + }); + } +} + +function hasSamplerFields(raw: unknown): boolean { + if (!isRecord(raw)) return false; + return SAMPLER_KEYS.some((key) => typeof raw[key] === "number" || typeof raw[key] === "boolean"); +} + +function getJsonName(raw: unknown, fallback: string): string { + if (isRecord(raw) && typeof raw.name === "string" && raw.name.trim()) return raw.name.trim(); + return path.parse(fallback).name || fallback; +} + +async function scanSettingsJson(params: { + kind: "instruction" | "sampler"; + profileHandle: string; + profileRoot: string; + directory: string; + fileName: string; +}): Promise { + const relativePath = path.join(params.directory, params.fileName); + try { + const raw = await readJsonFile(path.join(params.profileRoot, relativePath)); + if (params.kind === "instruction" && !isSillyTavernPreset(raw)) return null; + if (params.kind === "sampler" && !hasSamplerFields(raw)) return null; + return makeFileItem({ + kind: params.kind, + profileHandle: params.profileHandle, + profileRoot: params.profileRoot, + relativePath, + name: getJsonName(raw, params.fileName), + details: params.directory, + }); + } catch { + return null; + } +} + +async function scanPersonas(params: { + profileHandle: string; + profileRoot: string; +}): Promise { + const settingsPath = path.join(params.profileRoot, "settings.json"); + if (!(await pathExists(settingsPath))) return []; + const settings = await readJsonFile(settingsPath); + const powerUser = isRecord(settings) && isRecord(settings.power_user) ? settings.power_user : {}; + const personas = asStringRecord(powerUser.personas); + const descriptions = isRecord(powerUser.persona_descriptions) ? powerUser.persona_descriptions : {}; + const stat = await fs.stat(settingsPath); + return Object.entries(personas).map(([avatarFile, name]) => { + const desc = isRecord(descriptions[avatarFile]) ? asString(descriptions[avatarFile].description) : ""; + const relativePath = normalizePathForMeta(`settings.json#persona:${avatarFile}`); + const contentHash = sha256Text(JSON.stringify({ avatarFile, name, desc })); + return { + id: createImportItemId({ kind: "persona", profileHandle: params.profileHandle, relativePath }), + source: "sillytavern", + kind: "persona", + profileHandle: params.profileHandle, + relativePath, + contentHash, + size: stat.size, + mtimeMs: stat.mtimeMs, + name: name.trim() || path.parse(avatarFile).name || avatarFile, + details: avatarFile, + }; + }); +} + +function createTotals(profiles: SillyTavernImportScanProfile[]): Record { + return Object.fromEntries( + IMPORT_KINDS.map((kind) => [kind, profiles.reduce((sum, profile) => sum + profile.items[kind].length, 0)]) + ) as Record; +} + +export async function scanSillyTavernImportRoot(rootPath: string): Promise { + const root = path.resolve(rootPath.trim()); + const dataDir = path.join(root, "data"); + const dataEntries = await fs.readdir(dataDir, { withFileTypes: true }); + const profileNames = dataEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => !PROFILE_EXCLUDE.has(name) && !name.startsWith("_")) + .sort((a, b) => a.localeCompare(b)); + + const profiles: SillyTavernImportScanProfile[] = []; + for (const profileHandle of profileNames) { + const profileRoot = path.join(dataDir, profileHandle); + const items = emptyItems(); + + for (const fileName of await listFiles(path.join(profileRoot, "characters"), [".png", ".json"])) { + const item = await scanCharacter({ profileHandle, profileRoot, fileName }); + if (item) items.character.push(item); + } + items.persona.push(...(await scanPersonas({ profileHandle, profileRoot }))); + for (const fileName of await listFiles(path.join(profileRoot, "worlds"), [".json"])) { + items.world_info.push( + await makeFileItem({ + kind: "world_info", + profileHandle, + profileRoot, + relativePath: path.join("worlds", fileName), + name: path.parse(fileName).name || fileName, + details: fileName, + }) + ); + } + for (const fileName of await listFiles(path.join(profileRoot, "OpenAI Settings"), [".json"])) { + const instruction = await scanSettingsJson({ + kind: "instruction", + profileHandle, + profileRoot, + directory: "OpenAI Settings", + fileName, + }); + if (instruction) items.instruction.push(instruction); + const sampler = await scanSettingsJson({ + kind: "sampler", + profileHandle, + profileRoot, + directory: "OpenAI Settings", + fileName, + }); + if (sampler) items.sampler.push(sampler); + } + for (const directory of ["TextGen Settings", "KoboldAI Settings"] as const) { + for (const fileName of await listFiles(path.join(profileRoot, directory), [".json"])) { + const sampler = await scanSettingsJson({ kind: "sampler", profileHandle, profileRoot, directory, fileName }); + if (sampler) items.sampler.push(sampler); + } + } + const characterByBase = new Map(items.character.map((item) => [path.parse(item.relativePath).name, item.id])); + for (const chat of await listChatFiles(path.join(profileRoot, "chats"))) { + const relativePath = path.join("chats", chat.characterBase, chat.fileName); + items.chat.push( + await makeFileItem({ + kind: "chat", + profileHandle, + profileRoot, + relativePath, + name: path.parse(chat.fileName).name || chat.fileName, + details: chat.characterBase, + dependencyIds: characterByBase.get(chat.characterBase) ? [characterByBase.get(chat.characterBase)!] : [], + }) + ); + } + + profiles.push({ + handle: profileHandle, + rootRelativePath: normalizePathForMeta(path.join("data", profileHandle)), + items, + unsupported: {}, + }); + } + + return { rootPath: root, profiles, totals: createTotals(profiles) }; +} diff --git a/server/src/services/sillytavern-import/sillytavern-import-utils.ts b/server/src/services/sillytavern-import/sillytavern-import-utils.ts new file mode 100644 index 00000000..fc5db24f --- /dev/null +++ b/server/src/services/sillytavern-import/sillytavern-import-utils.ts @@ -0,0 +1,70 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { + SillyTavernImportKind, + SillyTavernImportSourceMeta, +} from "@shared/types/sillytavern-import"; + +export const ST_IMPORT_META_KEY = "sillytavernImport"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function asStringRecord(value: unknown): Record { + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string") + ); +} + +export function sha256Text(input: string): string { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +export async function sha256File(filePath: string): Promise { + const buffer = await fs.readFile(filePath); + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +export function createImportItemId(params: { + kind: SillyTavernImportKind; + profileHandle: string; + relativePath: string; +}): string { + return sha256Text(`${params.kind}:${params.profileHandle}:${params.relativePath}`).slice(0, 24); +} + +export function normalizePathForMeta(input: string): string { + return input.split(path.sep).join("/"); +} + +export function sourceMatches(a: unknown, b: SillyTavernImportSourceMeta): boolean { + const meta = isRecord(a) && isRecord(a[ST_IMPORT_META_KEY]) ? a[ST_IMPORT_META_KEY] : a; + if (!isRecord(meta)) return false; + return ( + meta.source === "sillytavern" && + meta.kind === b.kind && + meta.profileHandle === b.profileHandle && + meta.relativePath === b.relativePath && + meta.contentHash === b.contentHash + ); +} + +export function withImportedAt(meta: SillyTavernImportSourceMeta): SillyTavernImportSourceMeta { + return { ...meta, importedAt: new Date().toISOString() }; +} + +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; +} diff --git a/server/src/services/sillytavern-import/sillytavern-import.test.ts b/server/src/services/sillytavern-import/sillytavern-import.test.ts new file mode 100644 index 00000000..2847cbd5 --- /dev/null +++ b/server/src/services/sillytavern-import/sillytavern-import.test.ts @@ -0,0 +1,214 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { ensureInstructionsSchema } from "../../db/ensure-instructions-schema"; +import { listEntityProfiles } from "../chat-core/entity-profiles-repository"; +import { listInstructions } from "../chat-core/instructions-repository"; +import { listUserPersons, updateUserPerson } from "../chat-core/user-persons-repository"; +import { samplersService } from "../samplers.service"; +import { listWorldInfoBooksForIndexing } from "../world-info/world-info-repositories"; + +import { scanSillyTavernImportRoot } from "./sillytavern-import-scanner"; +import { importSillyTavernSelection } from "./sillytavern-importer"; + +describe("sillytavern import", () => { + let tempDir = ""; + let stRoot = ""; + let prevSamplersDir = ""; + let prevSamplersReady: Promise | null = null; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-st-import-")); + stRoot = path.join(tempDir, "SillyTavern"); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + await ensureInstructionsSchema(); + + const samplersStore = samplersService.samplers as unknown as { + dir: string; + ready: Promise; + }; + prevSamplersDir = samplersStore.dir; + prevSamplersReady = samplersStore.ready; + samplersStore.dir = path.join(tempDir, "samplers"); + samplersStore.ready = fs.mkdir(samplersStore.dir, { recursive: true }).then(() => undefined); + + await createFixture(stRoot); + }); + + afterEach(async () => { + resetDbForTests(); + const samplersStore = samplersService.samplers as unknown as { + dir: string; + ready: Promise; + }; + samplersStore.dir = prevSamplersDir; + samplersStore.ready = prevSamplersReady ?? Promise.resolve(); + if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("scans supported SillyTavern profile data", async () => { + const scan = await scanSillyTavernImportRoot(stRoot); + + expect(scan.profiles).toHaveLength(1); + expect(scan.totals).toMatchObject({ + character: 1, + persona: 1, + world_info: 1, + instruction: 1, + sampler: 2, + chat: 1, + }); + }); + + test("imports selected data and skips duplicates on repeat import", async () => { + const scan = await scanSillyTavernImportRoot(stRoot); + const itemIds = scan.profiles.flatMap((profile) => + Object.values(profile.items).flatMap((items) => items.map((item) => item.id)) + ); + + const first = await importSillyTavernSelection({ + rootPath: stRoot, + ownerId: "global", + selection: { itemIds }, + }); + const second = await importSillyTavernSelection({ + rootPath: stRoot, + ownerId: "global", + selection: { itemIds }, + }); + + expect(first.failed).toEqual([]); + expect(first.created.some((item) => item.kind === "character")).toBe(true); + expect(first.created.some((item) => item.kind === "world_info")).toBe(true); + expect(first.created.some((item) => item.kind === "chat")).toBe(true); + expect(second.created).toEqual([]); + expect(second.skipped.every((item) => item.reason === "duplicate")).toBe(true); + + expect(await listEntityProfiles({ ownerId: "global" })).toHaveLength(1); + const persons = await listUserPersons({ ownerId: "global" }); + expect(persons).toHaveLength(1); + expect(persons[0]?.avatarUrl).toMatch(/^\/media\/images\/user-persons\//); + expect(await listWorldInfoBooksForIndexing({ ownerId: "global" })).toHaveLength(2); + expect(await samplersService.samplers.getAll()).toHaveLength(2); + + const instructions = await listInstructions({ ownerId: "global" }); + expect(instructions).toHaveLength(1); + const stInstruction = instructions[0]; + expect(stInstruction?.kind).toBe("st_base"); + if (stInstruction?.kind === "st_base") { + expect(stInstruction.stBase.rawPreset.proxy_password).toBeUndefined(); + expect(stInstruction.stBase.rawPreset.custom_url).toBeUndefined(); + } + }); + + test("repairs legacy imported persona avatar paths on duplicate import", async () => { + const scan = await scanSillyTavernImportRoot(stRoot); + const personaId = scan.profiles[0]?.items.persona[0]?.id; + expect(personaId).toBeTruthy(); + + await importSillyTavernSelection({ + rootPath: stRoot, + ownerId: "global", + selection: { itemIds: [personaId!] }, + }); + const [created] = await listUserPersons({ ownerId: "global" }); + await updateUserPerson({ id: created!.id, avatarUrl: path.join(stRoot, "data", "default-user", "User Avatars", "persona.png") }); + + const second = await importSillyTavernSelection({ + rootPath: stRoot, + ownerId: "global", + selection: { itemIds: [personaId!] }, + }); + + const [repaired] = await listUserPersons({ ownerId: "global" }); + expect(second.created).toEqual([]); + expect(second.skipped[0]?.reason).toBe("duplicate"); + expect(repaired?.avatarUrl).toMatch(/^\/media\/images\/user-persons\//); + }); +}); + +async function createFixture(root: string): Promise { + const profileRoot = path.join(root, "data", "default-user"); + await fs.mkdir(path.join(profileRoot, "characters"), { recursive: true }); + await fs.mkdir(path.join(profileRoot, "worlds"), { recursive: true }); + await fs.mkdir(path.join(profileRoot, "OpenAI Settings"), { recursive: true }); + await fs.mkdir(path.join(profileRoot, "TextGen Settings"), { recursive: true }); + await fs.mkdir(path.join(profileRoot, "User Avatars"), { recursive: true }); + await fs.mkdir(path.join(profileRoot, "chats", "Alice"), { recursive: true }); + await fs.writeFile(path.join(profileRoot, "User Avatars", "persona.png"), "not-a-real-png"); + + await writeJson(path.join(profileRoot, "characters", "Alice.json"), { + spec: "chara_card_v2", + spec_version: "2.0", + data: { + name: "Alice", + description: "Alice description", + personality: "", + scenario: "", + first_mes: "Hello", + mes_example: "", + alternate_greetings: [], + tags: [], + character_book: { + name: "Alice book", + entries: { + "0": { uid: 0, key: ["alice"], content: "Alice lore", comment: "Main" }, + }, + }, + }, + }); + await writeJson(path.join(profileRoot, "worlds", "World.json"), { + name: "World", + entries: { + "0": { uid: 0, key: ["world"], content: "World lore", comment: "World" }, + }, + }); + await writeJson(path.join(profileRoot, "OpenAI Settings", "Preset.json"), { + chat_completion_source: "openai", + temperature: 0.7, + openai_max_tokens: 200, + proxy_password: "secret", + custom_url: "https://secret.invalid", + prompts: [{ identifier: "main", name: "Main", role: "system", content: "System" }], + prompt_order: [{ character_id: 100001, order: [{ identifier: "main", enabled: true }] }], + }); + await writeJson(path.join(profileRoot, "TextGen Settings", "Sampler.json"), { + temp: 0.4, + top_p: 0.9, + rep_pen: 1.1, + }); + await writeJson(path.join(profileRoot, "settings.json"), { + power_user: { + personas: { "persona.png": "Persona" }, + persona_descriptions: { + "persona.png": { description: "Persona description", position: 0, connections: [] }, + }, + }, + }); + await fs.writeFile( + path.join(profileRoot, "chats", "Alice", "Chat.jsonl"), + [ + JSON.stringify({ user_name: "Persona", character_name: "Alice" }), + JSON.stringify({ name: "Persona", is_user: true, is_system: false, mes: "Hi" }), + JSON.stringify({ + name: "Alice", + is_user: false, + is_system: false, + mes: "Hello", + swipes: ["Hello", "Hi there"], + swipe_id: 1, + }), + ].join("\n") + ); +} + +async function writeJson(filePath: string, payload: unknown): Promise { + await fs.writeFile(filePath, JSON.stringify(payload, null, 2)); +} diff --git a/server/src/services/sillytavern-import/sillytavern-importer.ts b/server/src/services/sillytavern-import/sillytavern-importer.ts new file mode 100644 index 00000000..97ea670f --- /dev/null +++ b/server/src/services/sillytavern-import/sillytavern-importer.ts @@ -0,0 +1,484 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { normalizeCharSpec } from "../../chat-core/charspec/normalize"; +import { extractCharSpecFromPngBuffer } from "../../chat-core/charspec/png"; +import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { initDb } from "../../db/client"; +import { chats, messageVariants } from "../../db/schema"; +import { createDataPath } from "../../utils"; +import { + createChat, + createChatMessage, + createImportedAssistantMessage, +} from "../chat-core/chats-repository"; +import { createEntityProfile, listEntityProfiles } from "../chat-core/entity-profiles-repository"; +import { + createStBaseConfigFromPreset, + detectStChatCompletionPreset, +} from "../chat-core/instruction-st-base"; +import { createInstruction, listInstructions } from "../chat-core/instructions-repository"; +import { createUserPerson, listUserPersons, updateUserPerson } from "../chat-core/user-persons-repository"; +import { resolveImportedSamplerPresetName, samplersService } from "../samplers.service"; +import { + convertWorldInfoImport, + type WorldInfoImportFormat, +} from "../world-info/world-info-converters"; +import { + createWorldInfoBook, + listWorldInfoBooksForIndexing, + replaceWorldInfoBindings, +} from "../world-info/world-info-repositories"; + +import { scanSillyTavernImportRoot } from "./sillytavern-import-scanner"; +import { + asString, + isRecord, + readJsonFile, + sourceMatches, + ST_IMPORT_META_KEY, + toErrorMessage, + withImportedAt, +} from "./sillytavern-import-utils"; + +import type { NormalizedCharSpec } from "../../chat-core/charspec/types"; +import type { SamplerItemSettingsType, SamplersItemType } from "@shared/types/samplers"; +import type { + SillyTavernImportCreatedItem, + SillyTavernImportFailedItem, + SillyTavernImportRequest, + SillyTavernImportResult, + SillyTavernImportScanItem, + SillyTavernImportSourceMeta, +} from "@shared/types/sillytavern-import"; + +type ImportContext = { + rootPath: string; + ownerId: string; + selectedIds: Set; + itemById: Map; + profileRootByHandle: Map; + created: SillyTavernImportCreatedItem[]; + skipped: SillyTavernImportResult["skipped"]; + failed: SillyTavernImportFailedItem[]; + entityProfileByItemId: Map; +}; + +type ChatMessageInput = { + name: string; + is_user: boolean; + is_system: boolean; + mes: string; + swipes?: string[]; + swipe_id?: number; +}; + +function toSourceMeta(item: SillyTavernImportScanItem): SillyTavernImportSourceMeta { + const { id: _id, name: _name, details: _details, dependencyIds: _dependencyIds, ...meta } = item; + return meta; +} + +function getProfileRoot(ctx: ImportContext, item: SillyTavernImportScanItem): string { + const root = ctx.profileRootByHandle.get(item.profileHandle); + if (!root) throw new Error(`Profile not found: ${item.profileHandle}`); + return root; +} + +function getFilePath(ctx: ImportContext, item: SillyTavernImportScanItem): string { + return path.join(getProfileRoot(ctx, item), item.relativePath); +} + +async function readCharacter(filePath: string): Promise<{ + raw: unknown; + normalized: NormalizedCharSpec; +}> { + const ext = path.extname(filePath).toLowerCase(); + const raw = + ext === ".png" + ? await extractCharSpecFromPngBuffer(await fs.readFile(filePath)) + : await readJsonFile(filePath); + return { raw, normalized: normalizeCharSpec(raw) }; +} + +async function saveCharacterAvatar(filePath: string): Promise { + if (path.extname(filePath).toLowerCase() !== ".png") return undefined; + const dir = createDataPath("media", "images", "entity-profiles"); + await fs.mkdir(dir, { recursive: true }); + const filename = `${randomUUID()}.png`; + await fs.copyFile(filePath, path.join(dir, filename)); + return `/media/images/entity-profiles/${filename}`; +} + +async function savePersonaAvatar(profileRoot: string, avatarFile: string): Promise { + const safeName = path.basename(avatarFile); + if (!safeName) return undefined; + const sourcePath = path.join(profileRoot, "User Avatars", safeName); + const ext = path.extname(safeName).toLowerCase(); + const allowedExtensions = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]); + if (!allowedExtensions.has(ext)) return undefined; + try { + await fs.access(sourcePath); + } catch { + return undefined; + } + const dir = createDataPath("media", "images", "user-persons"); + await fs.mkdir(dir, { recursive: true }); + const filename = `${randomUUID()}${ext}`; + await fs.copyFile(sourcePath, path.join(dir, filename)); + return `/media/images/user-persons/${filename}`; +} + +async function findExistingEntityId(ownerId: string, source: SillyTavernImportSourceMeta): Promise { + const profiles = await listEntityProfiles({ ownerId }); + return profiles.find((profile) => sourceMatches(profile.meta, source))?.id ?? null; +} + +async function importEmbeddedCharacterBook( + ctx: ImportContext, + item: SillyTavernImportScanItem, + profileId: string, + characterBook: unknown +): Promise { + if (!isRecord(characterBook)) return; + const meta = { + ...toSourceMeta(item), + kind: "world_info" as const, + relativePath: `${item.relativePath}#character_book`, + }; + const existing = await listWorldInfoBooksForIndexing({ ownerId: ctx.ownerId }); + const found = existing.find((book) => + sourceMatches(isRecord(book.extensions) ? book.extensions[ST_IMPORT_META_KEY] : null, meta) + ); + if (found) { + await replaceWorldInfoBindings({ + ownerId: ctx.ownerId, + scope: "entity_profile", + scopeId: profileId, + items: [{ bookId: found.id, bindingRole: "primary", meta }], + }); + return; + } + const converted = convertWorldInfoImport({ + raw: characterBook, + format: "character_book", + fallbackName: `${item.name} lorebook`, + }); + const book = await createWorldInfoBook({ + ownerId: ctx.ownerId, + name: converted.name, + data: converted.data, + extensions: { [ST_IMPORT_META_KEY]: withImportedAt(meta) }, + source: "imported", + }); + await replaceWorldInfoBindings({ + ownerId: ctx.ownerId, + scope: "entity_profile", + scopeId: profileId, + items: [{ bookId: book.id, bindingRole: "primary", meta }], + }); + ctx.created.push({ kind: "world_info", itemId: `${item.id}:character_book`, targetId: book.id, name: book.name }); +} + +async function importCharacter(ctx: ImportContext, item: SillyTavernImportScanItem, dependency = false): Promise { + const source = toSourceMeta(item); + const existingId = await findExistingEntityId(ctx.ownerId, source); + if (existingId) { + ctx.entityProfileByItemId.set(item.id, existingId); + if (!dependency) ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "duplicate" }); + return existingId; + } + const filePath = getFilePath(ctx, item); + const { normalized } = await readCharacter(filePath); + const name = normalized.name.trim() || item.name; + const avatarAssetId = await saveCharacterAvatar(filePath); + const profile = await createEntityProfile({ + ownerId: ctx.ownerId, + name, + kind: "CharSpec", + spec: { ...normalized, name }, + avatarAssetId, + meta: { [ST_IMPORT_META_KEY]: withImportedAt(source) }, + }); + ctx.entityProfileByItemId.set(item.id, profile.id); + ctx.created.push({ kind: "character", itemId: item.id, targetId: profile.id, name: profile.name }); + await importEmbeddedCharacterBook(ctx, item, profile.id, normalized.character_book); + return profile.id; +} + +async function importWorldInfo(ctx: ImportContext, item: SillyTavernImportScanItem): Promise { + const source = toSourceMeta(item); + const existing = await listWorldInfoBooksForIndexing({ ownerId: ctx.ownerId }); + if (existing.some((book) => sourceMatches(isRecord(book.extensions) ? book.extensions[ST_IMPORT_META_KEY] : null, source))) { + ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "duplicate" }); + return; + } + const raw = await readJsonFile(getFilePath(ctx, item)); + const converted = convertWorldInfoImport({ + raw, + format: "auto" satisfies WorldInfoImportFormat, + fallbackName: item.name, + }); + const book = await createWorldInfoBook({ + ownerId: ctx.ownerId, + name: converted.name, + data: converted.data, + extensions: { [ST_IMPORT_META_KEY]: withImportedAt(source) }, + source: "imported", + }); + ctx.created.push({ kind: "world_info", itemId: item.id, targetId: book.id, name: book.name }); +} + +async function importInstruction(ctx: ImportContext, item: SillyTavernImportScanItem): Promise { + const source = toSourceMeta(item); + const existing = await listInstructions({ ownerId: ctx.ownerId }); + if (existing.some((instruction) => sourceMatches(instruction.meta, source))) { + ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "duplicate" }); + return; + } + const preset = await readJsonFile(getFilePath(ctx, item)); + if (!detectStChatCompletionPreset(preset)) throw new Error("Not a SillyTavern chat-completion preset."); + const instruction = await createInstruction({ + ownerId: ctx.ownerId, + name: item.name, + kind: "st_base", + engine: "liquidjs", + stBase: createStBaseConfigFromPreset({ + preset, + fileName: path.basename(item.relativePath), + sensitiveImportMode: "remove", + }), + meta: { [ST_IMPORT_META_KEY]: withImportedAt(source) }, + }); + ctx.created.push({ kind: "instruction", itemId: item.id, targetId: instruction.id, name: instruction.name }); +} + +function toSamplerSettings(raw: unknown): SamplerItemSettingsType { + const src = isRecord(raw) ? raw : {}; + const settings: SamplerItemSettingsType = { sillytavernRaw: src }; + const numeric = (key: string): number | undefined => (typeof src[key] === "number" ? src[key] : undefined); + settings.temperature = numeric("temperature") ?? numeric("temp"); + settings.topP = numeric("top_p"); + settings.topK = numeric("top_k"); + settings.topA = numeric("top_a"); + settings.minP = numeric("min_p"); + settings.frequencyPenalty = numeric("frequency_penalty"); + settings.presencePenalty = numeric("presence_penalty"); + settings.repetitionPenalty = numeric("repetition_penalty") ?? numeric("rep_pen"); + settings.maxTokens = numeric("openai_max_tokens") ?? numeric("max_new_tokens"); + settings.seed = numeric("seed"); + return Object.fromEntries(Object.entries(settings).filter(([, value]) => typeof value !== "undefined")) as SamplerItemSettingsType; +} + +async function importSampler(ctx: ImportContext, item: SillyTavernImportScanItem): Promise { + const source = toSourceMeta(item); + const existing = await samplersService.samplers.getAll(); + if (existing.some((sampler) => sourceMatches(sampler.settings?.[ST_IMPORT_META_KEY], source))) { + ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "duplicate" }); + return; + } + const names = existing.map((sampler) => sampler.name); + const now = new Date().toISOString(); + const raw = await readJsonFile(getFilePath(ctx, item)); + const sampler: SamplersItemType = { + id: randomUUID(), + name: resolveImportedSamplerPresetName(item.name, names), + settings: { ...toSamplerSettings(raw), [ST_IMPORT_META_KEY]: withImportedAt(source) }, + createdAt: now, + updatedAt: now, + }; + await samplersService.samplers.create(sampler); + ctx.created.push({ kind: "sampler", itemId: item.id, targetId: sampler.id, name: sampler.name }); +} + +function parseChatMessage(value: unknown): ChatMessageInput | null { + if (!isRecord(value)) return null; + return { + name: asString(value.name), + is_user: value.is_user === true, + is_system: value.is_system === true, + mes: asString(value.mes), + swipes: Array.isArray(value.swipes) ? value.swipes.map(asString) : undefined, + swipe_id: typeof value.swipe_id === "number" ? value.swipe_id : undefined, + }; +} + +async function readChatMessages(filePath: string): Promise<{ meta: Record; messages: ChatMessageInput[] }> { + const text = await fs.readFile(filePath, "utf8"); + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const meta = lines[0] ? safeJsonParse(lines[0], {}) : {}; + const messages = lines.slice(1).map((line) => parseChatMessage(safeJsonParse(line, null))).filter((item): item is ChatMessageInput => Boolean(item)); + return { meta: isRecord(meta) ? meta : {}, messages }; +} + +async function chatExists(ctx: ImportContext, source: SillyTavernImportSourceMeta): Promise { + const db = await initDb(); + const rows = await db.select({ metaJson: chats.metaJson }).from(chats); + return rows.some((row) => sourceMatches(safeJsonParse(row.metaJson, null), source)); +} + +async function addSwipeVariants(params: { + ownerId: string; + messageId: string; + swipes: string[]; + selectedIndex: number; +}): Promise { + if (params.swipes.length <= 1) return; + const db = await initDb(); + const now = new Date(); + const extraVariants = params.swipes + .map((text, idx) => ({ text, idx })) + .filter((item) => item.idx !== params.selectedIndex) + .map((item) => ({ + id: randomUUID(), + ownerId: params.ownerId, + messageId: params.messageId, + createdAt: now, + kind: "import" as const, + promptText: item.text, + blocksJson: "[]", + metaJson: safeJsonStringify({ importedAt: now.toISOString(), swipeIndex: item.idx }), + isSelected: false, + })); + if (extraVariants.length > 0) await db.insert(messageVariants).values(extraVariants); +} + +async function importChat(ctx: ImportContext, item: SillyTavernImportScanItem): Promise { + const source = toSourceMeta(item); + if (await chatExists(ctx, source)) { + ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "duplicate" }); + return; + } + const dependencyId = item.dependencyIds?.[0]; + const dependency = dependencyId ? ctx.itemById.get(dependencyId) : null; + if (!dependency) { + ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "unsupported_dependency" }); + return; + } + const entityProfileId = + ctx.entityProfileByItemId.get(dependency.id) ?? (await importCharacter(ctx, dependency, true)); + if (!entityProfileId) throw new Error("Unable to resolve imported entity profile for chat."); + const { chat, mainBranch } = await createChat({ + ownerId: ctx.ownerId, + entityProfileId, + title: item.name, + meta: { [ST_IMPORT_META_KEY]: withImportedAt(source) }, + }); + const { meta, messages } = await readChatMessages(getFilePath(ctx, item)); + void meta; + for (const message of messages) { + if (message.is_user) { + await createChatMessage({ + ownerId: ctx.ownerId, + chatId: chat.id, + branchId: mainBranch.id, + role: "user", + promptText: message.mes, + meta: { [ST_IMPORT_META_KEY]: withImportedAt(source), stName: message.name }, + }); + continue; + } + const selectedIndex = typeof message.swipe_id === "number" ? Math.max(0, message.swipe_id) : 0; + const swipes = message.swipes && message.swipes.length > 0 ? message.swipes : [message.mes]; + const selectedText = swipes[selectedIndex] ?? swipes[0] ?? message.mes; + const created = await createImportedAssistantMessage({ + ownerId: ctx.ownerId, + chatId: chat.id, + branchId: mainBranch.id, + promptText: selectedText, + meta: { [ST_IMPORT_META_KEY]: withImportedAt(source), stName: message.name }, + }); + if (swipes.length > 1) { + await addSwipeVariants({ + ownerId: ctx.ownerId, + messageId: created.assistantMessageId, + swipes, + selectedIndex, + }); + } + } + ctx.created.push({ kind: "chat", itemId: item.id, targetId: chat.id, name: chat.title }); +} + +async function importPersona(ctx: ImportContext, item: SillyTavernImportScanItem): Promise { + const source = toSourceMeta(item); + const existing = await listUserPersons({ ownerId: ctx.ownerId }); + const existingPerson = existing.find((person) => sourceMatches(person.contentTypeExtended, source)); + const profileRoot = getProfileRoot(ctx, item); + const avatarFile = item.relativePath.split("#persona:")[1] ?? ""; + if (existingPerson) { + if (avatarFile && !existingPerson.avatarUrl?.startsWith("/media/")) { + const avatarUrl = await savePersonaAvatar(profileRoot, avatarFile); + if (avatarUrl) await updateUserPerson({ id: existingPerson.id, avatarUrl }); + } + ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "duplicate" }); + return; + } + const settings = await readJsonFile(path.join(profileRoot, "settings.json")); + const powerUser = isRecord(settings) && isRecord(settings.power_user) ? settings.power_user : {}; + const descriptions = isRecord(powerUser.persona_descriptions) ? powerUser.persona_descriptions : {}; + const rawDescription = isRecord(descriptions[avatarFile]) ? asString(descriptions[avatarFile].description) : ""; + const avatarUrl = avatarFile ? await savePersonaAvatar(profileRoot, avatarFile) : undefined; + const person = await createUserPerson({ + ownerId: ctx.ownerId, + name: item.name, + avatarUrl, + type: "extended", + contentTypeDefault: rawDescription, + contentTypeExtended: { + version: 2, + baseDescription: rawDescription, + settings: { additionalJoiner: "\n\n", wrapperEnabled: false, wrapperTemplate: "" }, + blocks: [], + [ST_IMPORT_META_KEY]: withImportedAt(source), + }, + }); + ctx.created.push({ kind: "persona", itemId: item.id, targetId: person.id, name: person.name }); +} + +async function runOne(ctx: ImportContext, item: SillyTavernImportScanItem): Promise { + if (!ctx.selectedIds.has(item.id)) { + ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "not_selected" }); + return; + } + try { + if (item.kind === "character") await importCharacter(ctx, item); + else if (item.kind === "persona") await importPersona(ctx, item); + else if (item.kind === "world_info") await importWorldInfo(ctx, item); + else if (item.kind === "instruction") await importInstruction(ctx, item); + else if (item.kind === "sampler") await importSampler(ctx, item); + else await importChat(ctx, item); + } catch (error) { + ctx.failed.push({ kind: item.kind, itemId: item.id, name: item.name, error: toErrorMessage(error) }); + } +} + +export async function importSillyTavernSelection(params: SillyTavernImportRequest): Promise { + const scan = await scanSillyTavernImportRoot(params.rootPath); + const itemById = new Map(); + const profileRootByHandle = new Map(); + for (const profile of scan.profiles) { + profileRootByHandle.set(profile.handle, path.join(scan.rootPath, profile.rootRelativePath)); + for (const items of Object.values(profile.items)) { + for (const item of items) itemById.set(item.id, item); + } + } + const ctx: ImportContext = { + rootPath: scan.rootPath, + ownerId: params.ownerId ?? "global", + selectedIds: new Set(params.selection.itemIds), + itemById, + profileRootByHandle, + created: [], + skipped: [], + failed: [], + entityProfileByItemId: new Map(), + }; + const orderedKinds = ["character", "persona", "world_info", "instruction", "sampler", "chat"] as const; + for (const kind of orderedKinds) { + for (const item of itemById.values()) { + if (item.kind === kind && ctx.selectedIds.has(item.id)) await runOne(ctx, item); + } + } + return { created: ctx.created, skipped: ctx.skipped, failed: ctx.failed }; +} diff --git a/shared/types/sillytavern-import.ts b/shared/types/sillytavern-import.ts new file mode 100644 index 00000000..f8e4aa01 --- /dev/null +++ b/shared/types/sillytavern-import.ts @@ -0,0 +1,79 @@ +export type SillyTavernImportKind = + | "character" + | "persona" + | "world_info" + | "instruction" + | "sampler" + | "chat"; + +export type SillyTavernImportSourceMeta = { + source: "sillytavern"; + kind: SillyTavernImportKind; + profileHandle: string; + relativePath: string; + contentHash: string; + size: number; + mtimeMs: number; + importedAt?: string; +}; + +export type SillyTavernImportScanItem = SillyTavernImportSourceMeta & { + id: string; + name: string; + details?: string; + dependencyIds?: string[]; +}; + +export type SillyTavernImportScanProfile = { + handle: string; + rootRelativePath: string; + items: Record; + unsupported: Record; +}; + +export type SillyTavernImportScanResult = { + rootPath: string; + profiles: SillyTavernImportScanProfile[]; + totals: Record; +}; + +export type SillyTavernImportSelection = { + itemIds: string[]; +}; + +export type SillyTavernImportScanRequest = { + rootPath: string; +}; + +export type SillyTavernImportRequest = { + rootPath: string; + selection: SillyTavernImportSelection; + ownerId?: string; +}; + +export type SillyTavernImportCreatedItem = { + kind: SillyTavernImportKind; + itemId: string; + targetId: string; + name: string; +}; + +export type SillyTavernImportSkippedItem = { + kind: SillyTavernImportKind; + itemId: string; + name: string; + reason: "duplicate" | "not_selected" | "unsupported_dependency"; +}; + +export type SillyTavernImportFailedItem = { + kind: SillyTavernImportKind; + itemId: string; + name: string; + error: string; +}; + +export type SillyTavernImportResult = { + created: SillyTavernImportCreatedItem[]; + skipped: SillyTavernImportSkippedItem[]; + failed: SillyTavernImportFailedItem[]; +}; diff --git a/web/src/api/sillytavern-import.ts b/web/src/api/sillytavern-import.ts new file mode 100644 index 00000000..4a755b98 --- /dev/null +++ b/web/src/api/sillytavern-import.ts @@ -0,0 +1,22 @@ +import { apiJson } from './api-json'; + +import type { + SillyTavernImportRequest, + SillyTavernImportResult, + SillyTavernImportScanRequest, + SillyTavernImportScanResult, +} from '@shared/types/sillytavern-import'; + +export async function scanSillyTavernImport(params: SillyTavernImportScanRequest): Promise { + return apiJson('/sillytavern-import/scan', { + method: 'POST', + body: JSON.stringify(params), + }); +} + +export async function importSillyTavernSelection(params: SillyTavernImportRequest): Promise { + return apiJson('/sillytavern-import/import', { + method: 'POST', + body: JSON.stringify(params), + }); +} diff --git a/web/src/features/sidebars/app-settings/index.tsx b/web/src/features/sidebars/app-settings/index.tsx index c8c7efa8..ba36f23f 100644 --- a/web/src/features/sidebars/app-settings/index.tsx +++ b/web/src/features/sidebars/app-settings/index.tsx @@ -11,6 +11,7 @@ import { Drawer } from "@ui/drawer"; import { BackgroundsTab } from "./backgrounds-tab"; import { DebugTab } from "./debug-tab"; import { GeneralTab } from "./general-tab"; +import { SillyTavernImportTab } from "./sillytavern-import-tab"; import { ThemingTab } from "./theming-tab"; import { type AppSettingsTab } from "./types"; @@ -49,6 +50,7 @@ export const AppSettingsSidebar: React.FC = () => { {t("appSettings.tabs.general")} {t("appSettings.tabs.theming")} {t("appSettings.tabs.backgrounds")} + {t("appSettings.tabs.sillytavern")} {t("appSettings.tabs.debug")} @@ -61,6 +63,9 @@ export const AppSettingsSidebar: React.FC = () => { + + + diff --git a/web/src/features/sidebars/app-settings/sillytavern-import-tab.tsx b/web/src/features/sidebars/app-settings/sillytavern-import-tab.tsx new file mode 100644 index 00000000..4aa03a87 --- /dev/null +++ b/web/src/features/sidebars/app-settings/sillytavern-import-tab.tsx @@ -0,0 +1,217 @@ +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Paper, + ScrollArea, + Stack, + Text, + TextInput, + Title, +} from '@mantine/core'; +import { useUnit } from 'effector-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuDownload, LuRefreshCw } from 'react-icons/lu'; + +import { + $sillyTavernImportResult, + $sillyTavernImportError, + $sillyTavernIsBusy, + $sillyTavernRootPath, + $sillyTavernScanError, + $sillyTavernScanResult, + $sillyTavernSelectedCount, + $sillyTavernSelectedItemIds, + SILLYTAVERN_IMPORT_KINDS, + importSillyTavernSelectionFx, + listItems, + listProfileItems, + scanSillyTavernImportFx, + sillyTavernImportRequested, + sillyTavernItemToggled, + sillyTavernKindToggled, + sillyTavernProfileToggled, + sillyTavernRootChanged, + sillyTavernScanRequested, +} from '@model/sillytavern-import'; + +import type { SillyTavernImportKind, SillyTavernImportScanItem } from '@shared/types/sillytavern-import'; + +function isEverySelected(items: SillyTavernImportScanItem[], selected: Set): boolean { + return items.length > 0 && items.every((item) => selected.has(item.id)); +} + +function isSomeSelected(items: SillyTavernImportScanItem[], selected: Set): boolean { + return items.some((item) => selected.has(item.id)); +} + +export const SillyTavernImportTab = () => { + const { t } = useTranslation(); + const [ + rootPath, + scan, + selectedIds, + selectedCount, + importResult, + isBusy, + scanPending, + importPending, + scanError, + importError, + ] = useUnit([ + $sillyTavernRootPath, + $sillyTavernScanResult, + $sillyTavernSelectedItemIds, + $sillyTavernSelectedCount, + $sillyTavernImportResult, + $sillyTavernIsBusy, + scanSillyTavernImportFx.pending, + importSillyTavernSelectionFx.pending, + $sillyTavernScanError, + $sillyTavernImportError, + ]); + + const allItems = useMemo(() => listItems(scan), [scan]); + const scanErrorText = scanError; + const importErrorText = importError; + + return ( + + + + sillyTavernRootChanged(event.currentTarget.value)} + disabled={isBusy} + /> + + + + {t('appSettings.sillytavern.selectedCount', { count: selectedCount })} + + + + + {scanErrorText ? {scanErrorText} : null} + {importErrorText ? {importErrorText} : null} + + {scan ? ( + + + + {t('appSettings.sillytavern.scanTitle')} + + {t('appSettings.sillytavern.totalCount', { count: allItems.length })} + + + + + {SILLYTAVERN_IMPORT_KINDS.map((kind) => { + const items = allItems.filter((item) => item.kind === kind); + return ( + sillyTavernKindToggled({ kind, checked: event.currentTarget.checked })} + /> + ); + })} + + + + + {scan.profiles.map((profile) => { + const profileItems = listProfileItems(profile); + return ( + + + + + sillyTavernProfileToggled({ + profileHandle: profile.handle, + checked: event.currentTarget.checked, + }) + } + /> + {profileItems.length} + + + {SILLYTAVERN_IMPORT_KINDS.map((kind: SillyTavernImportKind) => { + const items = profile.items[kind]; + if (items.length === 0) return null; + return ( + + + {t(`appSettings.sillytavern.kindLabels.${kind}`)}: {items.length} + + {items.map((item) => ( + + sillyTavernItemToggled({ + itemId: item.id, + checked: event.currentTarget.checked, + }) + } + /> + ))} + + ); + })} + + + ); + })} + + + + + ) : null} + + {importResult ? ( + + + {t('appSettings.sillytavern.resultTitle')} + + {t('appSettings.sillytavern.resultSummary', { + created: importResult.created.length, + skipped: importResult.skipped.length, + failed: importResult.failed.length, + })} + + {importResult.failed.slice(0, 8).map((item) => ( + + {item.name}: {item.error} + + ))} + + + ) : null} + + ); +}; diff --git a/web/src/features/sidebars/app-settings/types.ts b/web/src/features/sidebars/app-settings/types.ts index 9710bd45..658e12c4 100644 --- a/web/src/features/sidebars/app-settings/types.ts +++ b/web/src/features/sidebars/app-settings/types.ts @@ -1 +1 @@ -export type AppSettingsTab = "general" | "theming" | "backgrounds" | "debug"; +export type AppSettingsTab = "general" | "theming" | "backgrounds" | "sillytavern" | "debug"; diff --git a/web/src/i18n/resources/en/appSettings.ts b/web/src/i18n/resources/en/appSettings.ts index b71d7926..ff5c8b44 100644 --- a/web/src/i18n/resources/en/appSettings.ts +++ b/web/src/i18n/resources/en/appSettings.ts @@ -4,6 +4,7 @@ general: 'General', theming: 'Theming', backgrounds: 'Backgrounds', + sillytavern: 'SillyTavern', debug: 'Debug', }, sections: { @@ -167,6 +168,34 @@ deleteFailed: 'Failed to delete background', }, }, + sillytavern: { + rootPath: 'SillyTavern root path', + scanTitle: 'Detected data', + resultTitle: 'Import result', + selectedCount: 'Selected: {{count}}', + totalCount: 'Total: {{count}}', + resultSummary: 'Created: {{created}}, skipped: {{skipped}}, failed: {{failed}}', + actions: { + scan: 'Scan', + importSelected: 'Import selected', + }, + kinds: { + character: 'Cards ({{count}})', + persona: 'Personas ({{count}})', + world_info: 'Lorebooks ({{count}})', + instruction: 'ST presets ({{count}})', + sampler: 'Samplers ({{count}})', + chat: 'Chats ({{count}})', + }, + kindLabels: { + character: 'Cards', + persona: 'Personas', + world_info: 'Lorebooks', + instruction: 'ST presets', + sampler: 'Samplers', + chat: 'Chats', + }, + }, language: { label: 'Language', }, diff --git a/web/src/i18n/resources/ru/appSettings.ts b/web/src/i18n/resources/ru/appSettings.ts index 3ce08d2e..b0c5ac87 100644 --- a/web/src/i18n/resources/ru/appSettings.ts +++ b/web/src/i18n/resources/ru/appSettings.ts @@ -4,6 +4,7 @@ general: 'Основные', theming: 'Темизация', backgrounds: 'Фоны', + sillytavern: 'SillyTavern', debug: 'Debug', }, sections: { @@ -167,6 +168,34 @@ deleteFailed: 'Не удалось удалить фон', }, }, + sillytavern: { + rootPath: 'Путь к корню SillyTavern', + scanTitle: 'Найденные данные', + resultTitle: 'Результат импорта', + selectedCount: 'Выбрано: {{count}}', + totalCount: 'Всего: {{count}}', + resultSummary: 'Создано: {{created}}, пропущено: {{skipped}}, ошибок: {{failed}}', + actions: { + scan: 'Сканировать', + importSelected: 'Импортировать выбранное', + }, + kinds: { + character: 'Карточки ({{count}})', + persona: 'Персоны ({{count}})', + world_info: 'Лорабуки ({{count}})', + instruction: 'Пресеты ST ({{count}})', + sampler: 'Сэмплеры ({{count}})', + chat: 'Чаты ({{count}})', + }, + kindLabels: { + character: 'Карточки', + persona: 'Персоны', + world_info: 'Лорабуки', + instruction: 'Пресеты ST', + sampler: 'Сэмплеры', + chat: 'Чаты', + }, + }, language: { label: 'Язык', }, diff --git a/web/src/model/sillytavern-import/index.test.ts b/web/src/model/sillytavern-import/index.test.ts new file mode 100644 index 00000000..cc984630 --- /dev/null +++ b/web/src/model/sillytavern-import/index.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'vitest'; + +import { + applyKindSelection, + applyProfileSelection, + listItems, +} from './index'; + +import type { SillyTavernImportKind, SillyTavernImportScanResult } from '@shared/types/sillytavern-import'; + +describe('sillytavern import model', () => { + test('lists scan items and supports kind/profile selection toggles', async () => { + const scan = makeScan(); + const initial = new Set(listItems(scan).map((item) => item.id)); + + expect(listItems(scan).map((item) => item.id)).toEqual(['char-1', 'world-1', 'chat-1']); + expect(initial.size).toBe(3); + + const withoutWorld = applyKindSelection({ + scan, + selectedIds: initial, + kind: 'world_info', + checked: false, + }); + expect(Array.from(withoutWorld).sort()).toEqual(['char-1', 'chat-1']); + + const withoutProfile = applyProfileSelection({ + scan, + selectedIds: withoutWorld, + profileHandle: 'default-user', + checked: false, + }); + expect(withoutProfile.size).toBe(0); + }); +}); + +function makeScan(): SillyTavernImportScanResult { + return { + rootPath: 'F:\\SillyTavern', + totals: { + character: 1, + persona: 0, + world_info: 1, + instruction: 0, + sampler: 0, + chat: 1, + }, + profiles: [ + { + handle: 'default-user', + rootRelativePath: 'data/default-user', + unsupported: {}, + items: { + character: [makeItem('char-1', 'character')], + persona: [], + world_info: [makeItem('world-1', 'world_info')], + instruction: [], + sampler: [], + chat: [makeItem('chat-1', 'chat')], + }, + }, + ], + }; +} + +function makeItem(id: string, kind: SillyTavernImportKind) { + return { + id, + source: 'sillytavern' as const, + kind, + profileHandle: 'default-user', + relativePath: `${id}.json`, + contentHash: id, + size: 1, + mtimeMs: 1, + name: id, + }; +} diff --git a/web/src/model/sillytavern-import/index.ts b/web/src/model/sillytavern-import/index.ts new file mode 100644 index 00000000..1b3f30f7 --- /dev/null +++ b/web/src/model/sillytavern-import/index.ts @@ -0,0 +1,185 @@ +import { combine, createEffect, createEvent, createStore, sample } from 'effector'; + +import { + importSillyTavernSelection, + scanSillyTavernImport, +} from '../../api/sillytavern-import'; +import { loadEntityProfilesFx } from '../chat-core'; +import { loadInstructionsFx } from '../instructions'; +import { samplersModel } from '../samplers'; +import { userPersonsModel } from '../user-persons'; +import { loadWorldInfoBooksFx, loadWorldInfoEntityBindingsFx } from '../world-info'; + +import type { + SillyTavernImportKind, + SillyTavernImportResult, + SillyTavernImportScanItem, + SillyTavernImportScanResult, +} from '@shared/types/sillytavern-import'; + +const DEFAULT_ROOT = 'F:\\SillyTavern'; +const DEFAULT_OWNER_ID = 'global'; + +export const SILLYTAVERN_IMPORT_KINDS: SillyTavernImportKind[] = [ + 'character', + 'persona', + 'world_info', + 'instruction', + 'sampler', + 'chat', +]; + +export const sillyTavernRootChanged = createEvent(); +export const sillyTavernScanRequested = createEvent(); +export const sillyTavernImportRequested = createEvent(); +export const sillyTavernItemToggled = createEvent<{ itemId: string; checked: boolean }>(); +export const sillyTavernKindToggled = createEvent<{ kind: SillyTavernImportKind; checked: boolean }>(); +export const sillyTavernProfileToggled = createEvent<{ profileHandle: string; checked: boolean }>(); +export const sillyTavernSelectionCleared = createEvent(); +const sillyTavernSelectionReplaced = createEvent>(); + +export const scanSillyTavernImportFx = createEffect(async (rootPath: string): Promise => { + return scanSillyTavernImport({ rootPath }); +}); + +export const importSillyTavernSelectionFx = createEffect( + async (params: { rootPath: string; itemIds: string[] }): Promise => { + return importSillyTavernSelection({ + rootPath: params.rootPath, + ownerId: DEFAULT_OWNER_ID, + selection: { itemIds: params.itemIds }, + }); + }, +); + +export const $sillyTavernRootPath = createStore(DEFAULT_ROOT).on(sillyTavernRootChanged, (_, value) => value); + +export const $sillyTavernScanResult = createStore(null).on( + scanSillyTavernImportFx.doneData, + (_, result) => result, +); + +export const $sillyTavernImportResult = createStore(null) + .on(importSillyTavernSelectionFx.doneData, (_, result) => result) + .reset(scanSillyTavernImportFx.done); + +export const $sillyTavernScanError = createStore(null) + .on(scanSillyTavernImportFx.failData, (_, error) => (error instanceof Error ? error.message : String(error))) + .reset(scanSillyTavernImportFx, scanSillyTavernImportFx.done); + +export const $sillyTavernImportError = createStore(null) + .on(importSillyTavernSelectionFx.failData, (_, error) => (error instanceof Error ? error.message : String(error))) + .reset(importSillyTavernSelectionFx, importSillyTavernSelectionFx.done); + +export const $sillyTavernSelectedItemIds = createStore>(new Set()) + .on(scanSillyTavernImportFx.doneData, (_, result) => new Set(listItems(result).map((item) => item.id))) + .on(sillyTavernSelectionReplaced, (_, next) => next) + .on(sillyTavernItemToggled, (state, payload) => { + const next = new Set(state); + if (payload.checked) next.add(payload.itemId); + else next.delete(payload.itemId); + return next; + }) + .reset(sillyTavernSelectionCleared); + +export const $sillyTavernSelectedCount = $sillyTavernSelectedItemIds.map((items) => items.size); + +export const $sillyTavernIsBusy = combine( + scanSillyTavernImportFx.pending, + importSillyTavernSelectionFx.pending, + (scanPending, importPending) => scanPending || importPending, +); + +export function listProfileItems(profile: SillyTavernImportScanResult['profiles'][number] | undefined): SillyTavernImportScanItem[] { + if (!profile) return []; + return SILLYTAVERN_IMPORT_KINDS.flatMap((kind) => profile.items[kind]); +} + +export function listItems(scan: SillyTavernImportScanResult | null): SillyTavernImportScanItem[] { + if (!scan) return []; + return scan.profiles.flatMap(listProfileItems); +} + +export function applyKindSelection(params: { + scan: SillyTavernImportScanResult | null; + selectedIds: Set; + kind: SillyTavernImportKind; + checked: boolean; +}): Set { + const next = new Set(params.selectedIds); + for (const item of listItems(params.scan).filter((entry) => entry.kind === params.kind)) { + if (params.checked) next.add(item.id); + else next.delete(item.id); + } + return next; +} + +export function applyProfileSelection(params: { + scan: SillyTavernImportScanResult | null; + selectedIds: Set; + profileHandle: string; + checked: boolean; +}): Set { + const next = new Set(params.selectedIds); + const profile = params.scan?.profiles.find((item) => item.handle === params.profileHandle); + for (const item of listProfileItems(profile)) { + if (params.checked) next.add(item.id); + else next.delete(item.id); + } + return next; +} + +sample({ + clock: sillyTavernScanRequested, + source: $sillyTavernRootPath, + target: scanSillyTavernImportFx, +}); + +sample({ + clock: sillyTavernKindToggled, + source: { + scan: $sillyTavernScanResult, + selectedIds: $sillyTavernSelectedItemIds, + }, + fn: ({ scan, selectedIds }, payload) => + applyKindSelection({ scan, selectedIds, kind: payload.kind, checked: payload.checked }), + target: sillyTavernSelectionReplaced, +}); + +sample({ + clock: sillyTavernProfileToggled, + source: { + scan: $sillyTavernScanResult, + selectedIds: $sillyTavernSelectedItemIds, + }, + fn: ({ scan, selectedIds }, payload) => + applyProfileSelection({ + scan, + selectedIds, + profileHandle: payload.profileHandle, + checked: payload.checked, + }), + target: sillyTavernSelectionReplaced, +}); + +sample({ + clock: sillyTavernImportRequested, + source: { + rootPath: $sillyTavernRootPath, + itemIds: $sillyTavernSelectedItemIds, + }, + fn: ({ rootPath, itemIds }) => ({ rootPath, itemIds: Array.from(itemIds) }), + target: importSillyTavernSelectionFx, +}); + +sample({ + clock: importSillyTavernSelectionFx.doneData, + target: [ + loadEntityProfilesFx, + userPersonsModel.getItemsFx, + loadWorldInfoBooksFx, + loadWorldInfoEntityBindingsFx, + loadInstructionsFx, + samplersModel.getItemsFx, + ], +}); From 4592955821356d1359075978f3fd28e5940535b0 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Fri, 10 Jul 2026 14:53:25 +0300 Subject: [PATCH 02/14] fix: up --- BACKEND_OPERATIONS_AUDIT_2026-07-10.md | 987 ++++++++++++++++++ .../sidebars/operation-profiles/index.tsx | 6 +- .../operation-profiles/ui/run-trace-panel.tsx | 174 +++ .../ui/run-trace-summary.test.ts | 168 +++ .../ui/run-trace-summary.ts | 93 ++ .../i18n/resources/en/operationProfiles.ts | 68 ++ .../i18n/resources/ru/operationProfiles.ts | 68 ++ web/src/model/chat-entry-parts/index.ts | 26 +- .../model/operation-run-trace/index.test.ts | 184 ++++ web/src/model/operation-run-trace/index.ts | 326 ++++++ 10 files changed, 2078 insertions(+), 22 deletions(-) create mode 100644 BACKEND_OPERATIONS_AUDIT_2026-07-10.md create mode 100644 web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx create mode 100644 web/src/features/sidebars/operation-profiles/ui/run-trace-summary.test.ts create mode 100644 web/src/features/sidebars/operation-profiles/ui/run-trace-summary.ts create mode 100644 web/src/model/operation-run-trace/index.test.ts create mode 100644 web/src/model/operation-run-trace/index.ts diff --git a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md new file mode 100644 index 00000000..939eff6e --- /dev/null +++ b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md @@ -0,0 +1,987 @@ +# Аудит backend и блока Operations TaleSpinner + +Дата: 2026-07-10 +Область анализа: `server/src/**`, shared-контракты операций, документация Operations и связанные backend-тесты. +Назначение: самостоятельный handoff-документ для следующей сессии, в которой будут исправляться найденные проблемы. + +## Краткий вывод + +В TaleSpinner уже заложено сильное ядро управляемой narrative generation: + +- генерация разбита на явные фазы; +- профили операций собираются из переиспользуемых блоков; +- операции исполняются как детерминированный DAG; +- результаты операций представлены как effects; +- доступ к LLM изолирован через gateway; +- существуют typed events, cancellation, artifacts, runtime state и развитый набор тестов. + +Главная проблема не в общей идее, а в несовпадении заявленной модели с фактическими runtime-гарантиями. Система выглядит как воспроизводимый workflow engine с транзакционными барьерами, но execution, effect staging, persistence и finalization разделены не полностью. Неуспешный или отменённый run может оставить persistent artifacts, изменённые activation counters, knowledge access state, переписанный user turn или UI parts. + +Полная перепись backend не нужна. Следует сохранить текущий generation engine и последовательно обеспечить четыре гарантии: + +1. Каждый начатый run доходит до наблюдаемого terminal state. +2. Operation execution не изменяет persistence, а только возвращает proposed effects. +3. Persistent effects применяются по явно выбранной политике атомарности. +4. После завершения можно восстановить точный compiled plan и результаты его операций. + +## Исходное состояние проверок + +Во время аудита успешно выполнены: + +- `yarn typecheck:server`; +- `yarn lint:server`; +- `yarn --cwd server test`; + - 100 test files passed; + - 502 tests passed. + +Следовательно, большинство находок ниже — это не обычные ошибки компиляции, а пробелы в сквозных гарантиях, failure semantics и соответствии runtime публичным контрактам. + +## Как Operations работают сейчас + +Текущий runtime flow: + +1. Разрешается LLM runtime и глобально активный operation profile. +2. Загружаются включённые operation blocks, на которые ссылается профиль. +3. Block-local `opId` namespace-ятся идентификатором блока. +4. Блоки превращаются в плоский массив операций и DAG. +5. Собирается base prompt. +6. Загружаются и заранее обновляются activation counters. +7. Исполняется DAG `before_main_llm`. +8. Созданные effects применяются к prompt, artifacts, turns и UI. +9. Required barrier решает, можно ли запускать основную LLM. +10. Main LLM стримит ответ с периодической записью assistant parts. +11. Исполняется DAG `after_main_llm`. +12. Применяются after-effects. +13. В generation сохраняются phase reports и commit reports. + +Целевая модель: + +1. Compile immutable executable plan. +2. Validate полный plan и все resource bindings. +3. Execute nodes на immutable input snapshots. +4. Получить typed results и proposed effects без persistent mutations. +5. Применить required/optional barrier policy. +6. Commit принятые effects по явной transaction policy. +7. Сохранить plan fingerprint, operation results и commit report. +8. Гарантированно завершить run и generation одним terminal status. + +## Приоритеты + +- **P0 — blocker:** потеря ошибок, невозможное runtime-состояние, нарушение security boundary или полностью неработающее заявленное поведение. +- **P1 — высокий риск:** partial state, неконтролируемая стоимость, потеря обновлений, невалидные workflow или нарушение воспроизводимости. +- **P2 — архитектурный долг:** неоднозначная семантика, плохая диагностика, высокая цена изменений или риск будущей миграции. +- **P3 — cleanup:** устаревшие документы, мёртвый код, naming и некритичная консистентность. + +--- + +## P0 — блокирующие проблемы + +### OPS-001. Ошибки до создания `RunState` могут полностью потеряться + +Код: + +- `server/src/services/chat-generation-v3/run-chat-generation-v3.ts:153-185`; +- `server/src/services/chat-generation-v3/run-chat-generation-v3.ts:496-525`. + +Внешний `catch` финализирует generation и отправляет `run.finished` только при наличии одновременно `context` и `runState`. Если ошибка возникает во время provider resolution, compilation активного профиля, получения generation-control lease или загрузки persisted artifacts, async generator может просто закончиться без error event и без повторного throw. + +Возможные последствия: + +- SSE молча закрывается; +- пользователь не получает причину ошибки; +- пустой assistant placeholder остаётся в чате; +- уже созданная generation может навсегда остаться `streaming`; +- frontend cleanup получает `generationId = null`. + +Решение: + +- создать внешний lifecycle state до первого fallible шага; +- разделить `prepare.failed` и ошибку уже созданной generation; +- если generation ещё не существует, пробрасывать typed preparation error в transport; +- если generation существует, финализировать её даже без полноценного `RunState`; +- ввести terminal error envelope, не требующий полностью созданного run; +- очищать пустой assistant scaffolding после неуспешной подготовки. + +Необходимые тесты: + +- compilation активного профиля падает до создания `context`; +- acquisition control lease падает после создания generation, но до `RunState`; +- загрузка artifacts падает до `createInitialRunState`; +- SSE получает ошибку; +- generation не остаётся `streaming`; +- пустой assistant scaffolding удаляется или явно отмечается failed. + +### OPS-002. `assistant_output_main` rewrite не сохраняется + +Код: + +- `server/src/services/chat-generation-v3/operations/commit-effects-phase.ts:257-266`; +- `server/src/services/chat-generation-v3/main-llm/run-main-llm-phase.ts:22-57`. + +Effect `turn.assistant.replace_text` меняет только `runState.assistantText`. Assistant part сохраняется во время main LLM streaming, то есть до запуска after-operations. Повторной записи после assistant rewrite нет. + +Operation может закончиться как `done`, effect — как `applied`, а сохранённое сообщение и UI останутся с первоначальным текстом. + +Решение: + +- добавить `persistAssistantTurnText`, аналогичный user-turn handler; +- передавать в commit `assistantMainPartId`; +- менять `runState.assistantText` только после успешной persistence; +- отправлять отдельный assistant rewrite/canonicalization event для немедленного обновления UI; +- определять итог run через `required` при ошибке persistence. + +Необходимые тесты: + +- after-operation действительно меняет assistant main part; +- UI/SSE получает финальный переписанный текст; +- ошибка persistence порождает `commit.effect_error`; +- required rewrite failure завершает run ошибкой; +- optional failure сохраняет исходный assistant text. + +### OPS-003. Local backend не имеет строгой сетевой границы + +Код: + +- `server/src/app.ts:33`; +- `server/src/app.ts:57-59`; +- `server/src/core/request-context/request-context.ts:62-72`. + +Сервер слушает порт без явного loopback host и использует unrestricted CORS. При этом auth отсутствует, а caller может передать `ownerId`. + +Решение: + +- по умолчанию слушать только `127.0.0.1`; +- разрешать LAN exposure только явной env-настройкой; +- ограничить CORS origin desktop/web origin приложения; +- перестать принимать owner как доверенное правило из request body; +- документировать риски LAN mode; +- рассмотреть local session token для desktop-to-backend запросов. + +Необходимые тесты: + +- default host — loopback; +- LAN host работает только после явного opt-in; +- неподтверждённые origins отклоняются; +- body не может переопределить trusted owner scope. + +### OPS-004. Operation repositories не соблюдают owner scope + +Код: + +- `server/src/services/operations/operation-profiles-repository.ts:61-70`; +- `server/src/services/operations/operation-profiles-repository.ts:125-186`; +- `server/src/services/operations/operation-blocks-repository.ts:73-82`; +- `server/src/services/operations/operation-blocks-repository.ts:125-168`. + +`getOperationProfileById` и `getOperationBlockById` ищут только по ID. Update сначала загружает запись без owner scope, затем выполняет owner-scoped `UPDATE`, после чего снова делает unscoped read. При неверном owner API способен вернуть неизменённую чужую запись как успешный результат. + +Profile creation и compilation также не требуют совпадения owner у профиля и блоков. + +Решение: + +- сделать owner обязательным во всех repository methods для owner-controlled сущностей; +- использовать форму `getById({ ownerId, id })`; +- проверять affected row count для update/delete; +- запрещать ссылку profile на block другого owner; +- scope-ить export, activation, bundle operations и runtime resolution; +- оставить unscoped lookup только как явно названный internal/admin method, если он действительно нужен. + +Необходимые тесты: + +- cross-owner read/update/delete/export не проходят; +- profile не может ссылаться на чужой block; +- update с неправильным owner возвращает not found; +- runtime не использует активный профиль другого owner. + +--- + +## P1 — высокоприоритетные проблемы + +### OPS-005. Operation execution не является side-effect-free + +Код: + +- `server/src/services/chat-generation-v3/operations/knowledge-operation-executor.ts:80-100`; +- `server/src/services/chat-knowledge/knowledge-reveal-service.ts:95-114`. + +`knowledge_reveal` изменяет knowledge access state прямо во время execute. Это обходит execute/commit boundary, и mutation невозможно откатить, если позже упадёт required operation или commit. + +Решение: + +- разделить reveal на read-only planning и mutation handler; +- добавить typed effect `knowledge.reveal`; +- применять его только в commit phase; +- включить mutation в общую transaction policy; +- сделать повторное применение idempotent. + +### OPS-006. Activation counters сохраняются до успешного выполнения run + +Код: + +- `server/src/services/chat-generation-v3/run-chat-generation-v3.ts:226-241`. + +Counters увеличиваются или сбрасываются до выполнения операций и main LLM. Failed или aborted run всё равно потребляет activation interval. + +Решение: + +- вычислять decision в памяти; +- коммитить counters только при выбранном policy state; +- явно решить, когда interval считается использованным: `started`, `done` или successful effect commit; +- при необходимости хранить отдельно attempt и success state; +- обновлять counters атомарно с operation effects. + +### OPS-007. Commit effects допускает частичное применение + +Код: + +- `server/src/services/chat-generation-v3/operations/commit-effects-phase.ts:123-289`. + +Effects применяются по одному. При ошибке она записывается в report, но ранее применённые persistent effects остаются. Required failure блокирует дальнейшие фазы, но не откатывает artifact writes, turn rewrites и UI parts. + +Решение: + +- классифицировать effects как in-memory, local transactional и external/irreversible; +- валидировать и stage-ить все effects до начала применения; +- проводить local persistent effects через один unit of work; +- менять `RunState` после успешного DB commit; +- формализовать policy: `atomic_per_operation`, `atomic_per_hook` или `best_effort`; +- для required operations по умолчанию использовать atomic semantics. + +### OPS-008. `concurrent` запускает неограниченное число операций + +Код: + +- `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts:536-543`; +- `server/src/core/operation-orchestrator/types.ts:90`; +- `server/src/core/operation-orchestrator/executor.ts:60-70`. + +Runtime не передаёт `concurrency`, поэтому orchestrator использует `Infinity`. Validator не ограничивает количество operations. Один profile способен одновременно запустить большое число LLM/guard requests с retries. + +Риски: + +- неконтролируемая стоимость; +- provider rate limits; +- нагрузка на SQLite и event loop; +- плохая отмена, если все tasks уже стартовали. + +Решение: + +- ввести безопасный global default; +- добавить bounded `profile.concurrency`, если настройка нужна пользователю; +- иметь отдельные лимиты для LLM, knowledge и local tasks; +- предупреждать или отклонять чрезмерно дорогие profiles; +- сохранять abort semantics для queued tasks. + +### OPS-009. Нет resource limits для operation profile и artifacts + +Код: + +- `server/src/services/operations/operation-block-validator.ts:128-142`; +- `server/src/services/operations/operation-block-validator.ts:372-378`. + +Практически не ограничены: + +- число операций; +- число dependencies и run conditions; +- число exposures; +- длина templates/prompts; +- размер JSON schema; +- `history.maxItems`; +- размер artifact value. + +Решение: + +- добавить configurable caps; +- ограничить artifact history и размер одного value; +- ограничить auxiliary LLM output, timeout и retry budget; +- проверять cumulative profile cost при compilation; +- при реальной необходимости большой истории перейти от перезаписи JSON array к отдельным history rows. + +### OPS-010. `writeMode: "append"` не влияет на запись + +Код: + +- `server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts:33-61`; +- `server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts:135-181`. + +Оба stores всегда заменяют `value`. `writeMode` сохраняется только как metadata, а history обновляется независимо. + +Решение: + +- определить append semantics по format; +- text/markdown: concat с явным separator policy; +- JSON array: append items; +- JSON object: запретить append либо определить merge policy; +- если имелся в виду только history append, переименовать поле; +- гарантировать одинаковое поведение run-only и persisted stores. + +### OPS-011. `format: "json"` не гарантирует structured JSON value + +Код: + +- `server/src/services/chat-generation-v3/operations/llm-operation-executor.ts:149-220`; +- `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts:580-630`. + +LLM JSON output парсится, но затем снова сериализуется в string. Template operation может пометить любой текст как JSON. Guard и knowledge возвращают objects. Downstream operation получает разные типы value при одном `format`. + +Решение: + +- LLM executor должен возвращать structured value для JSON mode; +- template JSON необходимо parse/validate до создания effect; +- каждый artifact effect должен проверяться перед commit; +- serialization должна оставаться деталью persistence; +- ввести typed artifact value union. + +### OPS-012. `samplerPresetId` принимается, но игнорируется + +Код: + +- `server/src/services/operations/llm-operation-params.ts:53-70`; +- `server/src/services/chat-generation-v3/operations/llm-operation-executor.ts:378-401`. + +Поле хранится и валидируется как string, но executor использует только inline `samplers`. + +Решение: + +- разрешать preset во время compile/preflight или execution; +- определить precedence preset и inline overrides; +- проверять existence и ownership preset; +- snapshot-ить resolved sampler settings в run trace; +- запрещать activation profile с dangling preset. + +### OPS-013. Sampler ranges валидируются недостаточно строго + +Код: + +- `server/src/services/operations/llm-operation-params.ts:21-43`; +- `server/src/services/operations/guard-operation-params.ts:46-68`. + +Большинство sampler fields требуют только finite number. В gateway могут попасть negative max tokens, fractional seed или probability вне допустимого диапазона. + +Решение: + +- вынести sampler schemas в единый shared source; +- валидировать probabilities и penalties; +- требовать integer positive token limits и integer seed; +- provider-specific extensions держать в provider schemas; +- не нормализовать silently значения, если это меняет intent пользователя. + +### OPS-014. Невалидная композиция profile сохраняется до generation time + +Код: + +- `server/src/services/operations/operation-profiles-repository.ts:73-105`; +- `server/src/services/operations/operation-profile-resolver.ts:98-140`. + +При save проверяется существование blocks, но весь profile не компилируется. Cross-block artifact tag conflict и другие composition errors можно сохранить и активировать. Ошибка возникнет только при generation и может потеряться из-за OPS-001. + +Решение: + +- создать единый `compileAndValidateOperationProfile` application service; +- вызывать его на create, update, activation, import и dry-run; +- возвращать structured diagnostics с block/op IDs; +- запрещать activation unsupported required kinds; +- дать editor отдельный validation endpoint. + +### OPS-015. Block ordering можно нарушить значением operation order + +Код: + +- `server/src/services/operations/operation-profile-resolver.ts:16`; +- `server/src/services/operations/operation-profile-resolver.ts:25-46`; +- `server/src/services/operations/operation-block-validator.ts:215-224`. + +Runtime order вычисляется как `blockIndex * 1_000_000 + operation.order`, но operation order может быть любым finite number. Большие положительные или отрицательные значения interleave-ят разные blocks. + +Решение: + +- хранить structured tuple `(blockIndex, operationOrder, opId)`; +- сравнивать tuple напрямую; +- отказаться от numeric bucket encoding; +- ограничить editor order bounded integer, если это удобно UI. + +### OPS-016. Hook/exposure validation разрешает противоречивые конфигурации + +Код: + +- `server/src/services/operations/operation-block-validator.ts:466-494`; +- `server/src/services/chat-generation-v3/operations/effect-policy.ts:7-36`. + +Operation может содержать оба hooks и exposure, допустимый только в одном. Validator проверяет наличие требуемого hook, но не запрещает несовместимый дополнительный hook. Во второй фазе тот же effect падает policy error. + +Кроме того, `turn.user.replace_text` разрешён после main LLM, то есть user input может измениться уже после того, как assistant ответил на прежний текст. + +Решение: + +- prompt effects и user rewrite разрешать только before; +- assistant rewrite — только after; +- artifact/UI effects — в обоих hooks; +- либо сделать exposure явно hook-scoped; +- отклонять противоречия во время compilation. + +### OPS-017. Semantics dependency state неоднозначна и ограничена direct dependencies + +Код: + +- `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts:117-130`; +- `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts:550-566`. + +Operation восстанавливает preview state только из прямых `dependsOn`. В цепочке `A -> B -> C` операция C не получает effects A автоматически, если A не указан у C напрямую. Sequential mode управляет scheduler, но не делает результаты предыдущих unrelated operations видимыми во время execution. + +Решение: + +- определить, являются dependencies scheduling edges, data edges или обоими; +- при inherited-state semantics replay-ить transitive ancestor closure; +- при direct-input semantics моделировать read sets/inputs явно; +- документировать, что sequential mode сам по себе не создаёт data flow; +- покрыть тестами chain, diamond и independent branches. + +### OPS-018. Artifact identity разделена между `artifactId` и `tag` + +Код: + +- `shared/types/operation-profiles.ts:84-98`; +- `server/src/services/operations/operation-profile-resolver.ts:35-50`; +- `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts:31-46`. + +Compilation переписывает `artifactId`, но runtime stores, knowledge sources и template shorthand в основном используют `tag`. Неясно, какое поле является реальной identity и что стабильно между runs. + +Решение: + +- immutable artifact ID использовать как identity; +- tag считать human-readable alias; +- определить alias uniqueness в compiled profile; +- хранить оба поля явно; +- разрешать shorthand через compiled alias table, а не fallback lookups. + +### OPS-019. Lifecycle artifacts и activation state не совпадает + +Код: + +- `server/src/services/chat-generation-v3/prepare/resolve-run-context.ts:16-34`; +- `server/src/services/chat-generation-v3/run-chat-generation-v3.ts:210-240`; +- `server/src/db/schema/chat-runtime-state.ts:24-31`. + +Artifact session key включает profile version и block versions. Редактирование profile/block создаёт новую artifact session. Activation state при этом scoped по profile ID и `operationProfileSessionId`, без того же version fingerprint. После edit artifacts сбрасываются, а counters продолжаются. + +Решение: + +- определить единую operation session identity; +- явно решить, сохраняют ли config edits state; +- одинаково применять identity к artifacts и activation counters; +- сделать manual session reset предсказуемым; +- добавить retention/garbage collection старых unreachable sessions. + +### OPS-020. Concurrent runs могут терять runtime-state и artifact updates + +Код: + +- `server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts:123-173`; +- `server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts:137-170`. + +Persisted artifact update — read-then-write. Activation runtime state заменяет весь JSON payload. Две generation одного chat/branch/profile могут потерять history/counters или столкнуться при insert. + +Решение: + +- либо разрешать только одну active generation на chat branch; +- либо явно поддержать concurrent runs с conflict policy; +- использовать transactional UPSERT для artifact value/history; +- ввести optimistic versioning runtime state; +- не заменять unrelated counters из stale full-payload snapshot. + +### OPS-021. Import operation profiles неатомарен + +Код: + +- `server/src/application/operations/use-cases/import-operation-profiles.ts:22-122`; +- `server/src/db/ensure-operation-blocks-cutover.ts:42-104`. + +Import и startup cutover выполняют несколько writes без общей transaction. Ошибка оставляет orphan blocks или partial profiles. Прерванный cutover между insert block и update profile способен создать duplicate orphan при следующем запуске. + +Решение: + +- валидировать bundle полностью до write; +- импортировать каждый bundle одной DB transaction; +- передавать transaction в block/profile repositories; +- сделать cutover idempotent через marker или deterministic block ID; +- добавить failure-injection integration tests. + +### OPS-022. Profile/block updates не имеют optimistic concurrency + +Код: + +- `server/src/services/operations/operation-profiles-repository.ts:125-186`; +- `server/src/services/operations/operation-blocks-repository.ts:125-168`. + +Update использует read-modify-write и считает `version + 1` вне compare-and-set. Concurrent editors могут потерять изменения и получить одинаковый next version. + +Решение: + +- требовать `expectedVersion`; +- выполнять update через `WHERE id AND owner_id AND version`; +- возвращать typed version conflict; +- frontend должен явно reload/merge; +- ту же механику применять к import/update tools. + +### OPS-023. Generation creation не имеет полноценной idempotency semantics + +Начальные user/assistant записи создаются транзакционно, но HTTP retry после network interruption может создать второй turn и повторно выполнить persisted operations. + +Решение: + +- использовать стабильный client request/idempotency key; +- обеспечить uniqueness в owner/chat/branch scope; +- повторный принятый request должен возвращать существующий session/run; +- отличать retry от намеренного regenerate/continue; +- сохранять idempotency correlation в operation run trace. + +--- + +## P2 — архитектурные и диагностические проблемы + +### OPS-024. Operation execution results не сохраняются долговременно + +Код: + +- `server/src/services/chat-generation-v3/contracts.ts:279-309`; +- `server/src/services/chat-generation-v3/persist/finalize-run.ts:9-32`. + +`RunState` содержит `operationResultsByHook`, но `RunResult` их исключает. Finalization сохраняет только phase и commit reports. После reload нельзя определить, какие operations запускались, были skipped, failed или вернули конкретный output. + +Решение: + +- добавить durable run trace или operation-run tables; +- хранить plan fingerprint, profile/block versions, statuses, timings, skip details и safe errors; +- определить retention/redaction operation outputs; +- добавить owner-scoped historical trace API; +- использовать один контракт для live и historical frontend trace. + +### OPS-025. Точный compiled plan невозможно восстановить + +`ProfileSnapshot` содержит compiled operations только в памяти. Generation persistence не хранит full snapshot или разрешимый immutable plan version. Profile version недостаточно, потому что blocks меняются независимо. + +Решение: + +- считать canonical compiled-plan hash; +- сохранять profile ID/version, ordered block IDs/versions, session identity и plan hash; +- при необходимости replay сохранять canonical compiled spec; +- либо ввести immutable revisions/audit log profile и blocks. + +### OPS-026. Operation errors не попадают в SSE completion events + +Код: + +- `server/src/services/chat-generation-v3/operations/execute-operations-phase.ts:712-741`. + +Для `error` и `aborted` event содержит status, но не `task.error` или abort reason, хотя контракт поддерживает поле `error`. + +Решение: + +- сначала строить terminal result, затем emit event; +- включать stable error code, safe message и abort reason; +- перечислять failing operation IDs в required barrier message; +- редактировать provider errors и секретные данные. + +### OPS-027. Phase status не отражает реальные operation errors + +Код: + +- `server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts:68-128`. + +Execute phase отмечается `done`, если orchestrator вернул результат, даже при failed operations. Commit phase считается failed только для required errors; optional effect errors дают phase `done`. + +Решение: + +- различать `completed`, `completed_with_errors`, `failed`, `aborted` либо хранить summary counts; +- отделить barrier policy от health фазы; +- сохранять done/skipped/error/aborted totals. + +### OPS-028. `commit.effect_skipped` объявлен, но не используется + +Код: + +- `server/src/services/chat-generation-v3/contracts.ts:343-351`; +- `server/src/services/chat-generation-v3/operations/commit-effects-phase.ts:112-115`. + +Контракт допускает skipped effect, но commit фактически только применяет effect или фиксирует error. + +Решение: + +- удалить unused state; +- либо определить реальные skip cases: deduplication, superseded write, optional missing target, explicit policy; +- покрыть все states contract tests. + +### OPS-029. Required operations не fail-fast + +Required проверяется только на barrier после завершения всех runnable tasks. В concurrent mode дорогие sibling LLM operations продолжаются после failure required node. + +Решение: + +- формально выбрать `barrier_only` или `fail_fast` semantics; +- при fail-fast отменять queued и при необходимости running siblings; +- сделать policy profile-level настройкой, если нужны оба поведения; +- сохранить best-effort optional branches только как осознанный режим. + +### OPS-030. Credentials и presets late-bound без preflight + +Profiles можно сохранить и экспортировать с dangling `credentialRef`, provider, model или sampler preset. Dedicated operation bundle не может безопасно перенести local credential ID на другую установку. + +Решение: + +- проверять resource existence в profile preflight; +- никогда не экспортировать token values; +- экспортировать logical credential slots вместо local IDs; +- importer должен привязать slots к local credentials; +- unresolved bindings должны быть видны до activation. + +### OPS-031. Custom regex способен блокировать event loop + +LLM JSON extraction выполняет произвольный JavaScript regex на provider output. Проверяется syntax, но не complexity и input size. + +Решение: + +- ограничить pattern и output size; +- запретить ненужные risky flags; +- предпочесть safe extraction без backtracking; +- при сохранении power-user regex выполнять его в worker/timebox. + +### OPS-032. Prompt diagnostics всегда сохраняют много пользовательского контента + +Код: + +- `server/src/services/chat-generation-v3/run-chat-generation-v3.ts:326-341`; +- `server/src/services/chat-generation-v3/prompt/generation-debug-payload.ts:229-287`; +- `server/src/services/chat-core/generations-repository.ts:240-257`. + +Full LLM messages сохраняются в debug JSON независимо от SSE debug flag. Limit сериализации — 450 KB на generation. Дополнительно хранится truncated prompt snapshot. + +Риски: + +- рост SQLite; +- длительное хранение чувствительного narrative content; +- дублирование prompt data; +- неочевидная пользователю retention policy. + +Решение: + +- отделить обязательные reproduction data от optional debug data; +- добавить retention setting и cleanup; +- по умолчанию хранить hashes и structural metadata; +- full prompt retention сделать явным opt-in; +- не дублировать content в snapshot/debug; +- добавить configurable redaction. + +### OPS-033. Ключевые operation files чрезмерно велики + +На момент аудита: + +- `operation-block-validator.ts` — 867 lines; +- `execute-operations-phase.ts` — 794 lines; +- `contracts.ts` — 569 lines; +- `run-chat-generation-v3.ts` — 529 lines. + +Это превышает repository contract и концентрирует риски в самых изменяемых участках. + +Решение: + +- создать operation-kind registry: schema, compiler, executor, capabilities; +- вынести graph validation, artifact validation, hook policy и import migration; +- разделить context construction, task construction, event mapping и result mapping; +- оставить top-level generation engine небольшим phase coordinator. + +### OPS-034. Application-layer boundary остаётся непоследовательной + +Новые chat generation flows используют application use cases, но многие API routes напрямую вызывают repositories. Repositories одновременно выполняют validation, owner defaults, JSON normalization и business decisions. + +Решение: + +- создать application services для operation CRUD, compile/validate, activation, import/export и runtime-state reads; +- routes оставить validation/transport слоем; +- repositories оставить persistence/DTO mapping слоем; +- owner/request context и transaction передавать явно. + +### OPS-035. Доступность API связана с optional bootstrap systems + +Код: + +- `server/src/core/bootstrap/bootstrap-coordinator.ts:24-67`. + +До старта API выполняются migrations, schema cutovers, LLM, RAG и Chroma bootstrap. Отказ optional subsystem способен заблокировать весь backend. + +Решение: + +- классифицировать steps как required/optional; +- показывать readiness каждого subsystem; +- optional integrations инициализировать lazy или поддерживать degraded mode; +- migration failure оставить fatal; +- отсутствие Chroma не должно обязательно блокировать non-RAG chat. + +### OPS-036. Runtime рассчитан на один Node process + +Durable generation-control lease уже появился, но execution и AbortController остаются process-local. Для текущего local application это допустимо, но ограничивает workers, scale и restart recovery. + +Решение: + +- явно зафиксировать single-process support текущей версии; +- durable run state считать authoritative, in-memory controllers — optimization; +- будущих workers строить на lease ownership/heartbeat; +- не вводить multi-process deployment до решения artifact/activation concurrency. + +--- + +## P3 — cleanup и документация + +### OPS-037. Документация Operations устарела + +Код: + +- `docs/docs/user/operations.md:47-54`; +- `docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md:47-54`. + +Документация утверждает, что runtime поддерживает только `template` и `llm`, хотя исполняются также `guard`, `knowledge_search` и `knowledge_reveal`. + +Решение: + +- обновить RU и EN одновременно; +- описать hooks, activation, guard conditions и artifact lifecycle; +- перечислить реально unsupported kinds; +- после фикса OPS-017 документировать data-visibility semantics. + +### OPS-038. В repository остались старые pipeline/template runtime concepts + +Код: + +- `server/src/services/operations/template-operations-runtime.ts`; +- `shared/types/pipelines.ts`; +- `shared/types/pipeline-profile-spec.ts`; +- `shared/types/pipeline-execution.ts`. + +Old template runtime используется только собственными тестами, а pipeline contracts отключены от актуального Operations engine. Они создают две конкурирующие архитектурные модели. + +Решение: + +- проверить отсутствие внешних consumers; +- перенести полезные контракты в текущую модель; +- удалить dead runtime и obsolete tests; +- использовать один термин для актуального compiled operation workflow. + +### OPS-039. Error messages смешивают языки и abstraction levels + +Validation/API errors используют одновременно English и Russian strings. Frontend вынужден зависеть от free-form messages. + +Решение: + +- использовать stable English error codes и structured details; +- локализовать сообщения во frontend; +- передавать `blockId`, `opId`, field path и conflict IDs; +- не заставлять UI парсить strings. + +### OPS-040. Колонка `usage` хранит artifact format + +Код: + +- `server/src/db/schema/operation-profiles.ts:83-87`; +- `server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts:61-70`. + +Legacy column `usage` сейчас интерпретируется как `ArtifactFormat`, хотя старый контракт usage использовал значения `prompt_only`, `ui_only` и другие. + +Решение: + +- добавить explicit `format` и `semantics` либо versioned artifact envelope; +- мигрировать existing rows; +- перестать переиспользовать legacy column name с другой семантикой. + +--- + +## Предлагаемая целевая архитектура + +### 1. `CompiledOperationPlan` + +Canonical plan должен содержать: + +- plan version; +- profile ID/version; +- ordered block IDs/versions; +- canonical plan hash; +- normalized operations; +- validated dependency graph; +- resolved capabilities и resource bindings; +- concurrency/failure policies; +- artifact ID/tag symbol table. + +Compilation должна быть pure, кроме resource lookups, и возвращать structured diagnostics. + +### 2. Operation-kind registry + +Каждый operation kind предоставляет: + +- input schema; +- compile/preflight validation; +- capabilities; +- executor; +- output validator; +- allowed hooks; +- allowed effects; +- cost/resource class. + +Это заменит растущие `if (op.kind === ...)` в validator и executor. + +### 3. Side-effect-free operation execution + +Пример результата executor: + +```ts +type OperationNodeResult = { + output: unknown; + effects: RuntimeEffect[]; + diagnostics: OperationDiagnostics; +}; +``` + +Executor может вызывать external read/generation services, но не должен изменять TaleSpinner persistence. Knowledge reveal должен стать effect. + +### 4. Explicit commit policy + +Нужно выбрать поддерживаемые policies: + +- `atomic_per_hook` — local effects одного hook коммитятся вместе; +- `atomic_per_operation` — effects каждой operation атомарны отдельно; +- `best_effort` — partial state разрешён и явно отражён; +- `fail_fast` или `barrier_only` для required failure. + +Рекомендуемый default: `atomic_per_hook` для local DB mutations. External LLM calls происходят во время execute, но в persistence попадают только принятые результаты. + +### 5. Durable run trace + +Минимально сохранять: + +- run/generation ID; +- plan hash и source versions; +- hook/op IDs; +- timestamps и duration; +- status и skip details; +- safe error code/message; +- effect type и commit status; +- provider/model/resolved samplers для LLM operations; +- output metadata или redacted output по retention policy. + +### 6. Unified operation session lifecycle + +Единая session identity должна включать только поля, которые намеренно сбрасывают state, и одинаково применяться к: + +- persisted artifacts; +- activation counters; +- guard/runtime state; +- historical trace correlation. + +Config revision и manual reset должны иметь раздельно описанное поведение. + +--- + +## Рекомендуемая последовательность исправлений + +### Phase A. Terminal-state correctness + +1. Исправить OPS-001. +2. Добавить preparation failure events и cleanup. +3. Исправить assistant rewrite persistence из OPS-002. +4. Добавить operation errors в SSE. +5. Сначала написать regression tests. + +Критерии завершения: + +- каждый request имеет понятный terminal result; +- generation не остаётся `streaming` после обработанной ошибки; +- assistant rewrite меняет persisted content; +- frontend получает actionable operation error. + +### Phase B. Security и bounded execution + +1. Исправить owner scope. +2. Bind на loopback и ограничить CORS. +3. Ввести concurrency cap. +4. Добавить profile/artifact resource limits. +5. Усилить sampler validation. + +Критерии завершения: + +- cross-owner доступ невозможен; +- backend не доступен в LAN по умолчанию; +- profile не запускает unbounded provider calls; +- чрезмерные configs отклоняются на edge. + +### Phase C. Contract correctness + +1. Реализовать или переопределить artifact append. +2. Сделать JSON artifacts structured. +3. Разрешать sampler presets. +4. Заменить numeric order buckets. +5. Enforce hook/exposure compatibility. +6. Определить dependency data visibility. + +Критерии завершения: + +- каждое публичное operation field имеет tested runtime behavior; +- artifact shape соответствует format; +- editor-valid profile не падает из-за известных composition contradictions. + +### Phase D. Transaction и state model + +1. Перенести knowledge mutations в effects. +2. Stage-ить effects до commit. +3. Реализовать выбранную atomic commit policy. +4. Commit activation counters по документированной success semantics. +5. Решить concurrent run/lost update behavior. +6. Сделать imports и cutover transactional. + +Критерии завершения: + +- required failure не оставляет unintended persistent mutations; +- concurrent runs не теряют artifact history/counters; +- import — all-or-nothing. + +### Phase E. Reproducibility и maintainability + +1. Сохранять compiled-plan fingerprint и operation trace. +2. Добавить optimistic versioning profile/block. +3. Разделить oversized modules через registry. +4. Удалить obsolete pipeline/template runtime. +5. Добавить debug retention/redaction policy. +6. Обновить RU/EN documentation. + +Критерии завершения: + +- historical generation указывает точный operation plan; +- run trace переживает reload; +- concurrent edit не перетирает changes silently; +- в repository остаётся одна canonical Operations implementation. + +## Рекомендуемый первый fix batch + +Безопасный первый batch должен быть узким: + +1. Добавить failing tests на preparation errors и assistant rewrite persistence. +2. Изменить `runChatGenerationV3`, чтобы все failure paths завершались наблюдаемо. +3. Persist-ить `turn.assistant.replace_text` через отдельный handler. +4. Передавать error information в `operation.finished`. +5. Compile/validate profile до activation. +6. Добавить conservative concurrency cap. + +Этот batch исправит пользовательскую correctness, не требуя одновременно завершать полную transaction redesign. + +## Definition of Done для Operations subsystem + +- Сохранённый и активированный profile гарантированно компилируется. +- Каждое поддерживаемое поле имеет определённую и протестированную semantics. +- Unsupported required kinds нельзя активировать silently. +- Каждый run доходит до одного terminal status и отдаёт usable error. +- Operation execution не изменяет TaleSpinner persistence напрямую. +- Local effects применяются по explicit transaction policy. +- Required failure не оставляет unintended partial persistent state. +- Concurrent execution ограничен. +- Owner scope соблюдается в CRUD, export, activation и runtime. +- Artifact value shape соответствует declared format. +- Artifacts и activation state используют один documented session lifecycle. +- Точный compiled plan и operation results доступны после run. +- RU и EN documentation описывает фактическое поведение. + diff --git a/web/src/features/sidebars/operation-profiles/index.tsx b/web/src/features/sidebars/operation-profiles/index.tsx index 923f840a..ba52f206 100644 --- a/web/src/features/sidebars/operation-profiles/index.tsx +++ b/web/src/features/sidebars/operation-profiles/index.tsx @@ -37,10 +37,11 @@ import './operation-profiles.css'; import { BlockActions } from './ui/block-actions'; import { ProfileActions } from './ui/profile-actions'; import { ProfilePicker } from './ui/profile-picker'; +import { RunTracePanel } from './ui/run-trace-panel'; const TOOLBAR_TOOLTIP_SETTINGS = TOOLTIP_PORTAL_SETTINGS; -type TabValue = 'profiles' | 'blocks'; +type TabValue = 'profiles' | 'blocks' | 'run'; export const OperationProfilesSidebar: React.FC = () => { const { t } = useTranslation(); @@ -142,6 +143,7 @@ export const OperationProfilesSidebar: React.FC = () => { {t('operationProfiles.tabs.profiles')} {t('operationProfiles.tabs.blocks')} + {t('operationProfiles.tabs.run')} @@ -376,6 +378,8 @@ export const OperationProfilesSidebar: React.FC = () => { )} )} + + {activeTab === 'run' && } ); diff --git a/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx b/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx new file mode 100644 index 00000000..1ce5e2eb --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx @@ -0,0 +1,174 @@ +import { Badge, Collapse, Group, Loader, Paper, Stack, Text, UnstyledButton } from '@mantine/core'; +import { useUnit } from 'effector-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuChevronDown, LuChevronRight, LuCircleCheck, LuCircleMinus, LuCircleX } from 'react-icons/lu'; + +import { $lastRunTrace } from '@model/operation-run-trace'; + +import { describeDestination, describeOperationSummary, runStatusColor } from './run-trace-summary'; + +import type { RunTrace, TraceOperation } from '@model/operation-run-trace'; + +const HOOK_ORDER = ['before_main_llm', 'after_main_llm']; + +const StatusIcon: React.FC<{ status: TraceOperation['status'] }> = ({ status }) => { + if (status === 'running') return ; + if (status === 'done') return ; + if (status === 'skipped') return ; + return ; +}; + +const OperationRow: React.FC<{ trace: RunTrace; op: TraceOperation }> = ({ trace, op }) => { + const { t } = useTranslation(); + const [opened, setOpened] = React.useState(false); + const summary = describeOperationSummary(t, trace, op); + const hasCommitIssue = op.commits.some((commit) => commit.status === 'error'); + const hasDetails = op.effects.length > 0 || op.commits.length > 0 || Boolean(op.errorMessage); + + return ( + + hasDetails && setOpened((prev) => !prev)} + style={{ width: '100%', cursor: hasDetails ? 'pointer' : 'default' }} + aria-expanded={opened} + > + + + + + + + + {op.name} + + {hasCommitIssue && ( + + {t('operationProfiles.runTrace.details.commitError')} + + )} + + + {summary} + + + {hasDetails && ( + + {opened ? : } + + )} + + + + + + {op.errorMessage && ( + + {op.errorMessage} + + )} + {op.effects.map((effect, index) => ( + + + + {describeDestination(t, effect)} + + + {t('operationProfiles.runTrace.chars', { count: effect.valueChars })} + + + + {effect.valuePreview || t('operationProfiles.runTrace.details.valueEmpty')} + + + ))} + {op.commits.some((commit) => commit.status !== 'applied') && ( + + {op.commits + .filter((commit) => commit.status !== 'applied') + .map((commit, index) => ( + + {commit.effectType}: {t(`operationProfiles.runTrace.details.commitStatus.${commit.status}`)} + {commit.message ? ` — ${commit.message}` : ''} + + ))} + + )} + + + + ); +}; + +export const RunTracePanel: React.FC = () => { + const { t } = useTranslation(); + const trace = useUnit($lastRunTrace); + + if (!trace) { + return ( + + {t('operationProfiles.runTrace.empty')} + + ); + } + + const durationSeconds = trace.finishedAtTs ? Math.max(0, (trace.finishedAtTs - trace.startedAtTs) / 1000) : null; + const hooksInOrder = [ + ...HOOK_ORDER.filter((hook) => trace.operations.some((op) => op.hook === hook)), + ...Array.from(new Set(trace.operations.map((op) => op.hook))).filter((hook) => !HOOK_ORDER.includes(hook)), + ]; + + return ( + + + + {t(`operationProfiles.runTrace.runStatus.${trace.status}`)} + + {trace.trigger && ( + + {t(`operationProfiles.runTrace.trigger.${trace.trigger}`, trace.trigger)} + + )} + {durationSeconds !== null && ( + + {t('operationProfiles.runTrace.duration', { seconds: durationSeconds.toFixed(1) })} + + )} + {trace.mainLlm?.model && ( + + {trace.mainLlm.model} + + )} + + + {trace.errorMessage && ( + + {trace.errorMessage} + + )} + + {trace.operations.length === 0 && ( + + {t('operationProfiles.runTrace.noOperations')} + + )} + + {hooksInOrder.map((hook) => ( + + + {t(`operationProfiles.runTrace.hook.${hook}`, hook)} + + {trace.operations + .filter((op) => op.hook === hook) + .map((op) => ( + + ))} + + ))} + + ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/run-trace-summary.test.ts b/web/src/features/sidebars/operation-profiles/ui/run-trace-summary.test.ts new file mode 100644 index 00000000..5dfd6f89 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/run-trace-summary.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; + +import { describeDestination, describeOperationSummary, resolveOpName, runStatusColor } from './run-trace-summary'; + +import type { TranslateFn } from './run-trace-summary'; +import type { RunTrace, TraceEffect, TraceOperation } from '@model/operation-run-trace'; + +const t: TranslateFn = (key, params) => (params ? `${key} ${JSON.stringify(params)}` : key); + +function makeOp(overrides: Partial): TraceOperation { + return { + key: 'before_main_llm:op-1', + opId: 'op-1', + name: 'Op', + hook: 'before_main_llm', + status: 'done', + skipReason: null, + activation: null, + guard: null, + blockedByOpIds: [], + errorMessage: null, + debugSummary: null, + effects: [], + commits: [], + startedAtTs: null, + finishedAtTs: null, + ...overrides, + }; +} + +function makeTrace(operations: TraceOperation[]): RunTrace { + return { + runId: 'run-1', + generationId: null, + chatId: null, + branchId: null, + trigger: 'generate', + status: 'done', + failedType: null, + errorMessage: null, + startedAtTs: 0, + finishedAtTs: null, + mainLlm: null, + operations, + }; +} + +function makeEffect(overrides: Partial): TraceEffect { + return { + type: 'artifact.upsert', + mode: null, + role: null, + depthFromEnd: null, + artifactId: null, + valuePreview: '', + valueChars: 0, + ...overrides, + }; +} + +describe('describeDestination', () => { + it('maps every effect type to its destination key', () => { + expect(describeDestination(t, makeEffect({ type: 'prompt.system_update', mode: 'append' }))).toContain( + 'dest.systemPrompt', + ); + expect(describeDestination(t, makeEffect({ type: 'prompt.append_after_last_user' }))).toContain( + 'dest.promptAfterUser', + ); + expect(describeDestination(t, makeEffect({ type: 'prompt.insert_at_depth', depthFromEnd: 4 }))).toContain( + 'dest.promptDepth {"depth":4}', + ); + expect(describeDestination(t, makeEffect({ type: 'turn.user.replace_text' }))).toContain('dest.rewriteUser'); + expect(describeDestination(t, makeEffect({ type: 'turn.assistant.replace_text' }))).toContain( + 'dest.rewriteAssistant', + ); + expect(describeDestination(t, makeEffect({ type: 'ui.inline' }))).toContain('dest.uiCard'); + expect(describeDestination(t, makeEffect({ type: 'artifact.upsert', artifactId: 'world_state' }))).toContain( + 'dest.state {"tag":"world_state"}', + ); + }); +}); + +describe('describeOperationSummary', () => { + it('done: shows chars and exposure destinations, falling back to the artifact', () => { + const withExposure = makeOp({ + effects: [ + makeEffect({ type: 'artifact.upsert', artifactId: 'summary', valueChars: 412 }), + makeEffect({ type: 'prompt.insert_at_depth', depthFromEnd: 4, valueChars: 412 }), + ], + }); + const summary = describeOperationSummary(t, makeTrace([withExposure]), withExposure); + expect(summary).toContain('chars {"count":412}'); + expect(summary).toContain('dest.promptDepth'); + expect(summary).not.toContain('dest.state'); + + const artifactOnly = makeOp({ + effects: [makeEffect({ type: 'artifact.upsert', artifactId: 'guard_out', valueChars: 20 })], + }); + expect(describeOperationSummary(t, makeTrace([artifactOnly]), artifactOnly)).toContain('dest.state'); + }); + + it('skipped: activation by turns and by tokens', () => { + const byTurns = makeOp({ + status: 'skipped', + skipReason: 'activation_not_reached', + activation: { everyNTurns: 3, everyNContextTokens: null, turnsCounter: 1, tokensCounter: 0 }, + }); + expect(describeOperationSummary(t, makeTrace([byTurns]), byTurns)).toContain( + 'skip.activationTurns {"current":1,"target":3}', + ); + + const byTokens = makeOp({ + status: 'skipped', + skipReason: 'activation_not_reached', + activation: { everyNTurns: null, everyNContextTokens: 4000, turnsCounter: 0, tokensCounter: 1500 }, + }); + expect(describeOperationSummary(t, makeTrace([byTokens]), byTokens)).toContain( + 'skip.activationTokens {"current":1500,"target":4000}', + ); + }); + + it('skipped: guard condition with resolved source name', () => { + const guardOp = makeOp({ opId: 'op-guard', key: 'before_main_llm:op-guard', name: 'Гард перевода' }); + const skipped = makeOp({ + opId: 'op-translate', + key: 'before_main_llm:op-translate', + status: 'skipped', + skipReason: 'guard_not_matched', + guard: { sourceOpId: 'op-guard', outputKey: 'need_translate', operator: 'is_true', actual: false }, + }); + const summary = describeOperationSummary(t, makeTrace([guardOp, skipped]), skipped); + expect(summary).toContain('skip.guard'); + expect(summary).toContain('"name":"Гард перевода"'); + expect(summary).toContain('"key":"need_translate"'); + expect(summary).toContain('"actual":"operationProfiles.runTrace.actualNo"'); + }); + + it('skipped: dependency lists blocker names', () => { + const dep = makeOp({ opId: 'op-a', key: 'before_main_llm:op-a', name: 'Источник' }); + const skipped = makeOp({ + opId: 'op-b', + key: 'before_main_llm:op-b', + status: 'skipped', + skipReason: 'dependency_not_done', + blockedByOpIds: ['op-a'], + }); + expect(describeOperationSummary(t, makeTrace([dep, skipped]), skipped)).toContain('"names":"Источник"'); + }); + + it('error: prefers the error message', () => { + const op = makeOp({ status: 'error', errorMessage: 'boom' }); + expect(describeOperationSummary(t, makeTrace([op]), op)).toBe('boom'); + }); +}); + +describe('helpers', () => { + it('resolveOpName falls back to opId', () => { + expect(resolveOpName(makeTrace([]), 'op-x')).toBe('op-x'); + }); + + it('runStatusColor maps run status to badge colors', () => { + expect(runStatusColor('done')).toBe('teal'); + expect(runStatusColor('running')).toBe('blue'); + expect(runStatusColor('aborted')).toBe('yellow'); + expect(runStatusColor('failed')).toBe('red'); + expect(runStatusColor('error')).toBe('red'); + }); +}); diff --git a/web/src/features/sidebars/operation-profiles/ui/run-trace-summary.ts b/web/src/features/sidebars/operation-profiles/ui/run-trace-summary.ts new file mode 100644 index 00000000..26fe9c4f --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/run-trace-summary.ts @@ -0,0 +1,93 @@ +import type { RunTrace, TraceEffect, TraceOperation } from '@model/operation-run-trace'; + +export type TranslateFn = (key: string, params?: Record) => string; + +export function resolveOpName(trace: RunTrace, opId: string): string { + const found = trace.operations.find((op) => op.opId === opId); + return found?.name ?? opId; +} + +export function describeDestination(t: TranslateFn, effect: TraceEffect): string { + if (effect.type === 'prompt.system_update') { + const mode = effect.mode ? t(`operationProfiles.runTrace.modeLabel.${effect.mode}`) : ''; + return t('operationProfiles.runTrace.dest.systemPrompt', { mode }); + } + if (effect.type === 'prompt.append_after_last_user') return t('operationProfiles.runTrace.dest.promptAfterUser'); + if (effect.type === 'prompt.insert_at_depth') { + return t('operationProfiles.runTrace.dest.promptDepth', { depth: effect.depthFromEnd ?? 0 }); + } + if (effect.type === 'turn.user.replace_text') return t('operationProfiles.runTrace.dest.rewriteUser'); + if (effect.type === 'turn.assistant.replace_text') return t('operationProfiles.runTrace.dest.rewriteAssistant'); + if (effect.type === 'ui.inline') return t('operationProfiles.runTrace.dest.uiCard'); + return t('operationProfiles.runTrace.dest.state', { tag: effect.artifactId ?? '?' }); +} + +function describeDoneSummary(t: TranslateFn, op: TraceOperation): string { + const exposureEffects = op.effects.filter((effect) => effect.type !== 'artifact.upsert'); + const artifactEffect = op.effects.find((effect) => effect.type === 'artifact.upsert'); + const destinations = (exposureEffects.length > 0 ? exposureEffects : op.effects.slice(0, 1)).map((effect) => + describeDestination(t, effect), + ); + const chars = artifactEffect?.valueChars ?? exposureEffects[0]?.valueChars ?? null; + const charsLabel = chars !== null ? t('operationProfiles.runTrace.chars', { count: chars }) : null; + if (destinations.length === 0) return charsLabel ?? ''; + const target = destinations.join(' · '); + return charsLabel ? `${charsLabel} → ${target}` : target; +} + +function describeSkipSummary(t: TranslateFn, trace: RunTrace, op: TraceOperation): string { + if (op.skipReason === 'activation_not_reached') { + if (op.activation?.everyNTurns) { + return t('operationProfiles.runTrace.skip.activationTurns', { + current: op.activation.turnsCounter, + target: op.activation.everyNTurns, + }); + } + if (op.activation?.everyNContextTokens) { + return t('operationProfiles.runTrace.skip.activationTokens', { + current: op.activation.tokensCounter, + target: op.activation.everyNContextTokens, + }); + } + return t('operationProfiles.runTrace.skip.activation'); + } + if (op.skipReason === 'guard_not_matched') { + if (!op.guard) return t('operationProfiles.runTrace.skip.guardUnknown'); + return t('operationProfiles.runTrace.skip.guard', { + name: resolveOpName(trace, op.guard.sourceOpId), + key: op.guard.outputKey, + actual: + op.guard.actual === null + ? '—' + : op.guard.actual + ? t('operationProfiles.runTrace.actualYes') + : t('operationProfiles.runTrace.actualNo'), + }); + } + if (op.skipReason === 'dependency_not_done' || op.skipReason === 'dependency_missing') { + const names = op.blockedByOpIds.map((opId) => resolveOpName(trace, opId)); + return names.length > 0 + ? t('operationProfiles.runTrace.skip.dependency', { names: names.join(', ') }) + : t('operationProfiles.runTrace.opStatus.skipped'); + } + const known = ['disabled', 'unsupported_kind', 'orchestrator_aborted', 'filtered_out']; + if (op.skipReason && known.includes(op.skipReason)) { + return t(`operationProfiles.runTrace.skip.${op.skipReason}`); + } + return t('operationProfiles.runTrace.opStatus.skipped'); +} + +export function describeOperationSummary(t: TranslateFn, trace: RunTrace, op: TraceOperation): string { + if (op.status === 'running') return t('operationProfiles.runTrace.opStatus.running'); + if (op.status === 'done') return describeDoneSummary(t, op); + if (op.status === 'skipped') return describeSkipSummary(t, trace, op); + if (op.status === 'aborted') return t('operationProfiles.runTrace.opStatus.aborted'); + return op.errorMessage ?? t('operationProfiles.runTrace.opStatus.error'); +} + +export function runStatusColor(status: RunTrace['status']): string { + if (status === 'done') return 'teal'; + if (status === 'running') return 'blue'; + if (status === 'aborted') return 'yellow'; + return 'red'; +} diff --git a/web/src/i18n/resources/en/operationProfiles.ts b/web/src/i18n/resources/en/operationProfiles.ts index cb1cb8ee..633a125f 100644 --- a/web/src/i18n/resources/en/operationProfiles.ts +++ b/web/src/i18n/resources/en/operationProfiles.ts @@ -5,6 +5,74 @@ const enOperationProfiles = { tabs: { profiles: 'Profiles', blocks: 'Blocks', + run: 'Run', + }, + runTrace: { + title: 'Last run', + empty: 'No runs yet. Send a message in the chat — a report on operation activity will appear here.', + noOperations: 'No operations were executed in this run.', + runStatus: { + running: 'Generating', + done: 'Finished', + failed: 'Finished with an error', + aborted: 'Aborted', + error: 'Error', + }, + trigger: { + generate: 'generate', + regenerate: 'regenerate', + }, + hook: { + before_main_llm: 'Before the model reply', + after_main_llm: 'After the model reply', + unknown: 'Other', + }, + opStatus: { + running: 'running…', + done: 'done', + skipped: 'skipped', + error: 'error', + aborted: 'aborted', + }, + chars: '{{count}} chars', + duration: '{{seconds}} s', + actualYes: 'yes', + actualNo: 'no', + skip: { + activationTurns: 'will fire later: turn {{current}} of {{target}}', + activationTokens: 'will fire later: {{current}} of {{target}} tokens', + activation: 'will fire later (interval activation)', + guard: 'condition not met: “{{name}}” → {{key}} = {{actual}}', + guardUnknown: 'guard condition not met', + dependency: 'waited for: {{names}}', + disabled: 'disabled', + unsupported_kind: 'this operation kind is not executed yet', + orchestrator_aborted: 'the run was aborted', + filtered_out: 'does not match the current run', + }, + dest: { + systemPrompt: 'system prompt ({{mode}})', + promptAfterUser: 'prompt: after the player message', + promptDepth: 'prompt: depth {{depth}}', + rewriteUser: 'player message rewritten', + rewriteAssistant: 'model reply rewritten', + uiCard: 'card in chat', + state: 'state “{{tag}}”', + }, + modeLabel: { + prepend: 'prepend', + append: 'append', + replace: 'replace', + }, + details: { + commitError: 'commit error', + commitStatus: { + applied: 'applied', + skipped: 'skipped', + error: 'error', + }, + valueEmpty: '(empty)', + }, }, defaults: { newProfile: 'New profile', diff --git a/web/src/i18n/resources/ru/operationProfiles.ts b/web/src/i18n/resources/ru/operationProfiles.ts index c7556179..f8879c9f 100644 --- a/web/src/i18n/resources/ru/operationProfiles.ts +++ b/web/src/i18n/resources/ru/operationProfiles.ts @@ -5,6 +5,74 @@ const ruOperationProfiles = { tabs: { profiles: 'Профили', blocks: 'Блоки', + run: 'Запуск', + }, + runTrace: { + title: 'Последний запуск', + empty: 'Запусков ещё не было. Отправьте сообщение в чате — здесь появится отчёт о работе операций.', + noOperations: 'Операции в этом запуске не выполнялись.', + runStatus: { + running: 'Идёт генерация', + done: 'Завершён', + failed: 'Завершён с ошибкой', + aborted: 'Прерван', + error: 'Ошибка', + }, + trigger: { + generate: 'генерация', + regenerate: 'регенерация', + }, + hook: { + before_main_llm: 'До ответа модели', + after_main_llm: 'После ответа модели', + unknown: 'Прочее', + }, + opStatus: { + running: 'выполняется…', + done: 'выполнена', + skipped: 'пропущена', + error: 'ошибка', + aborted: 'прервана', + }, + chars: '{{count}} симв.', + duration: '{{seconds}} с', + actualYes: 'да', + actualNo: 'нет', + skip: { + activationTurns: 'сработает позже: ход {{current}} из {{target}}', + activationTokens: 'сработает позже: {{current}} из {{target}} токенов', + activation: 'сработает позже (активация по интервалу)', + guard: 'условие не выполнено: «{{name}}» → {{key}} = {{actual}}', + guardUnknown: 'условие гарда не выполнено', + dependency: 'ждала: {{names}}', + disabled: 'выключена', + unsupported_kind: 'этот тип операции пока не выполняется', + orchestrator_aborted: 'запуск был прерван', + filtered_out: 'не подходит под текущий запуск', + }, + dest: { + systemPrompt: 'system-промпт ({{mode}})', + promptAfterUser: 'промпт: после сообщения игрока', + promptDepth: 'промпт: глубина {{depth}}', + rewriteUser: 'переписана реплика игрока', + rewriteAssistant: 'переписан ответ модели', + uiCard: 'карточка в чате', + state: 'состояние «{{tag}}»', + }, + modeLabel: { + prepend: 'в начало', + append: 'в конец', + replace: 'замена', + }, + details: { + commitError: 'ошибка применения', + commitStatus: { + applied: 'применён', + skipped: 'пропущен', + error: 'ошибка', + }, + valueEmpty: '(пусто)', + }, }, defaults: { newProfile: 'Новый профиль', diff --git a/web/src/model/chat-entry-parts/index.ts b/web/src/model/chat-entry-parts/index.ts index da652bf0..abbc9bb7 100644 --- a/web/src/model/chat-entry-parts/index.ts +++ b/web/src/model/chat-entry-parts/index.ts @@ -1464,33 +1464,17 @@ sample({ target: applyStreamPatch, }); +// Per-operation progress lives in the "Запуск" tab of the operations sidebar +// (model/operation-run-trace); toasts stay only for failures. handleSseEnvelope.watch((env) => { + if (env.type !== 'operation.finished') return; const data = typeof env.data === 'object' && env.data !== null ? (env.data as Record) : null; if (!data) return; + const status = typeof data.status === 'string' ? data.status : ''; + if (status !== 'error' && status !== 'aborted') return; const name = typeof data.name === 'string' && data.name.trim().length > 0 ? data.name : String(data.opId ?? 'operation'); const hook = typeof data.hook === 'string' && data.hook.trim().length > 0 ? data.hook : 'unknown'; - if (env.type === 'operation.started') { - toaster.info({ - title: i18n.t('chat.toasts.operationStarted', { name, hook }), - }); - return; - } - - if (env.type !== 'operation.finished') return; - const status = typeof data.status === 'string' ? data.status : ''; - if (status === 'done') { - toaster.success({ - title: i18n.t('chat.toasts.operationFinishedDone', { name, hook }), - }); - return; - } - if (status === 'skipped') { - toaster.warning({ - title: i18n.t('chat.toasts.operationFinishedSkipped', { name, hook }), - }); - return; - } if (status === 'aborted') { toaster.error({ title: i18n.t('chat.toasts.operationFinishedAborted', { name, hook }), diff --git a/web/src/model/operation-run-trace/index.test.ts b/web/src/model/operation-run-trace/index.test.ts new file mode 100644 index 00000000..abb3298d --- /dev/null +++ b/web/src/model/operation-run-trace/index.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import { reduceRunTrace, type RunTrace } from './index'; + +import type { SseEnvelope } from '../../api/chat-core'; + +function env(type: string, data: Record, ts = 1000): SseEnvelope { + return { id: 'evt', type, ts, data }; +} + +function startedTrace(): RunTrace | null { + return reduceRunTrace( + null, + env('run.started', { + runId: 'run-1', + generationId: 'gen-1', + chatId: 'chat-1', + branchId: 'branch-1', + trigger: 'generate', + }), + ); +} + +describe('reduceRunTrace', () => { + it('starts a new trace on run.started and resets the previous one', () => { + const first = startedTrace(); + expect(first?.runId).toBe('run-1'); + expect(first?.status).toBe('running'); + expect(first?.operations).toEqual([]); + + const second = reduceRunTrace(first, env('run.started', { runId: 'run-2', trigger: 'regenerate' }, 2000)); + expect(second?.runId).toBe('run-2'); + expect(second?.trigger).toBe('regenerate'); + expect(second?.operations).toEqual([]); + }); + + it('ignores events before any run.started and events from another run', () => { + expect(reduceRunTrace(null, env('operation.started', { runId: 'run-1', opId: 'op-1' }))).toBeNull(); + + const trace = startedTrace(); + const next = reduceRunTrace(trace, env('operation.started', { runId: 'run-other', opId: 'op-1', hook: 'before_main_llm' })); + expect(next?.operations).toEqual([]); + }); + + it('collects started and finished operations with effects', () => { + let trace = startedTrace(); + trace = reduceRunTrace( + trace, + env('operation.started', { runId: 'run-1', hook: 'before_main_llm', opId: 'op-1', name: 'Summary' }, 1100), + ); + expect(trace?.operations[0]?.status).toBe('running'); + expect(trace?.operations[0]?.startedAtTs).toBe(1100); + + trace = reduceRunTrace( + trace, + env( + 'operation.finished', + { + runId: 'run-1', + hook: 'before_main_llm', + opId: 'op-1', + name: 'Summary', + status: 'done', + result: { + debugSummary: 'prompt.insert_at_depth:412', + effects: [ + { + type: 'artifact.upsert', + opId: 'op-1', + artifactId: 'story_summary', + value: 'a'.repeat(300), + }, + { + type: 'prompt.insert_at_depth', + opId: 'op-1', + role: 'system', + depthFromEnd: 4, + payload: 'short text', + }, + ], + }, + }, + 1500, + ), + ); + + const op = trace?.operations[0]; + expect(op?.status).toBe('done'); + expect(op?.finishedAtTs).toBe(1500); + expect(op?.effects).toHaveLength(2); + expect(op?.effects[0]).toMatchObject({ type: 'artifact.upsert', artifactId: 'story_summary', valueChars: 300 }); + expect(op?.effects[0]?.valuePreview.length).toBeLessThanOrEqual(201); + expect(op?.effects[1]).toMatchObject({ type: 'prompt.insert_at_depth', role: 'system', depthFromEnd: 4, valueChars: 10 }); + }); + + it('records skipped operations that never started, with skip details', () => { + let trace = startedTrace(); + trace = reduceRunTrace( + trace, + env('operation.finished', { + runId: 'run-1', + hook: 'before_main_llm', + opId: 'op-2', + name: 'Journal', + status: 'skipped', + skipReason: 'activation_not_reached', + skipDetails: { + activation: { everyNTurns: 3, turnsCounter: 1, tokensCounter: 0 }, + }, + }), + ); + + const op = trace?.operations[0]; + expect(op?.status).toBe('skipped'); + expect(op?.skipReason).toBe('activation_not_reached'); + expect(op?.activation).toMatchObject({ everyNTurns: 3, turnsCounter: 1 }); + + trace = reduceRunTrace( + trace, + env('operation.finished', { + runId: 'run-1', + hook: 'before_main_llm', + opId: 'op-3', + name: 'Translate', + status: 'skipped', + skipReason: 'guard_not_matched', + skipDetails: { + guard: { sourceOpId: 'op-guard', outputKey: 'need_translate', operator: 'is_true', actual: false }, + }, + }), + ); + expect(trace?.operations[1]?.guard).toMatchObject({ outputKey: 'need_translate', actual: false }); + }); + + it('attaches commit results to the operation', () => { + let trace = startedTrace(); + trace = reduceRunTrace( + trace, + env('operation.finished', { + runId: 'run-1', + hook: 'before_main_llm', + opId: 'op-1', + name: 'Summary', + status: 'done', + result: { effects: [] }, + }), + ); + trace = reduceRunTrace( + trace, + env('commit.effect_applied', { runId: 'run-1', hook: 'before_main_llm', opId: 'op-1', effectType: 'artifact.upsert' }), + ); + trace = reduceRunTrace( + trace, + env('commit.effect_error', { + runId: 'run-1', + hook: 'before_main_llm', + opId: 'op-1', + effectType: 'prompt.system_update', + message: 'boom', + }), + ); + + expect(trace?.operations[0]?.commits).toEqual([ + { effectType: 'artifact.upsert', status: 'applied', message: null }, + { effectType: 'prompt.system_update', status: 'error', message: 'boom' }, + ]); + }); + + it('tracks main llm and finalizes the run, aborting stuck operations', () => { + let trace = startedTrace(); + trace = reduceRunTrace(trace, env('main_llm.started', { runId: 'run-1', providerId: 'openrouter', model: 'x' })); + trace = reduceRunTrace( + trace, + env('operation.started', { runId: 'run-1', hook: 'after_main_llm', opId: 'op-9', name: 'Post' }), + ); + trace = reduceRunTrace(trace, env('main_llm.finished', { runId: 'run-1', status: 'done' })); + trace = reduceRunTrace(trace, env('run.finished', { runId: 'run-1', status: 'done', failedType: null }, 9000)); + + expect(trace?.mainLlm).toMatchObject({ providerId: 'openrouter', model: 'x', status: 'done' }); + expect(trace?.status).toBe('done'); + expect(trace?.finishedAtTs).toBe(9000); + expect(trace?.operations[0]?.status).toBe('aborted'); + }); +}); diff --git a/web/src/model/operation-run-trace/index.ts b/web/src/model/operation-run-trace/index.ts new file mode 100644 index 00000000..af28d520 --- /dev/null +++ b/web/src/model/operation-run-trace/index.ts @@ -0,0 +1,326 @@ +import { createStore } from 'effector'; + +import { handleSseEnvelope } from '../chat-entry-parts'; + +import type { SseEnvelope } from '../../api/chat-core'; + +const VALUE_PREVIEW_MAX_CHARS = 200; + +export type TraceOperationStatus = 'running' | 'done' | 'skipped' | 'error' | 'aborted'; + +export type TraceRunStatus = 'running' | 'done' | 'failed' | 'aborted' | 'error'; + +export type TraceEffect = { + type: string; + mode: string | null; + role: string | null; + depthFromEnd: number | null; + artifactId: string | null; + valuePreview: string; + valueChars: number; +}; + +export type TraceCommit = { + effectType: string; + status: 'applied' | 'skipped' | 'error'; + message: string | null; +}; + +export type TraceActivationSnapshot = { + everyNTurns: number | null; + everyNContextTokens: number | null; + turnsCounter: number; + tokensCounter: number; +}; + +export type TraceGuardSnapshot = { + sourceOpId: string; + outputKey: string; + operator: string; + actual: boolean | null; +}; + +export type TraceOperation = { + key: string; + opId: string; + name: string; + hook: string; + status: TraceOperationStatus; + skipReason: string | null; + activation: TraceActivationSnapshot | null; + guard: TraceGuardSnapshot | null; + blockedByOpIds: string[]; + errorMessage: string | null; + debugSummary: string | null; + effects: TraceEffect[]; + commits: TraceCommit[]; + startedAtTs: number | null; + finishedAtTs: number | null; +}; + +export type RunTrace = { + runId: string; + generationId: string | null; + chatId: string | null; + branchId: string | null; + trigger: string | null; + status: TraceRunStatus; + failedType: string | null; + errorMessage: string | null; + startedAtTs: number; + finishedAtTs: number | null; + mainLlm: { + providerId: string | null; + model: string | null; + status: string | null; + } | null; + operations: TraceOperation[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getString(data: Record, key: string): string | null { + const value = data[key]; + return typeof value === 'string' ? value : null; +} + +function getNumber(data: Record, key: string): number | null { + const value = data[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function toValuePreview(value: unknown): { preview: string; chars: number } { + const text = typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value) ?? ''; + return { + preview: text.length > VALUE_PREVIEW_MAX_CHARS ? `${text.slice(0, VALUE_PREVIEW_MAX_CHARS)}…` : text, + chars: text.length, + }; +} + +function readTraceEffect(raw: unknown): TraceEffect | null { + if (!isRecord(raw)) return null; + const type = getString(raw, 'type'); + if (!type) return null; + const value = 'value' in raw ? raw.value : 'payload' in raw ? raw.payload : 'text' in raw ? raw.text : undefined; + const { preview, chars } = toValuePreview(value); + const target = getString(raw, 'target'); + return { + type, + mode: getString(raw, 'mode'), + role: getString(raw, 'role') ?? target, + depthFromEnd: getNumber(raw, 'depthFromEnd'), + artifactId: getString(raw, 'artifactId'), + valuePreview: preview, + valueChars: chars, + }; +} + +function readActivation(raw: unknown): TraceActivationSnapshot | null { + if (!isRecord(raw)) return null; + return { + everyNTurns: getNumber(raw, 'everyNTurns'), + everyNContextTokens: getNumber(raw, 'everyNContextTokens'), + turnsCounter: getNumber(raw, 'turnsCounter') ?? 0, + tokensCounter: getNumber(raw, 'tokensCounter') ?? 0, + }; +} + +function readGuard(raw: unknown): TraceGuardSnapshot | null { + if (!isRecord(raw)) return null; + const sourceOpId = getString(raw, 'sourceOpId'); + const outputKey = getString(raw, 'outputKey'); + const operator = getString(raw, 'operator'); + if (!sourceOpId || !outputKey || !operator) return null; + const actual = raw.actual; + return { + sourceOpId, + outputKey, + operator, + actual: typeof actual === 'boolean' ? actual : null, + }; +} + +function readBlockedByOpIds(raw: unknown): string[] { + if (!isRecord(raw)) return []; + const value = raw.blockedByOpIds; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string'); +} + +function operationKey(hook: string, opId: string): string { + return `${hook}:${opId}`; +} + +function makeOperation(params: { hook: string; opId: string; name: string }): TraceOperation { + return { + key: operationKey(params.hook, params.opId), + opId: params.opId, + name: params.name, + hook: params.hook, + status: 'running', + skipReason: null, + activation: null, + guard: null, + blockedByOpIds: [], + errorMessage: null, + debugSummary: null, + effects: [], + commits: [], + startedAtTs: null, + finishedAtTs: null, + }; +} + +function upsertOperation( + trace: RunTrace, + params: { hook: string; opId: string; name: string }, + update: (op: TraceOperation) => TraceOperation, +): RunTrace { + const key = operationKey(params.hook, params.opId); + const existingIndex = trace.operations.findIndex((op) => op.key === key); + if (existingIndex < 0) { + return { ...trace, operations: [...trace.operations, update(makeOperation(params))] }; + } + const operations = trace.operations.map((op, index) => (index === existingIndex ? update(op) : op)); + return { ...trace, operations }; +} + +export function reduceRunTrace(current: RunTrace | null, env: SseEnvelope): RunTrace | null { + const data = isRecord(env.data) ? env.data : null; + if (!data) return current; + const ts = typeof env.ts === 'number' ? env.ts : Date.now(); + + if (env.type === 'run.started') { + const runId = getString(data, 'runId'); + if (!runId) return current; + return { + runId, + generationId: getString(data, 'generationId'), + chatId: getString(data, 'chatId'), + branchId: getString(data, 'branchId'), + trigger: getString(data, 'trigger'), + status: 'running', + failedType: null, + errorMessage: null, + startedAtTs: ts, + finishedAtTs: null, + mainLlm: null, + operations: [], + }; + } + + if (!current) return current; + const runId = getString(data, 'runId'); + if (runId && runId !== current.runId) return current; + + if (env.type === 'operation.started') { + const opId = getString(data, 'opId'); + const hook = getString(data, 'hook') ?? 'unknown'; + if (!opId) return current; + const name = getString(data, 'name') ?? opId; + return upsertOperation(current, { hook, opId, name }, (op) => ({ + ...op, + name, + status: 'running', + startedAtTs: op.startedAtTs ?? ts, + })); + } + + if (env.type === 'operation.finished') { + const opId = getString(data, 'opId'); + const hook = getString(data, 'hook') ?? 'unknown'; + if (!opId) return current; + const name = getString(data, 'name') ?? opId; + const statusRaw = getString(data, 'status'); + const status: TraceOperationStatus = + statusRaw === 'done' || statusRaw === 'skipped' || statusRaw === 'error' || statusRaw === 'aborted' + ? statusRaw + : 'error'; + const skipDetails = isRecord(data.skipDetails) ? data.skipDetails : null; + const error = isRecord(data.error) ? data.error : null; + const result = isRecord(data.result) ? data.result : null; + const effectsRaw = result && Array.isArray(result.effects) ? result.effects : []; + const effects = effectsRaw + .map((item) => readTraceEffect(item)) + .filter((item): item is TraceEffect => item !== null); + + return upsertOperation(current, { hook, opId, name }, (op) => ({ + ...op, + name, + status, + skipReason: getString(data, 'skipReason'), + activation: skipDetails ? readActivation(skipDetails.activation) : null, + guard: skipDetails ? readGuard(skipDetails.guard) : null, + blockedByOpIds: readBlockedByOpIds(skipDetails), + errorMessage: error ? getString(error, 'message') : null, + debugSummary: result ? getString(result, 'debugSummary') : null, + effects, + finishedAtTs: ts, + })); + } + + if ( + env.type === 'commit.effect_applied' || + env.type === 'commit.effect_skipped' || + env.type === 'commit.effect_error' + ) { + const opId = getString(data, 'opId'); + const hook = getString(data, 'hook') ?? 'unknown'; + const effectType = getString(data, 'effectType'); + if (!opId || !effectType) return current; + const status: TraceCommit['status'] = + env.type === 'commit.effect_applied' ? 'applied' : env.type === 'commit.effect_skipped' ? 'skipped' : 'error'; + return upsertOperation(current, { hook, opId, name: opId }, (op) => ({ + ...op, + commits: [...op.commits, { effectType, status, message: getString(data, 'message') }], + })); + } + + if (env.type === 'main_llm.started') { + return { + ...current, + mainLlm: { + providerId: getString(data, 'providerId'), + model: getString(data, 'model'), + status: 'running', + }, + }; + } + + if (env.type === 'main_llm.finished') { + return { + ...current, + mainLlm: { + providerId: current.mainLlm?.providerId ?? null, + model: current.mainLlm?.model ?? null, + status: getString(data, 'status'), + }, + }; + } + + if (env.type === 'run.finished') { + const statusRaw = getString(data, 'status'); + const status: TraceRunStatus = + statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'aborted' || statusRaw === 'error' + ? statusRaw + : 'error'; + return { + ...current, + status, + failedType: getString(data, 'failedType'), + errorMessage: getString(data, 'message'), + finishedAtTs: ts, + operations: current.operations.map((op) => + op.status === 'running' ? { ...op, status: 'aborted', finishedAtTs: op.finishedAtTs ?? ts } : op, + ), + }; + } + + return current; +} + +export const $lastRunTrace = createStore(null).on(handleSseEnvelope, (state, env) => + reduceRunTrace(state, env), +); From baa690bf5a5c49d8a02bb79aad6d8f3e383d4806 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 13 Jul 2026 01:41:48 +0300 Subject: [PATCH 03/14] fix: finalize generation preparation failures --- server/src/api/chat-entries.api.ts | 148 +----------------- server/src/api/chat-generation-stream.test.ts | 113 +++++++++++++ server/src/api/chat-generation-stream.ts | 144 +++++++++++++++++ .../chat-runtime/chat-generation-helpers.ts | 7 + .../chat-generation-use-cases.test.ts | 36 +++++ .../use-cases/continue-generation.ts | 1 + .../create-entry-and-start-generation.ts | 1 + .../services/chat-generation-v3/contracts.ts | 11 ++ .../orchestration/finalize-run-failure.ts | 56 +++++++ .../run-chat-generation-v3.test.ts | 78 ++++++++- .../run-chat-generation-v3.ts | 53 ++++--- 11 files changed, 477 insertions(+), 171 deletions(-) create mode 100644 server/src/api/chat-generation-stream.test.ts create mode 100644 server/src/api/chat-generation-stream.ts create mode 100644 server/src/services/chat-generation-v3/orchestration/finalize-run-failure.ts diff --git a/server/src/api/chat-entries.api.ts b/server/src/api/chat-entries.api.ts index de1a9527..f932fbc2 100644 --- a/server/src/api/chat-entries.api.ts +++ b/server/src/api/chat-entries.api.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { asyncHandler } from "@core/middleware/async-handler"; import { HttpError } from "@core/middleware/error-handler"; import { validate } from "@core/middleware/validate"; -import { initSse, type SseWriter } from "@core/sse/sse"; import { batchUpdateEntryParts } from "../application/chat-runtime/use-cases/batch-update-entry-parts"; import { continueGeneration } from "../application/chat-runtime/use-cases/continue-generation"; @@ -21,7 +20,6 @@ import { selectEntryVariant } from "../application/chat-runtime/use-cases/select import { setEntryPromptVisibility } from "../application/chat-runtime/use-cases/set-entry-prompt-visibility"; import { undoPartCanonicalization } from "../application/chat-runtime/use-cases/undo-part-canonicalization"; import { chatIdParamsSchema } from "../chat-core/schemas"; -import { GenerationControlService } from "../services/chat-core/generation-control-service"; import { getEntryById, softDeleteEntry, @@ -29,9 +27,9 @@ import { } from "../services/chat-entry-parts/entries-repository"; import { softDeletePart } from "../services/chat-entry-parts/parts-repository"; +import { streamGenerationSession } from "./chat-generation-stream"; + import type { BatchUpdateEntryPartsBody as ChatRuntimeBatchUpdateEntryPartsBody } from "../application/chat-runtime/chat-entry-helpers"; -import type { ChatGenerationSession } from "../application/chat-runtime/contracts"; -import type { RunEvent } from "../services/chat-generation-v3/contracts"; const router = express.Router(); @@ -42,148 +40,6 @@ function ensureSseRequested(req: Request): void { } } -function mapRunStatusToStreamDoneStatus(status: "done" | "failed" | "aborted" | "error"): "done" | "aborted" | "error" { - if (status === "done") return "done"; - if (status === "aborted") return "aborted"; - return "error"; -} - -type RunProxySummary = { - runStatus: "done" | "failed" | "aborted" | "error" | null; - sawTextDelta: boolean; -}; - -async function proxyRunEventsToSse(params: { - sse: SseWriter; - events: AsyncGenerator; - envBase: Record; - reqClosed: () => boolean; - abortController: AbortController; - onGenerationId: (generationId: string) => void; -}): Promise { - let generationId: string | null = null; - const summary: RunProxySummary = { - runStatus: null, - sawTextDelta: false, - }; - - for await (const evt of params.events) { - if (evt.type === "run.started") { - generationId = evt.data.generationId; - params.onGenerationId(generationId); - if (params.reqClosed()) { - params.abortController.abort(); - void GenerationControlService.requestAbort(generationId); - } - params.sse.send("llm.stream.meta", { ...params.envBase, generationId }); - } - - const eventGenerationId = - generationId ?? (evt.type === "run.started" ? evt.data.generationId : null); - const eventEnvelope = { - ...params.envBase, - generationId: eventGenerationId, - runId: evt.runId, - seq: evt.seq, - ...evt.data, - }; - params.sse.send(evt.type, eventEnvelope); - - if (evt.type === "main_llm.delta") { - if (evt.data.content.length > 0) summary.sawTextDelta = true; - params.sse.send("llm.stream.delta", { - ...params.envBase, - generationId: eventGenerationId, - content: evt.data.content, - }); - continue; - } - - if (evt.type === "main_llm.reasoning_delta") { - if (evt.data.content.length > 0) summary.sawTextDelta = true; - params.sse.send("llm.stream.reasoning_delta", { - ...params.envBase, - generationId: eventGenerationId, - content: evt.data.content, - }); - continue; - } - - if (evt.type === "main_llm.finished" && evt.data.status === "error") { - params.sse.send("llm.stream.error", { - ...params.envBase, - generationId: eventGenerationId, - code: "generation_error", - message: evt.data.message ?? "generation_error", - }); - continue; - } - - if (evt.type === "run.finished") { - summary.runStatus = evt.data.status; - params.sse.send("llm.stream.done", { - ...params.envBase, - generationId: eventGenerationId, - status: mapRunStatusToStreamDoneStatus(evt.data.status), - }); - if (evt.data.status !== "done" && evt.data.message) { - params.sse.send("llm.stream.error", { - ...params.envBase, - generationId: eventGenerationId, - code: "generation_error", - message: evt.data.message, - }); - } - break; - } - } - - return summary; -} - -async function streamGenerationSession(params: { - req: Request; - res: Response; - buildSession: (abortController: AbortController) => Promise; -}): Promise { - const sse = initSse({ res: params.res }); - let generationId: string | null = null; - const runAbortController = new AbortController(); - let shouldAbortOnClose = false; - let reqClosed = false; - - params.res.on("close", () => { - reqClosed = true; - if (shouldAbortOnClose) { - runAbortController.abort(); - if (generationId) void GenerationControlService.requestAbort(generationId); - } - sse.close(); - }); - - try { - const session = await params.buildSession(runAbortController); - shouldAbortOnClose = true; - if (reqClosed) { - runAbortController.abort(); - if (generationId) void GenerationControlService.requestAbort(generationId); - } - - await proxyRunEventsToSse({ - sse, - envBase: session.envBase, - reqClosed: () => reqClosed, - abortController: runAbortController, - onGenerationId: (id) => { - generationId = id; - }, - events: session.events, - }); - } finally { - sse.close(); - } -} - const listEntriesQuerySchema = z .object({ branchId: z.string().min(1).optional(), diff --git a/server/src/api/chat-generation-stream.test.ts b/server/src/api/chat-generation-stream.test.ts new file mode 100644 index 00000000..fc2022f2 --- /dev/null +++ b/server/src/api/chat-generation-stream.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test, vi } from "vitest"; + +import { proxyRunEventsToSse } from "./chat-generation-stream"; + +import type { RunEvent } from "../services/chat-generation-v3/contracts"; +import type { SseWriter } from "@core/sse/sse"; + +describe("proxyRunEventsToSse", () => { + test("preserves the normal generation stream compatibility events", async () => { + const sent: Array<{ type: string; data: unknown }> = []; + const sse: SseWriter = { + send: (type, data) => sent.push({ type, data }), + heartbeat: vi.fn(), + close: vi.fn(), + }; + const events = (async function* (): AsyncGenerator { + yield { + runId: "generation-1", + seq: 1, + type: "run.started", + data: { generationId: "generation-1", trigger: "generate" }, + }; + yield { + runId: "generation-1", + seq: 2, + type: "main_llm.delta", + data: { content: "hello" }, + }; + yield { + runId: "generation-1", + seq: 3, + type: "run.finished", + data: { generationId: "generation-1", status: "done", failedType: null }, + }; + })(); + const onGenerationId = vi.fn(); + + await proxyRunEventsToSse({ + sse, + events, + envBase: { chatId: "chat-1" }, + reqClosed: () => false, + abortController: new AbortController(), + onGenerationId, + }); + + expect(onGenerationId).toHaveBeenCalledWith("generation-1"); + expect(sent.map((event) => event.type)).toEqual([ + "llm.stream.meta", + "run.started", + "main_llm.delta", + "llm.stream.delta", + "run.finished", + "llm.stream.done", + ]); + expect(sent[sent.length - 1]?.data).toMatchObject({ + generationId: "generation-1", + status: "done", + }); + }); + + test("maps preparation failure to terminal SSE error events", async () => { + const sent: Array<{ type: string; data: unknown }> = []; + const sse: SseWriter = { + send: (type, data) => sent.push({ type, data }), + heartbeat: vi.fn(), + close: vi.fn(), + }; + const events = (async function* (): AsyncGenerator { + yield { + runId: "request-1", + seq: 1, + type: "run.preparation_failed", + data: { + generationId: null, + status: "error", + code: "generation_preparation_error", + message: "profile compilation failed", + }, + }; + })(); + + await proxyRunEventsToSse({ + sse, + events, + envBase: { chatId: "chat-1" }, + reqClosed: () => false, + abortController: new AbortController(), + onGenerationId: vi.fn(), + }); + + expect(sent).toEqual([ + expect.objectContaining({ type: "run.preparation_failed" }), + { + type: "llm.stream.error", + data: { + chatId: "chat-1", + generationId: null, + code: "generation_preparation_error", + message: "profile compilation failed", + }, + }, + { + type: "llm.stream.done", + data: { + chatId: "chat-1", + generationId: null, + status: "error", + }, + }, + ]); + }); +}); diff --git a/server/src/api/chat-generation-stream.ts b/server/src/api/chat-generation-stream.ts new file mode 100644 index 00000000..7980fbe8 --- /dev/null +++ b/server/src/api/chat-generation-stream.ts @@ -0,0 +1,144 @@ +import { type Request, type Response } from "express"; + +import { initSse, type SseWriter } from "@core/sse/sse"; + +import { GenerationControlService } from "../services/chat-core/generation-control-service"; + +import type { ChatGenerationSession } from "../application/chat-runtime/contracts"; +import type { RunEvent } from "../services/chat-generation-v3/contracts"; + +function mapRunStatusToStreamDoneStatus( + status: "done" | "failed" | "aborted" | "error" +): "done" | "aborted" | "error" { + if (status === "done") return "done"; + if (status === "aborted") return "aborted"; + return "error"; +} + +export async function proxyRunEventsToSse(params: { + sse: SseWriter; + events: AsyncGenerator; + envBase: Record; + reqClosed: () => boolean; + abortController: AbortController; + onGenerationId: (generationId: string) => void; +}): Promise { + let generationId: string | null = null; + + for await (const evt of params.events) { + if (evt.type === "run.started") { + generationId = evt.data.generationId; + params.onGenerationId(generationId); + if (params.reqClosed()) { + params.abortController.abort(); + void GenerationControlService.requestAbort(generationId); + } + params.sse.send("llm.stream.meta", { ...params.envBase, generationId }); + } + + const eventEnvelope = { + ...params.envBase, + generationId, + runId: evt.runId, + seq: evt.seq, + ...evt.data, + }; + params.sse.send(evt.type, eventEnvelope); + + if (evt.type === "run.preparation_failed") { + params.sse.send("llm.stream.error", { + ...params.envBase, + generationId: null, + code: evt.data.code, + message: evt.data.message, + }); + params.sse.send("llm.stream.done", { + ...params.envBase, + generationId: null, + status: "error", + }); + break; + } + + if (evt.type === "main_llm.delta" || evt.type === "main_llm.reasoning_delta") { + const streamType = + evt.type === "main_llm.delta" ? "llm.stream.delta" : "llm.stream.reasoning_delta"; + params.sse.send(streamType, { + ...params.envBase, + generationId, + content: evt.data.content, + }); + continue; + } + + if (evt.type === "main_llm.finished" && evt.data.status === "error") { + params.sse.send("llm.stream.error", { + ...params.envBase, + generationId, + code: "generation_error", + message: evt.data.message ?? "generation_error", + }); + continue; + } + + if (evt.type === "run.finished") { + params.sse.send("llm.stream.done", { + ...params.envBase, + generationId, + status: mapRunStatusToStreamDoneStatus(evt.data.status), + }); + if (evt.data.status !== "done" && evt.data.message) { + params.sse.send("llm.stream.error", { + ...params.envBase, + generationId, + code: "generation_error", + message: evt.data.message, + }); + } + break; + } + } +} + +export async function streamGenerationSession(params: { + req: Request; + res: Response; + buildSession: (abortController: AbortController) => Promise; +}): Promise { + const sse = initSse({ res: params.res }); + let generationId: string | null = null; + const runAbortController = new AbortController(); + let shouldAbortOnClose = false; + let reqClosed = false; + + params.res.on("close", () => { + reqClosed = true; + if (shouldAbortOnClose) { + runAbortController.abort(); + if (generationId) void GenerationControlService.requestAbort(generationId); + } + sse.close(); + }); + + try { + const session = await params.buildSession(runAbortController); + shouldAbortOnClose = true; + if (reqClosed) { + runAbortController.abort(); + if (generationId) void GenerationControlService.requestAbort(generationId); + } + + await proxyRunEventsToSse({ + sse, + envBase: session.envBase, + reqClosed: () => reqClosed, + abortController: runAbortController, + onGenerationId: (id) => { + generationId = id; + }, + events: session.events, + }); + } finally { + sse.close(); + } +} diff --git a/server/src/application/chat-runtime/chat-generation-helpers.ts b/server/src/application/chat-runtime/chat-generation-helpers.ts index a75024ce..441333bc 100644 --- a/server/src/application/chat-runtime/chat-generation-helpers.ts +++ b/server/src/application/chat-runtime/chat-generation-helpers.ts @@ -2,6 +2,7 @@ import { getGenerationByIdWithDebug } from "../../services/chat-core/generations import { getActiveVariantWithParts, listEntries, + softDeleteEntry, } from "../../services/chat-entry-parts/entries-repository"; import { createPart } from "../../services/chat-entry-parts/parts-repository"; import { @@ -141,12 +142,18 @@ export async function finalizeChatGenerationArtifacts(params: { generationId: string | null; assistantVariantId: string; cleanupEntryId?: string; + cleanupEmptyEntryId?: string; }): Promise { if (params.generationId) { await linkVariantToGeneration({ variantId: params.assistantVariantId, generationId: params.generationId, }); + } else if (params.cleanupEmptyEntryId) { + await softDeleteEntry({ + entryId: params.cleanupEmptyEntryId, + by: "agent", + }); } if (params.cleanupEntryId) { diff --git a/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts b/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts index 718987f0..a213c749 100644 --- a/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts +++ b/server/src/application/chat-runtime/use-cases/chat-generation-use-cases.test.ts @@ -228,6 +228,42 @@ describe("chat generation application use cases", () => { }); }); + test("createEntryAndStartGeneration removes empty assistant scaffolding on preparation failure", async () => { + const fixture = await seedChatFixture(); + mocks.runChatGenerationV3.mockImplementationOnce(async function* () { + yield { + runId: "request-prepare-failure", + seq: 1, + type: "run.preparation_failed", + data: { + generationId: null, + status: "error", + code: "generation_preparation_error", + message: "profile compilation failed", + }, + } as RunEvent; + }); + + const session = await createEntryAndStartGeneration({ + chatId: fixture.chatId, + body: { + role: "user", + content: "Hello there", + settings: {}, + requestId: "request-prepare-failure", + }, + }); + + await collectEvents(session.events); + const db = await initDb(); + const assistantRows = await db + .select({ softDeleted: chatEntries.softDeleted }) + .from(chatEntries) + .where(eq(chatEntries.entryId, String(session.envBase.assistantEntryId))); + + expect(assistantRows[0]?.softDeleted).toBe(true); + }); + test("continueGeneration returns session metadata for the latest user turn", async () => { const fixture = await seedChatFixture(); const user = await createEntryWithVariant({ diff --git a/server/src/application/chat-runtime/use-cases/continue-generation.ts b/server/src/application/chat-runtime/use-cases/continue-generation.ts index 2d1ad3ec..21f38d74 100644 --- a/server/src/application/chat-runtime/use-cases/continue-generation.ts +++ b/server/src/application/chat-runtime/use-cases/continue-generation.ts @@ -133,6 +133,7 @@ export async function continueGeneration( await finalizeChatGenerationArtifacts({ generationId, assistantVariantId: staged.assistantVariantId, + cleanupEmptyEntryId: staged.assistantEntryId, }); }, }); diff --git a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts index dc69a0f8..24dd06b1 100644 --- a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts +++ b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts @@ -189,6 +189,7 @@ export async function createEntryAndStartGeneration( await finalizeChatGenerationArtifacts({ generationId, assistantVariantId: staged.assistantVariantId, + cleanupEmptyEntryId: staged.assistantEntryId, }); }, }); diff --git a/server/src/services/chat-generation-v3/contracts.ts b/server/src/services/chat-generation-v3/contracts.ts index 3e2610e0..aff1be57 100644 --- a/server/src/services/chat-generation-v3/contracts.ts +++ b/server/src/services/chat-generation-v3/contracts.ts @@ -315,6 +315,17 @@ export type RunDebugStateSnapshotStage = | "post_commit_after"; export type RunEvent = + | { + runId: string; + seq: number; + type: "run.preparation_failed"; + data: { + generationId: null; + status: "error"; + code: "generation_preparation_error"; + message: string; + }; + } | { runId: string; seq: number; diff --git a/server/src/services/chat-generation-v3/orchestration/finalize-run-failure.ts b/server/src/services/chat-generation-v3/orchestration/finalize-run-failure.ts new file mode 100644 index 00000000..921fbf24 --- /dev/null +++ b/server/src/services/chat-generation-v3/orchestration/finalize-run-failure.ts @@ -0,0 +1,56 @@ +import { structuredLogger } from "@core/logging/structured-logger"; + +import { defaultGenerationPersistencePort } from "../persist/generation-persistence-port"; + +import { buildRunResult, markRunPhase } from "./run-state-helpers"; + +import type { RunContext, RunRequest, RunResult, RunState } from "../contracts"; + +export async function finalizeRunFailure(params: { + requestId: RunRequest["requestId"]; + context: RunContext; + runState: RunState; + errorMessage: string; + aborted: boolean; + prepareStartedAt: number; +}): Promise { + params.runState.finishedStatus = params.aborted ? "aborted" : "error"; + params.runState.errorMessage = params.errorMessage; + + const hasPrepareReport = params.runState.phaseReports.some( + (report) => report.phase === "prepare_run_context" + ); + if (!hasPrepareReport) { + markRunPhase( + params.runState, + "prepare_run_context", + params.aborted ? "aborted" : "failed", + params.prepareStartedAt, + params.errorMessage + ); + } + + const result = buildRunResult({ + context: params.context, + runState: params.runState, + }); + await defaultGenerationPersistencePort.finalize({ + context: params.context, + result, + }); + + structuredLogger.error("generation.finished_with_error", { + event: "generation.finished_with_error", + requestId: params.requestId ?? null, + generationId: params.context.generationId, + runId: params.context.runId, + chatId: params.context.chatId, + branchId: params.context.branchId, + profileId: params.context.profileSnapshot?.profileId ?? null, + status: result.status, + failedType: result.failedType, + errorMessage: params.errorMessage, + }); + + return result; +} diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts index 4c525f9b..b3b58d48 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ commitEffectsPhase: vi.fn(), runMainLlmPhase: vi.fn(), generationControlAcquire: vi.fn(), + profileSessionArtifactLoad: vi.fn(), finalizeRun: vi.fn(), updateGenerationPromptData: vi.fn(), updateGenerationDebugJson: vi.fn(), @@ -66,7 +67,7 @@ vi.mock("../chat-core/generation-runtime", () => ({ vi.mock("./artifacts/profile-session-artifact-store", () => ({ ProfileSessionArtifactStore: { - load: vi.fn(async () => ({})), + load: mocks.profileSessionArtifactLoad, loadOperationActivationStates: vi.fn(async () => ({})), upsertOperationActivationState: vi.fn(async () => undefined), }, @@ -186,9 +187,84 @@ beforeEach(() => { heartbeat: vi.fn(async () => undefined), release: vi.fn(async () => undefined), }); + mocks.profileSessionArtifactLoad.mockResolvedValue({}); }); describe("runChatGenerationV3", () => { + test("reports preparation errors that happen before a generation exists", async () => { + mocks.resolveRunContext.mockRejectedValueOnce(new Error("profile compilation failed")); + + const events: any[] = []; + for await (const event of runChatGenerationV3(makeRequest())) { + events.push(event); + } + + expect(events).toEqual([ + expect.objectContaining({ + type: "run.preparation_failed", + data: { + generationId: null, + status: "error", + code: "generation_preparation_error", + message: "profile compilation failed", + }, + }), + ]); + expect(mocks.generationControlAcquire).not.toHaveBeenCalled(); + expect(mocks.finalizeRun).not.toHaveBeenCalled(); + }); + + test("finalizes and reports a generation when control lease acquisition fails", async () => { + mocks.generationControlAcquire.mockRejectedValueOnce(new Error("control lease failed")); + + const events: any[] = []; + for await (const event of runChatGenerationV3(makeRequest())) { + events.push(event); + } + + expect(events.map((event) => event.type)).toEqual(["run.started", "run.finished"]); + expect(events[1]?.data).toMatchObject({ + generationId: "gen-1", + status: "error", + message: "control lease failed", + }); + expect(mocks.finalizeRun).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ generationId: "gen-1" }), + result: expect.objectContaining({ + generationId: "gen-1", + status: "error", + errorMessage: "control lease failed", + }), + }) + ); + }); + + test("finalizes and reports a generation when persisted artifacts fail to load", async () => { + mocks.profileSessionArtifactLoad.mockRejectedValueOnce(new Error("artifact load failed")); + + const events: any[] = []; + for await (const event of runChatGenerationV3(makeRequest())) { + events.push(event); + } + + expect(events.map((event) => event.type)).toEqual(["run.started", "run.finished"]); + expect(events[1]?.data).toMatchObject({ + generationId: "gen-1", + status: "error", + message: "artifact load failed", + }); + expect(mocks.finalizeRun).toHaveBeenCalledWith( + expect.objectContaining({ + result: expect.objectContaining({ + generationId: "gen-1", + status: "error", + errorMessage: "artifact load failed", + }), + }) + ); + }); + test("does not start main LLM when before barrier fails", async () => { mocks.executeOperationsPhase.mockResolvedValueOnce([ { diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts index 0ee1fe75..fac1bacd 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts @@ -9,6 +9,7 @@ import { normalizeOperationActivationConfig, resolveOperationActivationState, } from "./operations/operation-activation-intervals"; +import { finalizeRunFailure } from "./orchestration/finalize-run-failure"; import { RunEventStream } from "./orchestration/run-event-stream"; import { runOperationHookPhase } from "./orchestration/run-operation-hook-phase"; import { @@ -129,6 +130,7 @@ export async function* runChatGenerationV3( let controlLease: Awaited< ReturnType<(typeof defaultGenerationControlPort)["acquire"]> > | null = null; + const prepareStartedAt = Date.now(); const debugEnabled = isChatGenerationDebugEnabled(request.settings); const emit = (type: RunEvent["type"], data: unknown): void => { eventStream.emit(type, data); @@ -151,10 +153,14 @@ export async function* runChatGenerationV3( }; try { - const prepareStartedAt = Date.now(); const resolved = await resolveRunContext({ request }); context = resolved.context; eventStream.setRunId(context.runId); + runState = createInitialRunState({}); + emit("run.started", { + generationId: context.generationId, + trigger: context.trigger, + }); controlLease = await defaultGenerationControlPort.acquire({ generationId: context.generationId, runInstanceId: context.runId, @@ -169,11 +175,6 @@ export async function* runChatGenerationV3( branchId: context.branchId, profileId: context.profileSnapshot?.profileId ?? null, }); - emit("run.started", { - generationId: context.generationId, - trigger: context.trigger, - }); - const persistedArtifactsSnapshot = context.sessionKey && context.profileSnapshot ? await ProfileSessionArtifactStore.load({ @@ -182,7 +183,7 @@ export async function* runChatGenerationV3( }) : {}; - runState = createInitialRunState(persistedArtifactsSnapshot); + runState.persistedArtifactsSnapshot = persistedArtifactsSnapshot; markPhase("prepare_run_context", "done", prepareStartedAt); yield* eventStream.flushEvents(); @@ -495,26 +496,30 @@ export async function* runChatGenerationV3( yield* eventStream.flushEvents(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (runState) { - runState.finishedStatus = abortController.signal.aborted ? "aborted" : "error"; - runState.errorMessage = message; + if (!context || !runState) { + yield { + runId: request.requestId ?? "preparation", + seq: 1, + type: "run.preparation_failed", + data: { + generationId: null, + status: "error", + code: "generation_preparation_error", + message, + }, + }; + return; } - if (context && runState && !finalized) { - const result = buildRunResult({ context, runState }); - await defaultGenerationPersistencePort.finalize({ context, result }); - finalized = true; - structuredLogger.error("generation.finished_with_error", { - event: "generation.finished_with_error", - requestId: request.requestId ?? null, - generationId: context.generationId, - runId: context.runId, - chatId: context.chatId, - branchId: context.branchId, - profileId: context.profileSnapshot?.profileId ?? null, - status: result.status, - failedType: result.failedType, + if (!finalized) { + const result = await finalizeRunFailure({ + requestId: request.requestId, + context, + runState, errorMessage: message, + aborted: abortController.signal.aborted, + prepareStartedAt, }); + finalized = true; emit("run.finished", { generationId: context.generationId, status: result.status, From 3dad07e1c1f7e62d0480595b76bc73fe89be6be3 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 13 Jul 2026 01:50:47 +0300 Subject: [PATCH 04/14] fix: persist assistant rewrite effects --- .../services/chat-generation-v3/contracts.ts | 16 +++ .../operations/commit-effects-phase.test.ts | 120 ++++++++++++++++++ .../operations/commit-effects-phase.ts | 20 ++- .../effect-handlers/turn-effects.test.ts | 43 ++++++- .../effect-handlers/turn-effects.ts | 41 +++++- .../operations-flow.integration.test.ts | 12 +- .../orchestration/run-operation-hook-phase.ts | 3 + .../run-chat-generation-v3.test.ts | 41 ++++++ .../assistant-canonicalization.test.ts | 37 ++++++ .../assistant-canonicalization.ts | 28 ++++ web/src/model/chat-entry-parts/index.ts | 22 ++++ 11 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 web/src/model/chat-entry-parts/assistant-canonicalization.test.ts create mode 100644 web/src/model/chat-entry-parts/assistant-canonicalization.ts diff --git a/server/src/services/chat-generation-v3/contracts.ts b/server/src/services/chat-generation-v3/contracts.ts index aff1be57..9a4fbb1c 100644 --- a/server/src/services/chat-generation-v3/contracts.ts +++ b/server/src/services/chat-generation-v3/contracts.ts @@ -259,6 +259,16 @@ export type TurnUserCanonicalizationRecord = { committedAt: string; }; +export type TurnAssistantCanonicalizationRecord = { + hook: OperationHook; + opId: string; + assistantEntryId: string; + assistantMainPartId: string; + beforeText: string; + afterText: string; + committedAt: string; +}; + export type PhaseReport = { phase: | "prepare_run_context" @@ -454,6 +464,12 @@ export type RunEvent = }>; }; } + | { + runId: string; + seq: number; + type: "turn.assistant.canonicalized"; + data: TurnAssistantCanonicalizationRecord; + } | { runId: string; seq: number; diff --git a/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts b/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts index 77e56c61..d3257625 100644 --- a/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts +++ b/server/src/services/chat-generation-v3/operations/commit-effects-phase.test.ts @@ -211,6 +211,12 @@ describe("commit effects phase", () => { }); test("applies assistant canonicalization after_main_llm", async () => { + const persistSpy = vi.spyOn(turnEffects, "persistAssistantTurnText").mockResolvedValue({ + previousText: "raw", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + }); + const onAssistantTurnCanonicalized = vi.fn(); const state = makeRunState(); state.assistantText = "raw"; state.operationResultsByHook.after_main_llm = [ @@ -231,10 +237,124 @@ describe("commit effects phase", () => { sessionKey: null, runState: state, runArtifactStore: new RunArtifactStore(), + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + }, + onAssistantTurnCanonicalized, }); expect(result.requiredError).toBe(false); + expect(persistSpy).toHaveBeenCalledWith({ + target: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + }, + text: "normalized", + }); expect(state.assistantText).toBe("normalized"); + expect(onAssistantTurnCanonicalized).toHaveBeenCalledWith( + expect.objectContaining({ + hook: "after_main_llm", + opId: "assistant", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + beforeText: "raw", + afterText: "normalized", + }) + ); + }); + + test("keeps original assistant text and marks required error when persistence fails", async () => { + vi.spyOn(turnEffects, "persistAssistantTurnText").mockRejectedValue( + new Error("assistant persist failure") + ); + const state = makeRunState(); + state.assistantText = "raw"; + state.operationResultsByHook.after_main_llm = [ + makeDoneResult({ + opId: "required-assistant", + order: 10, + hook: "after_main_llm", + required: true, + effects: [ + { + type: "turn.assistant.replace_text", + opId: "required-assistant", + text: "normalized", + }, + ], + }), + ]; + + const result = await commitEffectsPhase({ + hook: "after_main_llm", + ownerId: "global", + chatId: "chat", + branchId: "branch", + profile: null, + sessionKey: null, + runState: state, + runArtifactStore: new RunArtifactStore(), + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + }, + }); + + expect(result.requiredError).toBe(true); + expect(result.report.effects[0]).toMatchObject({ + effectType: "turn.assistant.replace_text", + status: "error", + message: "assistant persist failure", + }); + expect(state.assistantText).toBe("raw"); + }); + + test("keeps original assistant text without failing the barrier for optional persistence", async () => { + vi.spyOn(turnEffects, "persistAssistantTurnText").mockRejectedValue( + new Error("optional assistant persist failure") + ); + const state = makeRunState(); + state.assistantText = "raw"; + state.operationResultsByHook.after_main_llm = [ + makeDoneResult({ + opId: "optional-assistant", + order: 10, + hook: "after_main_llm", + effects: [ + { + type: "turn.assistant.replace_text", + opId: "optional-assistant", + text: "normalized", + }, + ], + }), + ]; + + const result = await commitEffectsPhase({ + hook: "after_main_llm", + ownerId: "global", + chatId: "chat", + branchId: "branch", + profile: null, + sessionKey: null, + runState: state, + runArtifactStore: new RunArtifactStore(), + persistenceTarget: { + mode: "entry_parts", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + }, + }); + + expect(result.requiredError).toBe(false); + expect(result.report.status).toBe("done"); + expect(result.report.effects[0]?.status).toBe("error"); + expect(state.assistantText).toBe("raw"); }); test("invokes user turn persistence handler and reports applied event", async () => { diff --git a/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts b/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts index d763833f..259c1f14 100644 --- a/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts +++ b/server/src/services/chat-generation-v3/operations/commit-effects-phase.ts @@ -3,7 +3,10 @@ import { type RunArtifactStore } from "../artifacts/run-artifact-store"; import { applyArtifactEffect } from "./effect-handlers/artifact-effects"; import { applyPromptEffect } from "./effect-handlers/prompt-effects"; -import { persistUserTurnText } from "./effect-handlers/turn-effects"; +import { + persistAssistantTurnText, + persistUserTurnText, +} from "./effect-handlers/turn-effects"; import { persistUiInlineEffect } from "./effect-handlers/ui-effects"; import { validateEffectForHook } from "./effect-policy"; @@ -13,6 +16,7 @@ import type { RuntimeEffect, RunPersistenceTarget, RunState, + TurnAssistantCanonicalizationRecord, TurnUserCanonicalizationRecord, UserTurnTarget, } from "../contracts"; @@ -109,6 +113,7 @@ export async function commitEffectsPhase(params: { persistenceTarget?: RunPersistenceTarget; userTurnTarget?: UserTurnTarget; onUserTurnCanonicalized?: (payload: TurnUserCanonicalizationRecord) => void; + onAssistantTurnCanonicalized?: (payload: TurnAssistantCanonicalizationRecord) => void; onCommitEvent?: (event: { type: "commit.effect_applied" | "commit.effect_skipped" | "commit.effect_error"; data: { hook: OperationHook; opId: string; effectType: RuntimeEffect["type"]; message?: string }; @@ -254,7 +259,20 @@ export async function commitEffectsPhase(params: { continue; } + const persisted = await persistAssistantTurnText({ + target: params.persistenceTarget, + text: effect.text, + }); params.runState.assistantText = effect.text; + params.onAssistantTurnCanonicalized?.({ + hook: params.hook, + opId: opResult.opId, + assistantEntryId: persisted.assistantEntryId, + assistantMainPartId: persisted.assistantMainPartId, + beforeText: persisted.previousText ?? "", + afterText: effect.text, + committedAt: new Date().toISOString(), + }); effectsReport.push({ opId: opResult.opId, effectType: effect.type, diff --git a/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.test.ts b/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.test.ts index b01a8469..91deefbd 100644 --- a/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.test.ts +++ b/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.test.ts @@ -3,14 +3,16 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getPartWithVariantContextById: vi.fn(), createPart: vi.fn(), + updatePartPayloadText: vi.fn(), })); vi.mock("../../../chat-entry-parts/parts-repository", () => ({ getPartWithVariantContextById: mocks.getPartWithVariantContextById, createPart: mocks.createPart, + updatePartPayloadText: mocks.updatePartPayloadText, })); -import { persistUserTurnText } from "./turn-effects"; +import { persistAssistantTurnText, persistUserTurnText } from "./turn-effects"; beforeEach(() => { vi.clearAllMocks(); @@ -48,6 +50,45 @@ beforeEach(() => { replacesPartId: "part-1", tags: ["canonicalization"], }); + mocks.updatePartPayloadText.mockResolvedValue(undefined); +}); + +describe("persistAssistantTurnText", () => { + test("updates the assistant main part after validating its entry", async () => { + const result = await persistAssistantTurnText({ + target: { + mode: "entry_parts", + assistantEntryId: "entry-1", + assistantMainPartId: "part-1", + }, + text: "normalized assistant text", + }); + + expect(mocks.updatePartPayloadText).toHaveBeenCalledWith({ + partId: "part-1", + payloadText: "normalized assistant text", + payloadFormat: "markdown", + }); + expect(result).toEqual({ + previousText: "original user text", + assistantEntryId: "entry-1", + assistantMainPartId: "part-1", + }); + }); + + test("does not write when the assistant entry does not match", async () => { + await expect( + persistAssistantTurnText({ + target: { + mode: "entry_parts", + assistantEntryId: "entry-other", + assistantMainPartId: "part-1", + }, + text: "normalized assistant text", + }) + ).rejects.toThrow(/entry mismatch/); + expect(mocks.updatePartPayloadText).not.toHaveBeenCalled(); + }); }); describe("persistUserTurnText", () => { diff --git a/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts b/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts index f81fa20a..a0c3bf99 100644 --- a/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts +++ b/server/src/services/chat-generation-v3/operations/effect-handlers/turn-effects.ts @@ -2,9 +2,48 @@ import { safeJsonStringify } from "../../../../chat-core/json"; import { createPart, getPartWithVariantContextById, + updatePartPayloadText, } from "../../../chat-entry-parts/parts-repository"; -import type { UserTurnTarget } from "../../contracts"; +import type { RunPersistenceTarget, UserTurnTarget } from "../../contracts"; + +export async function persistAssistantTurnText(params: { + target: RunPersistenceTarget | undefined; + text: string; +}): Promise<{ + previousText: string | null; + assistantEntryId: string; + assistantMainPartId: string; +}> { + const target = params.target; + if (!target) { + throw new Error("Assistant persistence target is required for turn.assistant.* effect"); + } + + const context = await getPartWithVariantContextById({ + partId: target.assistantMainPartId, + }); + if (!context) throw new Error("Assistant target part not found"); + if (context.entryId !== target.assistantEntryId) { + throw new Error("Assistant target entry mismatch"); + } + + const previousText = + typeof context.part.payload === "string" + ? context.part.payload + : safeJsonStringify(context.part.payload, ""); + await updatePartPayloadText({ + partId: target.assistantMainPartId, + payloadText: params.text, + payloadFormat: "markdown", + }); + + return { + previousText, + assistantEntryId: target.assistantEntryId, + assistantMainPartId: target.assistantMainPartId, + }; +} export async function persistUserTurnText(params: { target: UserTurnTarget | undefined; diff --git a/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts b/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts index 21f8fe8c..20d950e1 100644 --- a/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts +++ b/server/src/services/chat-generation-v3/operations/operations-flow.integration.test.ts @@ -3,12 +3,13 @@ import { type LegacyOperationOutput, type OperationInProfile, } from "@shared/types/operation-profiles"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { RunArtifactStore } from "../artifacts/run-artifact-store"; import { commitEffectsPhase } from "./commit-effects-phase"; +import * as turnEffects from "./effect-handlers/turn-effects"; import { executeOperationsPhase } from "./execute-operations-phase"; import type { InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; @@ -142,7 +143,16 @@ function collectEvents() { } describe("operations flow integration (execute + commit)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test("before artifacts and prompt injection are visible in after phase and canonicalize assistant", async () => { + vi.spyOn(turnEffects, "persistAssistantTurnText").mockResolvedValue({ + previousText: "raw", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main", + }); const runState = makeRunState(); const runArtifactStore = new RunArtifactStore(); const templateContext = makeTemplateContext(); diff --git a/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts b/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts index 86a3c31f..4adb00ca 100644 --- a/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts +++ b/server/src/services/chat-generation-v3/orchestration/run-operation-hook-phase.ts @@ -108,6 +108,9 @@ export async function* runOperationHookPhase( runArtifactStore: params.runArtifactStore, persistenceTarget: params.persistenceTarget, userTurnTarget: params.userTurnTarget, + onAssistantTurnCanonicalized: (data) => { + params.emit("turn.assistant.canonicalized", data); + }, onUserTurnCanonicalized: (data) => { params.emit("turn.user.canonicalized", data); if (params.debugEnabled) { diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts index b3b58d48..4415e30e 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts @@ -583,6 +583,47 @@ describe("runChatGenerationV3", () => { }); }); + test("emits assistant canonicalization after a successful rewrite commit", async () => { + mocks.executeOperationsPhase.mockResolvedValue([]); + mocks.commitEffectsPhase.mockImplementation(async (params: any) => { + if (params.hook === "after_main_llm") { + params.onAssistantTurnCanonicalized?.({ + hook: "after_main_llm", + opId: "assistant-rewrite", + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + beforeText: "raw", + afterText: "normalized", + committedAt: "2026-07-13T00:00:00.000Z", + }); + } + return { + report: { hook: params.hook, status: "done", effects: [] }, + requiredError: false, + }; + }); + mocks.runMainLlmPhase.mockImplementation(async ({ runState }: any) => { + runState.assistantText = "raw"; + return { status: "done" }; + }); + + const events: any[] = []; + for await (const event of runChatGenerationV3(makeRequest())) { + events.push(event); + } + + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn.assistant.canonicalized", + data: expect.objectContaining({ + assistantEntryId: "assistant-entry", + assistantMainPartId: "assistant-main-part", + afterText: "normalized", + }), + }) + ); + }); + test("streams main_llm.reasoning_delta while main phase is running", async () => { const mainGate = deferred(); mocks.executeOperationsPhase.mockResolvedValue([]); diff --git a/web/src/model/chat-entry-parts/assistant-canonicalization.test.ts b/web/src/model/chat-entry-parts/assistant-canonicalization.test.ts new file mode 100644 index 00000000..a149fd1f --- /dev/null +++ b/web/src/model/chat-entry-parts/assistant-canonicalization.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; + +import { applyAssistantCanonicalizationPatch } from './assistant-canonicalization'; + +import type { ChatEntryWithVariantDto } from '../../api/chat-entry-parts'; + +describe('applyAssistantCanonicalizationPatch', () => { + test('replaces the streamed assistant main-part payload', () => { + const entries = [ + { + entry: { entryId: 'assistant-entry' }, + variant: { + variantId: 'variant-1', + entryId: 'assistant-entry', + parts: [ + { partId: 'reasoning-part', channel: 'reasoning', payload: 'thinking' }, + { partId: 'assistant-main-part', channel: 'main', payload: 'raw answer' }, + ], + }, + } as ChatEntryWithVariantDto, + ]; + + const updated = applyAssistantCanonicalizationPatch({ + entries, + entryId: 'assistant-entry', + partId: 'assistant-main-part', + afterText: 'normalized answer', + }); + + expect(updated[0]?.variant?.parts?.find((part) => part.channel === 'main')?.payload).toBe( + 'normalized answer', + ); + expect(updated[0]?.variant?.parts?.find((part) => part.channel === 'reasoning')?.payload).toBe( + 'thinking', + ); + }); +}); diff --git a/web/src/model/chat-entry-parts/assistant-canonicalization.ts b/web/src/model/chat-entry-parts/assistant-canonicalization.ts new file mode 100644 index 00000000..a6995721 --- /dev/null +++ b/web/src/model/chat-entry-parts/assistant-canonicalization.ts @@ -0,0 +1,28 @@ +import type { ChatEntryWithVariantDto } from '../../api/chat-entry-parts'; + +export function applyAssistantCanonicalizationPatch(params: { + entries: ChatEntryWithVariantDto[]; + entryId: string; + partId: string; + afterText: string; +}): ChatEntryWithVariantDto[] { + const entryIndex = params.entries.findIndex((item) => item.entry.entryId === params.entryId); + if (entryIndex < 0) return params.entries; + const target = params.entries[entryIndex]; + if (!target?.variant) return params.entries; + + const parts = target.variant.parts ?? []; + const partIndex = parts.findIndex((part) => part.partId === params.partId); + if (partIndex < 0) return params.entries; + const currentPart = parts[partIndex]; + if (!currentPart || currentPart.payload === params.afterText) return params.entries; + + const nextParts = [...parts]; + nextParts[partIndex] = { ...currentPart, payload: params.afterText }; + const nextEntries = [...params.entries]; + nextEntries[entryIndex] = { + ...target, + variant: { ...target.variant, parts: nextParts }, + }; + return nextEntries; +} diff --git a/web/src/model/chat-entry-parts/index.ts b/web/src/model/chat-entry-parts/index.ts index abbc9bb7..17d51f32 100644 --- a/web/src/model/chat-entry-parts/index.ts +++ b/web/src/model/chat-entry-parts/index.ts @@ -25,6 +25,8 @@ import { $currentBranchId, $currentChat, setOpenedChat } from '../chat-core'; import { logChatGenerationSseEvent } from '../chat-generation-debug'; import { userPersonsModel } from '../user-persons'; +import { applyAssistantCanonicalizationPatch } from './assistant-canonicalization'; + import type { SseEnvelope } from '../../api/chat-core'; import type { BatchUpdateEntryPartsRequest, @@ -1371,6 +1373,26 @@ sample({ clock: handleSseEnvelope, source: { entries: $entries, stream: $activeStream, generationId: $activeGenerationId }, fn: ({ entries, stream }, env) => { + if (env.type === 'turn.assistant.canonicalized') { + const data = isRecord(env.data) ? env.data : null; + const assistantEntryId = typeof data?.assistantEntryId === 'string' ? data.assistantEntryId : null; + const assistantMainPartId = typeof data?.assistantMainPartId === 'string' ? data.assistantMainPartId : null; + const afterText = typeof data?.afterText === 'string' ? data.afterText : null; + if (!assistantEntryId || !assistantMainPartId || afterText === null) { + return { entries, stream }; + } + + return { + entries: applyAssistantCanonicalizationPatch({ + entries, + entryId: assistantEntryId, + partId: assistantMainPartId, + afterText, + }), + stream, + }; + } + if (env.type === 'turn.user.canonicalized') { const data = env.data as Record | null; const userEntryId = data && typeof data.userEntryId === 'string' ? data.userEntryId : null; From 8afcdb43938ff0520aa86ae50e64631139adf759 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 13 Jul 2026 01:53:00 +0300 Subject: [PATCH 05/14] docs: record completed operations audit tasks --- BACKEND_OPERATIONS_AUDIT_2026-07-10.md | 49 ++++++++++++++++++-------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md index 939eff6e..aa2b1397 100644 --- a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md +++ b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md @@ -36,6 +36,24 @@ Следовательно, большинство находок ниже — это не обычные ошибки компиляции, а пробелы в сквозных гарантиях, failure semantics и соответствии runtime публичным контрактам. +## Прогресс исправлений + +Обновлено: 2026-07-13. + +| Задача | Статус | Коммит | Результат | +| --- | --- | --- | --- | +| OPS-001 | Выполнено | `baa690b` | Preparation failures наблюдаемы в SSE; созданная generation финализируется; пустой assistant scaffolding удаляется при ошибке до создания generation. | +| OPS-002 | Выполнено | `3dad07e` | Assistant rewrite сохраняется в main-part; required/optional persistence failures соблюдают policy; UI получает `turn.assistant.canonicalized`. | + +Проверки после OPS-002: + +- backend: 101 test files, 513 tests passed; +- frontend: 36 test files, 121 tests passed; +- `yarn verify:server` и `yarn verify:web` прошли; +- `yarn build:server` и `yarn build:web` прошли. + +Phase A завершена частично: OPS-001 и OPS-002 закрыты, передача operation errors в SSE остаётся следующей задачей. + ## Как Operations работают сейчас Текущий runtime flow: @@ -76,7 +94,9 @@ ## P0 — блокирующие проблемы -### OPS-001. Ошибки до создания `RunState` могут полностью потеряться +### OPS-001. Ошибки до создания `RunState` могут полностью потеряться — выполнено + +Статус: выполнено 2026-07-13, коммит `baa690b`. Код: @@ -111,7 +131,9 @@ - generation не остаётся `streaming`; - пустой assistant scaffolding удаляется или явно отмечается failed. -### OPS-002. `assistant_output_main` rewrite не сохраняется +### OPS-002. `assistant_output_main` rewrite не сохраняется — выполнено + +Статус: выполнено 2026-07-13, коммит `3dad07e`. Код: @@ -882,11 +904,11 @@ Config revision и manual reset должны иметь раздельно оп ### Phase A. Terminal-state correctness -1. Исправить OPS-001. -2. Добавить preparation failure events и cleanup. -3. Исправить assistant rewrite persistence из OPS-002. -4. Добавить operation errors в SSE. -5. Сначала написать regression tests. +1. [x] Исправить OPS-001. +2. [x] Добавить preparation failure events и cleanup. +3. [x] Исправить assistant rewrite persistence из OPS-002. +4. [ ] Добавить operation errors в SSE. +5. [x] Сначала написать regression tests для выполненных задач. Критерии завершения: @@ -960,12 +982,12 @@ Config revision и manual reset должны иметь раздельно оп Безопасный первый batch должен быть узким: -1. Добавить failing tests на preparation errors и assistant rewrite persistence. -2. Изменить `runChatGenerationV3`, чтобы все failure paths завершались наблюдаемо. -3. Persist-ить `turn.assistant.replace_text` через отдельный handler. -4. Передавать error information в `operation.finished`. -5. Compile/validate profile до activation. -6. Добавить conservative concurrency cap. +1. [x] Добавить failing tests на preparation errors и assistant rewrite persistence. +2. [x] Изменить `runChatGenerationV3`, чтобы все failure paths завершались наблюдаемо. +3. [x] Persist-ить `turn.assistant.replace_text` через отдельный handler. +4. [ ] Передавать error information в `operation.finished`. +5. [ ] Compile/validate profile до activation. +6. [ ] Добавить conservative concurrency cap. Этот batch исправит пользовательскую correctness, не требуя одновременно завершать полную transaction redesign. @@ -984,4 +1006,3 @@ Config revision и manual reset должны иметь раздельно оп - Artifacts и activation state используют один documented session lifecycle. - Точный compiled plan и operation results доступны после run. - RU и EN documentation описывает фактическое поведение. - From 919f523a57ce44c6741f2095da98f626ae8bc5ab Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Tue, 14 Jul 2026 17:24:10 +0300 Subject: [PATCH 06/14] fix: expose operation errors in generation events --- BACKEND_OPERATIONS_AUDIT_2026-07-10.md | 42 +++++++++-- .../core/operation-orchestrator/executor.ts | 4 +- .../orchestrator.test.ts | 75 ++++++++++++++++++- .../src/core/operation-orchestrator/types.ts | 10 ++- .../execute-operations-phase.test.ts | 57 ++++++++++++++ .../operations/execute-operations-phase.ts | 32 +++++--- .../operations/operation-error.ts | 27 +++++++ .../orchestration/run-state-helpers.ts | 22 ++++++ .../run-chat-generation-v3.test.ts | 3 + .../run-chat-generation-v3.ts | 21 ++++-- web/src/i18n/resources/en/chat.ts | 2 + web/src/i18n/resources/ru/chat.ts | 2 + web/src/model/chat-entry-parts/index.ts | 23 +++--- .../operation-finished-failure.test.ts | 32 ++++++++ .../operation-finished-failure.ts | 28 +++++++ .../model/operation-run-trace/index.test.ts | 20 +++++ 16 files changed, 364 insertions(+), 36 deletions(-) create mode 100644 server/src/services/chat-generation-v3/operations/operation-error.ts create mode 100644 web/src/model/chat-entry-parts/operation-finished-failure.test.ts create mode 100644 web/src/model/chat-entry-parts/operation-finished-failure.ts diff --git a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md index aa2b1397..07c8d785 100644 --- a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md +++ b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md @@ -38,12 +38,13 @@ ## Прогресс исправлений -Обновлено: 2026-07-13. +Обновлено: 2026-07-14. | Задача | Статус | Коммит | Результат | | --- | --- | --- | --- | | OPS-001 | Выполнено | `baa690b` | Preparation failures наблюдаемы в SSE; созданная generation финализируется; пустой assistant scaffolding удаляется при ошибке до создания generation. | | OPS-002 | Выполнено | `3dad07e` | Assistant rewrite сохраняется в main-part; required/optional persistence failures соблюдают policy; UI получает `turn.assistant.canonicalized`. | +| OPS-026 | Выполнено | Текущие изменения | `operation.finished` передаёт stable error code, безопасное сообщение и abort reason; frontend показывает причину и сохраняет её в Run Trace; required barrier перечисляет проблемные operation IDs. | Проверки после OPS-002: @@ -52,7 +53,27 @@ - `yarn verify:server` и `yarn verify:web` прошли; - `yarn build:server` и `yarn build:web` прошли. -Phase A завершена частично: OPS-001 и OPS-002 закрыты, передача operation errors в SSE остаётся следующей задачей. +Проверки после OPS-026: + +- backend: 101 test files, 516 tests passed; +- frontend: 37 test files, 124 tests passed; +- `yarn verify:server` и `yarn verify:web` прошли; +- `yarn build:server` и `yarn build:web` прошли. + +Phase A завершена: terminal generation state, assistant rewrite persistence и actionable operation errors покрыты regression-тестами. + +### Важные незакрытые задачи + +Следующий приоритетный batch — Phase B. Наиболее важный остаток: + +1. [ ] **OPS-004 (P0): owner scope во всех operation repositories и runtime resolution.** Сейчас cross-owner read/update/export и использование чужого active profile не исключены на уровне repository contract. +2. [ ] **OPS-003 (P0): строгая local network boundary.** Backend должен слушать loopback по умолчанию, LAN mode требовать opt-in, а CORS и owner identity — перестать доверять произвольному caller. +3. [ ] **OPS-008 + OPS-009 (P1): bounded execution.** Нужны conservative concurrency cap и лимиты размера profile, operation output и artifact history, иначе один профиль может породить неконтролируемое число provider calls и рост памяти/БД. +4. [ ] **OPS-014 (P1): compile/validate profile до activation.** Сохранённый активный профиль обязан гарантированно компилироваться до generation time; вместе с concurrency cap это оставшийся хвост первоначального узкого fix batch. +5. [ ] **OPS-005–OPS-007 + OPS-020 (P1): transaction/state correctness.** Knowledge mutations, activation counters и effects ещё способны оставить partial state или потерять updates при ошибках и concurrent runs. +6. [ ] **OPS-021 + OPS-022 (P1): atomic import/cutover и optimistic concurrency.** Ошибка multi-write import оставляет orphan/partial records, а параллельное редактирование profile/block молча перетирает изменения. + +После security и bounded-execution batch следует переходить к полной transaction redesign, а не смешивать её с небольшими contract fixes. ## Как Operations работают сейчас @@ -589,7 +610,9 @@ Update использует read-modify-write и считает `version + 1` в - при необходимости replay сохранять canonical compiled spec; - либо ввести immutable revisions/audit log profile и blocks. -### OPS-026. Operation errors не попадают в SSE completion events +### OPS-026. Operation errors не попадают в SSE completion events — выполнено + +Статус: выполнено 2026-07-14 в текущих изменениях. Код: @@ -604,6 +627,15 @@ Update использует read-modify-write и считает `version + 1` в - перечислять failing operation IDs в required barrier message; - редактировать provider errors и секретные данные. +Результат: + +- terminal details добавлены в typed `orch.task.finished`; +- `operation.finished` получает stable error code, sanitized message и abort reason; +- сообщения ограничены по длине, credential-like values редактируются; +- Run Trace сохраняет error message, а failure toast показывает actionable description; +- required barrier message перечисляет failing operation IDs; +- добавлены backend и frontend regression-тесты. + ### OPS-027. Phase status не отражает реальные operation errors Код: @@ -907,7 +939,7 @@ Config revision и manual reset должны иметь раздельно оп 1. [x] Исправить OPS-001. 2. [x] Добавить preparation failure events и cleanup. 3. [x] Исправить assistant rewrite persistence из OPS-002. -4. [ ] Добавить operation errors в SSE. +4. [x] Добавить operation errors в SSE. 5. [x] Сначала написать regression tests для выполненных задач. Критерии завершения: @@ -985,7 +1017,7 @@ Config revision и manual reset должны иметь раздельно оп 1. [x] Добавить failing tests на preparation errors и assistant rewrite persistence. 2. [x] Изменить `runChatGenerationV3`, чтобы все failure paths завершались наблюдаемо. 3. [x] Persist-ить `turn.assistant.replace_text` через отдельный handler. -4. [ ] Передавать error information в `operation.finished`. +4. [x] Передавать error information в `operation.finished`. 5. [ ] Compile/validate profile до activation. 6. [ ] Добавить conservative concurrency cap. diff --git a/server/src/core/operation-orchestrator/executor.ts b/server/src/core/operation-orchestrator/executor.ts index 1c737dda..18f67f30 100644 --- a/server/src/core/operation-orchestrator/executor.ts +++ b/server/src/core/operation-orchestrator/executor.ts @@ -331,7 +331,7 @@ export async function executeOrchestratorPlan(args: ExecutePlanArgs): Promise { + const events: OrchestratorEvent[] = []; + const codedError = new Error("provider request failed") as Error & { code: string }; + codedError.code = "LLM_PROVIDER_ERROR"; + + await runOrchestrator( + { + runId: "run-terminal-details", + hook: "before_main_llm", + trigger: "generate", + executionMode: "sequential", + tasks: [ + { + taskId: "failing-operation", + enabled: true, + required: false, + order: 1, + run: async () => { + throw codedError; + }, + }, + ], + }, + { onEvent: (event) => events.push(event) } + ); + + expect(events).toContainEqual({ + type: "orch.task.finished", + data: { + runId: "run-terminal-details", + taskId: "failing-operation", + status: "error", + error: { code: "LLM_PROVIDER_ERROR", message: "provider request failed" }, + }, + }); +}); + +test("includes the abort reason in task finished events", async () => { + const events: OrchestratorEvent[] = []; + const abortError = new Error("user cancelled operation"); + abortError.name = "AbortError"; + + await runOrchestrator( + { + runId: "run-abort-details", + hook: "after_main_llm", + trigger: "generate", + executionMode: "sequential", + tasks: [ + { + taskId: "cancelled-operation", + enabled: true, + required: false, + order: 1, + run: async () => { + throw abortError; + }, + }, + ], + }, + { onEvent: (event) => events.push(event) } + ); + + expect(events).toContainEqual({ + type: "orch.task.finished", + data: { + runId: "run-abort-details", + taskId: "cancelled-operation", + status: "aborted", + reason: "user cancelled operation", + }, + }); +}); + test("aborted before start skips all plan tasks", async () => { const ac = new AbortController(); ac.abort("user_cancel"); @@ -236,4 +310,3 @@ test("aborted before start skips all plan tasks", async () => { reason: "orchestrator_aborted", }); }); - diff --git a/server/src/core/operation-orchestrator/types.ts b/server/src/core/operation-orchestrator/types.ts index f43d7b74..c70279da 100644 --- a/server/src/core/operation-orchestrator/types.ts +++ b/server/src/core/operation-orchestrator/types.ts @@ -46,7 +46,15 @@ export type OrchestratorEvent = | { type: "orch.task.started"; data: { runId: string; taskId: string } } | { type: "orch.task.finished"; - data: { runId: string; taskId: string; status: TaskStatus }; + data: + | { runId: string; taskId: string; status: "done" | "skipped" } + | { + runId: string; + taskId: string; + status: "error"; + error: { message: string; code?: string }; + } + | { runId: string; taskId: string; status: "aborted"; reason?: string }; } | { type: "orch.task.skipped"; diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts index d957e680..eccafb5b 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts @@ -567,6 +567,11 @@ describe("executeOperationsPhase", () => { }); test("returns error for strictVariables with missing variable", async () => { + const finishedEvents = collectEvents<{ + opId: string; + status: string; + error?: { code: string; message: string }; + }>(); const out = await executeOperationsPhase({ runId: "run-5", hook: "before_main_llm", @@ -585,11 +590,63 @@ describe("executeOperationsPhase", () => { baseArtifacts: makeBaseArtifacts(), assistantText: "", templateContext: makeTemplateContext(), + onOperationFinished: (event) => { + finishedEvents.push({ + opId: event.opId, + status: event.status, + error: event.error, + }); + }, }); expect(out[0]?.status).toBe("error"); expect(out[0]?.effects).toEqual([]); expect(out[0]?.error?.message.length).toBeGreaterThan(0); + expect(finishedEvents.items).toEqual([ + { + opId: "a", + status: "error", + error: { + code: "OPERATION_ERROR", + message: expect.stringContaining("missing"), + }, + }, + ]); + }); + + test("redacts credentials from operation.finished error messages", async () => { + mocks.llmGatewayStream.mockImplementation(() => + streamOf([ + { type: "error", message: "Authorization: Bearer provider-secret" }, + { type: "done", status: "error" }, + ]) + ); + const finishedEvents = collectEvents<{ error?: { code: string; message: string } }>(); + + await executeOperationsPhase({ + runId: "run-redacted-error", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeLlmOp({ + opId: "redacted-error", + order: 10, + prompt: "summarize", + output: artifactOutput("summary"), + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + onOperationFinished: (event) => finishedEvents.push({ error: event.error }), + }); + + expect(finishedEvents.items[0]?.error).toEqual({ + code: "LLM_PROVIDER_ERROR", + message: "Authorization: [REDACTED]", + }); }); test("blocks dependent node when ancestor fails", async () => { diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts index 2d7140d0..ca9b9231 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts @@ -17,6 +17,7 @@ import { executeGuardOperation } from "./guard-operation-executor"; import { evaluateGuardRunConditions } from "./guard-run-conditions"; import { executeKnowledgeOperation } from "./knowledge-operation-executor"; import { executeLlmOperation } from "./llm-operation-executor"; +import { toSafeOperationError } from "./operation-error"; import type { TaskResult } from "../../../core/operation-orchestrator/types"; import type { InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; @@ -329,10 +330,11 @@ function mapTaskResult(params: { order: op.config.order, dependsOn: op.config.dependsOn ?? [], effects: [], - error: { - code: task.error.code ?? "OPERATION_ERROR", + error: toSafeOperationError({ + code: task.error.code, message: task.error.message, - }, + fallbackCode: "OPERATION_ERROR", + }), }; } @@ -346,12 +348,10 @@ function mapTaskResult(params: { order: op.config.order, dependsOn: op.config.dependsOn ?? [], effects: [], - error: task.reason - ? { - code: "OPERATION_ABORTED", - message: task.reason, - } - : undefined, + error: toSafeOperationError({ + message: task.reason ?? "Operation aborted", + fallbackCode: "OPERATION_ABORTED", + }), }; } @@ -732,11 +732,25 @@ export async function executeOperationsPhase(params: { } const result = evt.data.status === "done" ? taskResultByOpId.get(op.opId) : undefined; + const error = + evt.data.status === "error" + ? toSafeOperationError({ + code: evt.data.error.code, + message: evt.data.error.message, + fallbackCode: "OPERATION_ERROR", + }) + : evt.data.status === "aborted" + ? toSafeOperationError({ + message: evt.data.reason ?? "Operation aborted", + fallbackCode: "OPERATION_ABORTED", + }) + : undefined; params.onOperationFinished?.({ hook: params.hook, opId: op.opId, name: op.name, status: evt.data.status, + error, result, }); } diff --git a/server/src/services/chat-generation-v3/operations/operation-error.ts b/server/src/services/chat-generation-v3/operations/operation-error.ts new file mode 100644 index 00000000..a830f505 --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/operation-error.ts @@ -0,0 +1,27 @@ +const MAX_OPERATION_ERROR_MESSAGE_LENGTH = 500; +const OPERATION_ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{0,63}$/; + +function sanitizeOperationErrorMessage(message: string): string { + const redacted = message + .replace( + /\b(authorization|api[-_ ]?key|token|password|secret)(\s*[:=]\s*)((?:bearer\s+)?[^\s,;]+)/gi, + "$1$2[REDACTED]" + ) + .replace(/(bearer\s+)[^\s,;]+/gi, "$1[REDACTED]"); + if (redacted.length <= MAX_OPERATION_ERROR_MESSAGE_LENGTH) return redacted; + return `${redacted.slice(0, MAX_OPERATION_ERROR_MESSAGE_LENGTH)}...[truncated]`; +} + +export function toSafeOperationError(params: { + code?: string; + message: string; + fallbackCode: "OPERATION_ERROR" | "OPERATION_ABORTED"; +}): { code: string; message: string } { + return { + code: + params.code && OPERATION_ERROR_CODE_RE.test(params.code) + ? params.code + : params.fallbackCode, + message: sanitizeOperationErrorMessage(params.message), + }; +} diff --git a/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts b/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts index 63895b2a..7acb99f9 100644 --- a/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts +++ b/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts @@ -1,5 +1,7 @@ import type { ArtifactValue, + CommitPhaseReport, + OperationExecutionResult, PromptDraftMessage, RunContext, RunDebugStateSnapshotStage, @@ -57,6 +59,26 @@ export function mergeArtifactsForDebug( return merged; } +export function buildRequiredOperationFailureMessage(params: { + stage: "before" | "after"; + operationResults: OperationExecutionResult[]; + commitReport: CommitPhaseReport; + requiredCommitError: boolean; +}): string { + const operationIds = params.requiredCommitError + ? params.commitReport.effects + .filter((effect) => effect.status === "error") + .map((effect) => effect.opId) + : params.operationResults.map((result) => result.opId); + const uniqueOperationIds = Array.from(new Set(operationIds)).sort(); + const baseMessage = params.requiredCommitError + ? `Required ${params.stage} effect commit failed` + : `Required ${params.stage} operation did not finish with done`; + return uniqueOperationIds.length > 0 + ? `${baseMessage}: ${uniqueOperationIds.join(", ")}` + : baseMessage; +} + export function createInitialRunState( persistedArtifactsSnapshot: RunState["persistedArtifactsSnapshot"] ): RunState { diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts index 4415e30e..2a77f411 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts @@ -294,6 +294,9 @@ describe("runChatGenerationV3", () => { const finished = events.find((e) => e.type === "run.finished"); expect(finished?.data.status).toBe("failed"); expect(finished?.data.failedType).toBe("before_barrier"); + expect(finished?.data.message).toBe( + "Required before operation did not finish with done: op-1" + ); }); test("does not fail before barrier for required activation_not_reached skip", async () => { diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts index fac1bacd..2ebf786a 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts @@ -14,6 +14,7 @@ import { RunEventStream } from "./orchestration/run-event-stream"; import { runOperationHookPhase } from "./orchestration/run-operation-hook-phase"; import { buildRunDebugStateSnapshot, + buildRequiredOperationFailureMessage, buildRunResult, cloneLlmMessages, clonePromptDraftMessages, @@ -306,10 +307,12 @@ export async function* runChatGenerationV3( if (beforeBarrierFailed) { runState.finishedStatus = "failed"; runState.failedType = "before_barrier"; - runState.errorMessage = - commitBefore.requiredError - ? "Required before effect commit failed" - : "Required before operation did not finish with done"; + runState.errorMessage = buildRequiredOperationFailureMessage({ + stage: "before", + operationResults: requiredBeforeNotDone, + commitReport: commitBefore.report, + requiredCommitError: commitBefore.requiredError, + }); markPhase("before_barrier", "failed", barrierStartedAt, runState.errorMessage); } else { markPhase("before_barrier", "done", barrierStartedAt); @@ -452,10 +455,12 @@ export async function* runChatGenerationV3( if (commitAfter.requiredError || requiredAfterNotDone.length > 0) { runState.finishedStatus = "failed"; runState.failedType = "after_main_llm"; - runState.errorMessage = - commitAfter.requiredError - ? "Required after effect commit failed" - : "Required after operation did not finish with done"; + runState.errorMessage = buildRequiredOperationFailureMessage({ + stage: "after", + operationResults: requiredAfterNotDone, + commitReport: commitAfter.report, + requiredCommitError: commitAfter.requiredError, + }); } else { runState.finishedStatus = "done"; } diff --git a/web/src/i18n/resources/en/chat.ts b/web/src/i18n/resources/en/chat.ts index 0a4e11e6..8c76457c 100644 --- a/web/src/i18n/resources/en/chat.ts +++ b/web/src/i18n/resources/en/chat.ts @@ -261,6 +261,8 @@ operationFinishedSkipped: 'Operation {{name}} ({{hook}}) skipped', operationFinishedError: 'Operation {{name}} ({{hook}}) failed', operationFinishedAborted: 'Operation {{name}} ({{hook}}) aborted', + operationFallbackName: 'operation', + operationFallbackHook: 'unknown phase', }, }; diff --git a/web/src/i18n/resources/ru/chat.ts b/web/src/i18n/resources/ru/chat.ts index 48e536d4..0677a656 100644 --- a/web/src/i18n/resources/ru/chat.ts +++ b/web/src/i18n/resources/ru/chat.ts @@ -261,6 +261,8 @@ operationFinishedSkipped: 'Операция {{name}} ({{hook}}) пропущена', operationFinishedError: 'Операция {{name}} ({{hook}}) завершилась с ошибкой', operationFinishedAborted: 'Операция {{name}} ({{hook}}) прервана', + operationFallbackName: 'операция', + operationFallbackHook: 'неизвестная фаза', }, }; diff --git a/web/src/model/chat-entry-parts/index.ts b/web/src/model/chat-entry-parts/index.ts index 17d51f32..8efb22d4 100644 --- a/web/src/model/chat-entry-parts/index.ts +++ b/web/src/model/chat-entry-parts/index.ts @@ -26,6 +26,7 @@ import { logChatGenerationSseEvent } from '../chat-generation-debug'; import { userPersonsModel } from '../user-persons'; import { applyAssistantCanonicalizationPatch } from './assistant-canonicalization'; +import { readOperationFinishedFailure } from './operation-finished-failure'; import type { SseEnvelope } from '../../api/chat-core'; import type { @@ -1490,21 +1491,23 @@ sample({ // (model/operation-run-trace); toasts stay only for failures. handleSseEnvelope.watch((env) => { if (env.type !== 'operation.finished') return; - const data = typeof env.data === 'object' && env.data !== null ? (env.data as Record) : null; - if (!data) return; - const status = typeof data.status === 'string' ? data.status : ''; - if (status !== 'error' && status !== 'aborted') return; - const name = typeof data.name === 'string' && data.name.trim().length > 0 ? data.name : String(data.opId ?? 'operation'); - const hook = typeof data.hook === 'string' && data.hook.trim().length > 0 ? data.hook : 'unknown'; - - if (status === 'aborted') { + const failure = readOperationFinishedFailure(env.data); + if (!failure) return; + const interpolation = { + name: failure.name ?? i18n.t('chat.toasts.operationFallbackName'), + hook: failure.hook ?? i18n.t('chat.toasts.operationFallbackHook'), + }; + + if (failure.status === 'aborted') { toaster.error({ - title: i18n.t('chat.toasts.operationFinishedAborted', { name, hook }), + title: i18n.t('chat.toasts.operationFinishedAborted', interpolation), + description: failure.errorMessage ?? undefined, }); return; } toaster.error({ - title: i18n.t('chat.toasts.operationFinishedError', { name, hook }), + title: i18n.t('chat.toasts.operationFinishedError', interpolation), + description: failure.errorMessage ?? undefined, }); }); diff --git a/web/src/model/chat-entry-parts/operation-finished-failure.test.ts b/web/src/model/chat-entry-parts/operation-finished-failure.test.ts new file mode 100644 index 00000000..e97fa879 --- /dev/null +++ b/web/src/model/chat-entry-parts/operation-finished-failure.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { readOperationFinishedFailure } from './operation-finished-failure'; + +describe('readOperationFinishedFailure', () => { + it('returns actionable error details', () => { + expect( + readOperationFinishedFailure({ + status: 'error', + opId: 'summarize', + name: 'Summarize', + hook: 'before_main_llm', + error: { code: 'LLM_PROVIDER_ERROR', message: 'Provider request failed' }, + }), + ).toEqual({ + status: 'error', + name: 'Summarize', + hook: 'before_main_llm', + errorMessage: 'Provider request failed', + }); + }); + + it('ignores successful events and tolerates missing abort details', () => { + expect(readOperationFinishedFailure({ status: 'done', opId: 'ok' })).toBeNull(); + expect(readOperationFinishedFailure({ status: 'aborted', opId: 'cancelled' })).toEqual({ + status: 'aborted', + name: 'cancelled', + hook: null, + errorMessage: null, + }); + }); +}); diff --git a/web/src/model/chat-entry-parts/operation-finished-failure.ts b/web/src/model/chat-entry-parts/operation-finished-failure.ts new file mode 100644 index 00000000..db9961b2 --- /dev/null +++ b/web/src/model/chat-entry-parts/operation-finished-failure.ts @@ -0,0 +1,28 @@ +export type OperationFinishedFailure = { + status: 'error' | 'aborted'; + name: string | null; + hook: string | null; + errorMessage: string | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +export function readOperationFinishedFailure(data: unknown): OperationFinishedFailure | null { + if (!isRecord(data)) return null; + const status = data.status; + if (status !== 'error' && status !== 'aborted') return null; + + const error = isRecord(data.error) ? data.error : null; + return { + status, + name: readNonEmptyString(data.name) ?? readNonEmptyString(data.opId), + hook: readNonEmptyString(data.hook), + errorMessage: error ? readNonEmptyString(error.message) : null, + }; +} diff --git a/web/src/model/operation-run-trace/index.test.ts b/web/src/model/operation-run-trace/index.test.ts index abb3298d..ed119289 100644 --- a/web/src/model/operation-run-trace/index.test.ts +++ b/web/src/model/operation-run-trace/index.test.ts @@ -132,6 +132,26 @@ describe('reduceRunTrace', () => { expect(trace?.operations[1]?.guard).toMatchObject({ outputKey: 'need_translate', actual: false }); }); + it('records actionable operation error details', () => { + let trace = startedTrace(); + trace = reduceRunTrace( + trace, + env('operation.finished', { + runId: 'run-1', + hook: 'before_main_llm', + opId: 'op-error', + name: 'Summarize', + status: 'error', + error: { code: 'LLM_PROVIDER_ERROR', message: 'Provider request failed' }, + }), + ); + + expect(trace?.operations[0]).toMatchObject({ + status: 'error', + errorMessage: 'Provider request failed', + }); + }); + it('attaches commit results to the operation', () => { let trace = startedTrace(); trace = reduceRunTrace( From 0637f87bfa520e42cefbd70240d180f9a8765877 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Tue, 14 Jul 2026 20:35:35 +0300 Subject: [PATCH 07/14] feat: redesign operations workspace --- .../sidebars/operation-profiles/index.tsx | 100 +----- .../operation-block-editor.tsx | 155 +++------ .../operation-profile-blocks-editor.tsx | 103 +++--- .../operation-profiles/operation-profiles.css | 125 +++----- .../operation-workspace.css | 301 ++++++++++++++++++ .../operation-profiles/ui/block-actions.tsx | 130 ++------ .../ui/block-settings-panel.tsx | 53 +++ .../ui/editor-save-actions.tsx | 45 +++ .../ui/entity-actions-menu.tsx | 122 +++++++ .../operation-profiles/ui/operation-list.tsx | 116 ++++--- .../operation-profiles/ui/operation-row.tsx | 27 +- .../ui/operation-workspace-mode.test.ts | 41 +++ .../ui/operation-workspace-mode.ts | 22 ++ .../ui/operation-workspace.tsx | 103 ++++++ .../operation-profiles/ui/profile-actions.tsx | 130 ++------ .../ui/profile-settings-panel.tsx | 105 ++++++ .../operation-profiles/ui/run-trace-panel.tsx | 31 +- web/src/i18n/resources/en/drawer.ts | 2 + .../i18n/resources/en/operationProfiles.ts | 29 ++ web/src/i18n/resources/ru/drawer.ts | 10 +- .../i18n/resources/ru/operationProfiles.ts | 43 ++- web/src/ui/drawer.tsx | 4 +- 22 files changed, 1146 insertions(+), 651 deletions(-) create mode 100644 web/src/features/sidebars/operation-profiles/operation-workspace.css create mode 100644 web/src/features/sidebars/operation-profiles/ui/block-settings-panel.tsx create mode 100644 web/src/features/sidebars/operation-profiles/ui/editor-save-actions.tsx create mode 100644 web/src/features/sidebars/operation-profiles/ui/entity-actions-menu.tsx create mode 100644 web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.test.ts create mode 100644 web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.ts create mode 100644 web/src/features/sidebars/operation-profiles/ui/operation-workspace.tsx create mode 100644 web/src/features/sidebars/operation-profiles/ui/profile-settings-panel.tsx diff --git a/web/src/features/sidebars/operation-profiles/index.tsx b/web/src/features/sidebars/operation-profiles/index.tsx index ba52f206..be37159b 100644 --- a/web/src/features/sidebars/operation-profiles/index.tsx +++ b/web/src/features/sidebars/operation-profiles/index.tsx @@ -1,8 +1,8 @@ -import { Button, Group, Select, Stack, Tabs, Text } from '@mantine/core'; +import { Button, Select, Stack, Tabs, Text } from '@mantine/core'; import { useUnit } from 'effector-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuGitFork, LuSave, LuUndo2 } from 'react-icons/lu'; +import { LuGitFork } from 'react-icons/lu'; import { v4 as uuidv4 } from 'uuid'; import { @@ -34,7 +34,9 @@ import { resolveBundleAutoApplyTargets } from '../common/bundle-helpers'; import { OperationBlockEditor, type OperationBlockToolbarState } from './operation-block-editor'; import { OperationProfileBlocksEditor, type OperationProfileBlocksToolbarState } from './operation-profile-blocks-editor'; import './operation-profiles.css'; +import './operation-workspace.css'; import { BlockActions } from './ui/block-actions'; +import { EditorSaveActions } from './ui/editor-save-actions'; import { ProfileActions } from './ui/profile-actions'; import { ProfilePicker } from './ui/profile-picker'; import { RunTracePanel } from './ui/run-trace-panel'; @@ -134,7 +136,9 @@ export const OperationProfilesSidebar: React.FC = () => { const sidebarState = sidebars.operationProfiles; const preferSplitLayout = sidebarState.isFullscreen || sidebarState.size === 'full'; - const uiClassName = preferSplitLayout ? 'op-ui' : 'op-ui op-ui--drawer'; + const uiClassName = preferSplitLayout + ? `op-ui op-ui--fullscreen op-ui--${activeTab}` + : `op-ui op-ui--drawer op-ui--${activeTab}`; return ( @@ -184,52 +188,13 @@ export const OperationProfilesSidebar: React.FC = () => { /> - {selectedProfile && profileToolbarState && ( + {selectedProfile && profileToolbarState && preferSplitLayout && (
- - {preferSplitLayout ? ( - <> - - - - ) : ( - <> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!profileToolbarState.canSave} - onClick={profileToolbarState.onSave} - /> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!profileToolbarState.canDiscard} - onClick={profileToolbarState.onDiscard} - /> - - )} - +
)} + {!preferSplitLayout && } {!selectedProfile ? ( @@ -317,51 +282,12 @@ export const OperationProfilesSidebar: React.FC = () => { /> )} - {selectedBlock && blockToolbarState && ( - - {preferSplitLayout ? ( - <> - - - - ) : ( - <> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!blockToolbarState.canSave} - onClick={blockToolbarState.onSave} - /> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!blockToolbarState.canDiscard} - onClick={blockToolbarState.onDiscard} - /> - - )} - + {selectedBlock && blockToolbarState && preferSplitLayout && ( + )} + {!preferSplitLayout && } {!selectedBlock ? ( diff --git a/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx b/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx index 57fe8d41..c50b97a9 100644 --- a/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx +++ b/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx @@ -1,20 +1,23 @@ -import { Alert, Button, Card, Collapse, Group, Stack, Text } from '@mantine/core'; +import { Alert, Text } from '@mantine/core'; import { useMediaQuery } from '@mantine/hooks'; import { useUnit } from 'effector-react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { LuChevronDown, LuChevronUp, LuPlus } from 'react-icons/lu'; import { updateOperationBlockFx } from '@model/operation-blocks'; -import { FormInput, FormSwitch } from '@ui/form-components'; import { toOperationBlockFormValues } from './form/operation-block-form-values'; import { fromOperationProfileForm, makeDefaultOperation, type OperationProfileFormValues } from './form/operation-profile-form-mapping'; import { OperationBlockNodeEditorModal } from './node-editor/block-node-editor-modal'; +import { BlockSettingsPanel } from './ui/block-settings-panel'; import { OperationEditor } from './ui/operation-editor/operation-editor'; -import { OperationList } from './ui/operation-list'; -import { getOperationListLayout } from './ui/operation-list-layout'; +import { OperationWorkspace } from './ui/operation-workspace'; +import { + resolveOperationWorkspaceMode, + shouldShowBlockSettings, + type CompactOperationView, +} from './ui/operation-workspace-mode'; import { isOperationKind } from './utils/operation-kind'; import type { OperationListRowMeta } from './ui/types'; @@ -116,9 +119,8 @@ export const OperationBlockEditor: React.FC = ({ }) => { const { t } = useTranslation(); const doUpdate = useUnit(updateOperationBlockFx); - const isMobile = useMediaQuery('(max-width: 767px)'); - const useSplitLayout = preferSplitLayout && !isMobile; - const operationListLayout = useMemo(() => getOperationListLayout(useSplitLayout), [useSplitLayout]); + const isCompactViewport = useMediaQuery('(max-width: 1023px)'); + const useSplitLayout = preferSplitLayout && !isCompactViewport; const initial = useMemo(() => toOperationBlockFormValues(block), [block]); const methods = useForm({ defaultValues: initial }); @@ -152,10 +154,10 @@ export const OperationBlockEditor: React.FC = ({ .filter((row): row is OperationListRowMeta => row !== null); }, [fields, watchedOperations]); - const [isProfileOpen, setIsProfileOpen] = useState(true); const [jsonError, setJsonError] = useState(null); const [baselineValues, setBaselineValues] = useState(initial); const [editingOpId, setEditingOpId] = useState(() => resolveEditingOperationId(null, initial.operations)); + const [compactView, setCompactView] = useState('list'); const hasUnsavedChanges = useMemo(() => { const current = (watchedValues as OperationProfileFormValues | undefined) ?? baselineValues; @@ -169,6 +171,10 @@ export const OperationBlockEditor: React.FC = ({ setEditingOpId((prev) => resolveEditingOperationId(prev, initial.operations)); }, [initial, reset]); + useEffect(() => { + setCompactView('list'); + }, [block.blockId]); + const selectedIndex = useMemo(() => { if (rows.length === 0) return null; if (editingOpId) { @@ -180,6 +186,7 @@ export const OperationBlockEditor: React.FC = ({ const selectedRow = selectedIndex === null ? null : rows.find((row) => row.index === selectedIndex) ?? null; const selectedOpId = selectedRow?.opId ?? null; + const workspaceMode = resolveOperationWorkspaceMode({ useSplitLayout, compactView, selectedOpId }); const saveBlockDraft = useCallback( async (values: OperationProfileFormValues, meta?: unknown) => { @@ -253,21 +260,27 @@ export const OperationBlockEditor: React.FC = ({ const next = makeDefaultOperation(); append(next); setEditingOpId(next.opId); - }, [append]); - - const moveSelection = useCallback( - (direction: 'prev' | 'next') => { - if (rows.length === 0) return; - const current = selectedOpId ? rows.findIndex((row) => row.opId === selectedOpId) : 0; - const safeCurrent = current >= 0 ? current : 0; - const nextIndex = direction === 'prev' ? Math.max(0, safeCurrent - 1) : Math.min(rows.length - 1, safeCurrent + 1); - const next = rows[nextIndex]; - if (!next) return; - setEditingOpId(next.opId); + if (!useSplitLayout) setCompactView('inspector'); + }, [append, useSplitLayout]); + + const selectOperation = useCallback( + (opId: string) => { + setEditingOpId(opId); + if (!useSplitLayout) setCompactView('inspector'); }, - [rows, selectedOpId], + [useSplitLayout], ); + const moveSelection = (direction: 'prev' | 'next') => { + if (rows.length === 0) return; + const current = selectedOpId ? rows.findIndex((row) => row.opId === selectedOpId) : 0; + const safeCurrent = current >= 0 ? current : 0; + const nextIndex = direction === 'prev' ? Math.max(0, safeCurrent - 1) : Math.min(rows.length - 1, safeCurrent + 1); + const next = rows[nextIndex]; + if (!next) return; + setEditingOpId(next.opId); + }; + const removeOperationAt = useCallback( (targetIndex: number, targetOpId: string) => { if (!window.confirm(t('operationProfiles.confirm.deleteOperation'))) return; @@ -275,11 +288,13 @@ export const OperationBlockEditor: React.FC = ({ if (currentPosition < 0) { remove(targetIndex); setEditingOpId(rows[0]?.opId ?? null); + if (rows.length <= 1) setCompactView('list'); return; } const next = rows[currentPosition + 1]?.opId ?? rows[currentPosition - 1]?.opId ?? null; remove(targetIndex); setEditingOpId(next); + if (!next) setCompactView('list'); }, [remove, rows, t], ); @@ -300,95 +315,25 @@ export const OperationBlockEditor: React.FC = ({ return ( - - - setIsProfileOpen((v) => !v)} - > - - {isProfileOpen ? : } - {t('operationProfiles.blocks.blockSettingsTitle')} - - - - - - - - - - - - - - - +
+ {shouldShowBlockSettings(workspaceMode) && } {jsonError && ( {jsonError} )} - - {rows.length === 0 ? ( - - - {t('operationProfiles.operations.title')} - - {t('operationProfiles.operations.empty')} - - - - - ) : useSplitLayout ? ( -
-
- setEditingOpId(opId)} - className={operationListLayout.listClassName} - scrollAreaClassName={operationListLayout.scrollAreaClassName} - /> -
- -
-
- - {t('operationProfiles.inspector.title')} - - {selectedIndex === null ? t('operationProfiles.inspector.noneSelected') : t('operationProfiles.inspector.operationNumber', { number: selectedIndex + 1 })} - - -
- - {inspectorContent} -
-
- ) : ( - <> - - setEditingOpId(opId)} - className={operationListLayout.listClassName} - scrollAreaClassName={operationListLayout.scrollAreaClassName} - /> - - {inspectorContent} - - )} - + setCompactView('list')} + /> +
= ({ profile, blocks, return ( - - - - - - - - -
- - } - size="input-sm" - variant="ghost" - tooltipSettings={ACTION_TOOLTIP_SETTINGS} - onClick={onResetSessionId} - /> -
-
-
+ {error && ( @@ -151,7 +113,7 @@ export const OperationProfileBlocksEditor: React.FC = ({ profile, blocks, )} - +
{t('operationProfiles.blocks.profileCompositionTitle')} @@ -189,22 +151,25 @@ export const OperationProfileBlocksEditor: React.FC = ({ profile, blocks, fields.map((field, index) => { const block = blocks.find((item) => item.blockId === field.blockId); return ( - +
{block?.name ?? field.blockId} - - {field.blockId} - - index > 0 && move(index, index - 1)} disabled={index === 0}> + index > 0 && move(index, index - 1)} + disabled={index === 0} + > index < fields.length - 1 && move(index, index + 1)} disabled={index >= fields.length - 1} > @@ -219,37 +184,45 @@ export const OperationProfileBlocksEditor: React.FC = ({ profile, blocks, > - remove(index)}> + remove(index)} + > - - { - setValue(`blockRefs.${index}.enabled`, event.currentTarget.checked, { shouldDirty: true }); - }} - /> - +
+ { + setValue(`blockRefs.${index}.enabled`, event.currentTarget.checked, { shouldDirty: true }); + }} + /> +
+ { const numeric = typeof value === 'number' && Number.isFinite(value) ? value : 0; setValue(`blockRefs.${index}.order`, numeric, { shouldDirty: true }); }} step={10} - style={{ width: 160 }} - /> -
- + style={{ width: 160 }} + /> +
+ ); }) )}
- +
); diff --git a/web/src/features/sidebars/operation-profiles/operation-profiles.css b/web/src/features/sidebars/operation-profiles/operation-profiles.css index 327f7a4a..6689cc58 100644 --- a/web/src/features/sidebars/operation-profiles/operation-profiles.css +++ b/web/src/features/sidebars/operation-profiles/operation-profiles.css @@ -7,6 +7,23 @@ --op-space-lg: 20px; --op-space-xl: 24px; width: 100%; + min-height: 0; +} + +.op-ui--fullscreen.op-ui--blocks { + height: 100%; + overflow: hidden; + gap: var(--op-space-sm) !important; +} + +.op-ui--fullscreen.op-ui--run { + height: 100%; + overflow: hidden; +} + +.op-ui--fullscreen.op-ui--blocks > .mantine-Tabs-root, +.op-ui--fullscreen.op-ui--blocks > .op-command { + flex: 0 0 auto; } .op-ui .op-command { @@ -45,6 +62,25 @@ margin-left: auto; } +.op-ui .op-compactSaveBar { + position: sticky; + top: 0; + z-index: 8; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--op-space-sm); + padding: 10px 12px; + background: color-mix(in srgb, var(--op-surface) 94%, var(--op-accent)); + border: 1px solid var(--op-accent); + border-radius: var(--op-radius-md); + box-shadow: var(--op-shadow); +} + +.op-ui .op-saveStatus { + color: var(--op-accent); +} + .op-ui .op-nodeButton { min-width: 200px; } @@ -84,88 +120,6 @@ padding: var(--op-space-md); } -.op-ui .op-editorHeader { - display: flex; - gap: var(--op-space-sm); - align-items: center; - justify-content: space-between; - flex-wrap: wrap; -} - -.op-ui .op-stickyHeader { - position: sticky; - top: 0; - background: var(--op-surface); - z-index: 5; - padding-bottom: var(--op-space-sm); - border-bottom: 1px solid var(--op-border); -} - -.op-ui .op-inspectorHeader { - margin-bottom: var(--op-space-sm); -} - -.op-ui .op-sectionToggle { - cursor: pointer; - user-select: none; -} - -.op-ui .op-workspace { - display: grid; - grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); - gap: var(--op-space-md); - align-items: start; -} - -.op-ui .op-listPane { - display: flex; - flex-direction: column; - min-height: 0; -} - -.op-ui .op-stickyPane { - position: sticky; - top: 0; - max-height: calc(100dvh - 180px); - overflow: hidden; -} - -.op-ui .op-listPane, -.op-ui .op-inspectorPane { - background: var(--op-surface); - border: 1px solid var(--op-border); - border-radius: var(--op-radius-lg); - box-shadow: var(--op-shadow); - padding: var(--op-space-md); -} - -.op-ui .op-listHeader { - background: var(--op-surface); - padding-bottom: var(--op-space-sm); - border-bottom: 1px solid var(--op-border); - margin-bottom: var(--op-space-sm); -} - -.op-ui .op-listLayout { - min-height: 0; -} - -.op-ui .op-stickyPane .op-listLayout { - height: 100%; -} - -.op-ui .op-listScrollArea { - max-height: min(56vh, 640px); - overflow-y: auto; - padding-right: 4px; -} - -.op-ui .op-listScrollArea--fill { - flex: 1; - min-height: 0; - max-height: none; -} - .op-ui .op-listRows { display: flex; flex-direction: column; @@ -269,12 +223,6 @@ outline-offset: 2px; } -@media (max-width: 1199px) { - .op-ui .op-workspace { - grid-template-columns: 1fr; - } -} - @media (max-width: 767px) { .op-ui .op-command, .op-ui .op-listPane, @@ -319,4 +267,5 @@ .op-ui .op-listScrollArea { max-height: 50vh; } + } diff --git a/web/src/features/sidebars/operation-profiles/operation-workspace.css b/web/src/features/sidebars/operation-profiles/operation-workspace.css new file mode 100644 index 00000000..feea7a5e --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/operation-workspace.css @@ -0,0 +1,301 @@ +.op-ui .op-blockEditorRoot { + display: flex; + flex-direction: column; + gap: var(--op-space-sm); + min-height: 0; +} + +.op-ui--fullscreen.op-ui--blocks .op-blockEditorRoot { + flex: 1 1 auto; + overflow: hidden; +} + +.op-ui .op-settingsPanel { + flex: 0 0 auto; + background: var(--op-surface); + border: 1px solid var(--op-border); + border-radius: var(--op-radius-lg); + overflow: hidden; +} + +.op-ui .op-settingsSummary { + display: flex; + align-items: center; + gap: var(--op-space-sm); + flex: 1 1 auto; + min-width: 0; + padding: 10px 14px; + text-align: left; +} + +.op-ui .op-settingsHeader { + display: flex; + align-items: center; +} + +.op-ui .op-profileEnabledToggle { + flex: 0 0 auto; + padding: 10px 14px 10px 8px; + white-space: nowrap; +} + +.op-ui .op-settingsIdentity { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.op-ui .op-settingsCopy { + min-width: 0; +} + +.op-ui .op-settingsChevron { + flex: 0 0 auto; + transition: transform 160ms ease; +} + +.op-ui .op-settingsChevron[data-opened='true'] { + transform: rotate(180deg); +} + +.op-ui .op-settingsFields { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.4fr) auto; + gap: var(--op-space-sm); + align-items: end; + padding: var(--op-space-sm) 14px 14px; + border-top: 1px solid var(--op-border); +} + +.op-ui .op-profileSettingsFields { + display: flex; + flex-direction: column; + gap: var(--op-space-sm); + padding: var(--op-space-sm) 14px 14px; + border-top: 1px solid var(--op-border); +} + +.op-ui .op-profileIdentityFields, +.op-ui .op-profileRuntimeFields { + display: grid; + gap: var(--op-space-sm); + align-items: end; +} + +.op-ui .op-profileIdentityFields { + grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.35fr); +} + +.op-ui .op-profileRuntimeFields { + grid-template-columns: minmax(280px, 520px); +} + +.op-ui .op-switchField { + display: flex; + align-items: center; + align-self: end; + min-height: 36px; +} + +.op-ui--drawer .op-settingsFields, +.op-ui--drawer .op-profileIdentityFields, +.op-ui--drawer .op-profileRuntimeFields { + grid-template-columns: 1fr; +} + +.op-ui .op-blockRefControls { + display: grid; + grid-template-columns: minmax(220px, 1fr) 160px; + gap: var(--op-space-md); + align-items: end; + margin-top: var(--op-space-xs); +} + +.op-ui .op-compositionPanel, +.op-ui .op-compactList, +.op-ui .op-compactInspectorBody { + background: var(--op-surface); + border: 1px solid var(--op-border); + border-radius: var(--op-radius-lg); + padding: var(--op-space-md); +} + +.op-ui .op-compositionRow { + padding: var(--op-space-sm) 0; + border-top: 1px solid var(--op-border); +} + +.op-ui .op-compositionRow:last-child { + padding-bottom: 0; +} + +.op-ui .op-editorHeader { + display: flex; + gap: var(--op-space-sm); + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.op-ui .op-inspectorHeader { + flex: 0 0 auto; + padding: 14px var(--op-space-md) var(--op-space-sm); + border-bottom: 1px solid var(--op-border); +} + +.op-ui .op-sectionToggle { + cursor: pointer; + user-select: none; +} + +.op-ui .op-workspace { + display: grid; + grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + gap: var(--op-space-md); + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.op-ui .op-listPane, +.op-ui .op-inspectorPane { + min-height: 0; + background: var(--op-surface); + border: 1px solid var(--op-border); + border-radius: var(--op-radius-lg); + overflow: hidden; +} + +.op-ui .op-listPane, +.op-ui .op-inspectorPane, +.op-ui .op-listLayout { + display: flex; + flex-direction: column; +} + +.op-ui .op-listPane { + padding: var(--op-space-md); +} + +.op-ui .op-listHeader { + background: var(--op-surface); + padding-bottom: var(--op-space-sm); + border-bottom: 1px solid var(--op-border); + margin-bottom: var(--op-space-sm); +} + +.op-ui .op-listLayout, +.op-ui .op-inspectorScroll { + flex: 1 1 auto; + min-height: 0; +} + +.op-ui .op-inspectorScroll { + overflow-y: auto; + padding: var(--op-space-md); +} + +.op-ui .op-listScrollArea { + max-height: min(56vh, 640px); + overflow-y: auto; + padding-right: 4px; +} + +.op-ui .op-listScrollArea--fill { + flex: 1; + min-height: 0; + max-height: none; +} + +.op-ui .op-compactScreen { + animation: op-panel-enter 160ms ease-out; +} + +.op-ui .op-compactInspector { + min-width: 0; +} + +.op-ui .op-compactNav { + position: sticky; + top: 0; + z-index: 7; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--op-space-sm); + margin-bottom: var(--op-space-sm); + padding: 8px 4px; + background: var(--ts-surface); + border-bottom: 1px solid var(--op-border); +} + +.op-ui .op-emptyState, +.op-ui .op-runEmpty { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--op-space-md); + padding: var(--op-space-xl); +} + +.op-ui .op-emptyState { + min-height: 260px; + color: var(--op-muted); + background: var(--op-surface); + border: 1px dashed var(--op-border-strong); + border-radius: var(--op-radius-lg); +} + +.op-ui .op-runEmpty { + min-height: 320px; + max-width: 480px; + margin: auto; +} + +.op-ui .op-runEmptyIcon { + display: grid; + place-items: center; + width: 52px; + height: 52px; + color: var(--op-accent); + background: var(--op-accent-soft); + border-radius: 50%; +} + +@keyframes op-panel-enter { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (max-width: 767px) { + .op-ui .op-settingsFields, + .op-ui .op-profileIdentityFields, + .op-ui .op-profileRuntimeFields, + .op-ui .op-blockRefControls { + grid-template-columns: 1fr; + } + + .op-ui .op-profileEnabledToggle { + padding-left: 4px; + } +} + +@media (prefers-reduced-motion: reduce) { + .op-ui .op-compactScreen { + animation: none; + } + + .op-ui .op-settingsChevron, + .op-ui .op-listRow { + transition: none; + } +} diff --git a/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx b/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx index dc981fb5..ac28f260 100644 --- a/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx @@ -1,26 +1,9 @@ -import { Group } from '@mantine/core'; -import React, { useRef } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuCopyPlus, LuPlus, LuTrash2 } from 'react-icons/lu'; -import { EXPORT_FILE_ICON, IMPORT_FILE_ICON } from '@ui/file-transfer-icons'; -import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; -import { toaster } from '@ui/toaster'; -import { TOOLTIP_PORTAL_SETTINGS } from '@ui/z-index'; +import { EntityActionsMenu } from './entity-actions-menu'; type SelectedBlock = { blockId: string; name: string } | null; -const QUICK_ACTION_TOOLTIP_SETTINGS = TOOLTIP_PORTAL_SETTINGS; - -function downloadJson(filename: string, blob: Blob) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} type Props = { selected: SelectedBlock; @@ -33,96 +16,25 @@ type Props = { export const BlockActions: React.FC = ({ selected, onCreate, onDuplicate, onDelete, onExport, onImport }) => { const { t } = useTranslation(); - const fileInputRef = useRef(null); - return ( - - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={onCreate} - /> - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.blockId} - onClick={() => selected?.blockId && onDuplicate(selected.blockId)} - /> - } - size="input-sm" - colorPalette="red" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.blockId} - onClick={() => { - if (!selected?.blockId) return; - if (!window.confirm(t('operationProfiles.confirm.deleteBlock'))) return; - onDelete(selected.blockId); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.blockId} - onClick={async () => { - if (!selected?.blockId) return; - try { - const exported = await onExport(selected.blockId); - downloadJson(exported.filename, exported.blob); - } catch (e) { - toaster.error({ - title: t('operationProfiles.toasts.exportError'), - description: e instanceof Error ? e.message : String(e), - }); - } - }} - /> - - { - const file = e.currentTarget.files?.[0]; - if (!file) return; - void onImport(file) - .catch((err) => { - toaster.error({ - title: t('operationProfiles.toasts.importError'), - description: err instanceof Error ? err.message : String(err), - }); - }) - .finally(() => { - e.currentTarget.value = ''; - }); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={() => { - fileInputRef.current?.click(); - }} - /> - + ); }; diff --git a/web/src/features/sidebars/operation-profiles/ui/block-settings-panel.tsx b/web/src/features/sidebars/operation-profiles/ui/block-settings-panel.tsx new file mode 100644 index 00000000..b3026b86 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/block-settings-panel.tsx @@ -0,0 +1,53 @@ +import { Badge, Collapse, Stack, Text, UnstyledButton } from '@mantine/core'; +import React from 'react'; +import { useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { LuChevronDown } from 'react-icons/lu'; + +import { FormInput, FormSwitch } from '@ui/form-components'; + +type Props = { + operationCount: number; +}; + +export const BlockSettingsPanel: React.FC = ({ operationCount }) => { + const { t } = useTranslation(); + const [opened, setOpened] = React.useState(false); + const [name, enabled] = useWatch({ name: ['name', 'enabled'] }) as [unknown, unknown]; + const blockName = typeof name === 'string' && name.trim() ? name.trim() : t('operationProfiles.blocks.blockSettingsTitle'); + + return ( +
+ setOpened((value) => !value)} + aria-expanded={opened} + > +
+ + + + {blockName} + + + {t('operationProfiles.blocks.operationCount', { count: operationCount })} + + +
+ + {enabled ? t('operationProfiles.status.enabled') : t('operationProfiles.status.disabled')} + +
+ + +
+ + +
+ +
+
+
+
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/editor-save-actions.tsx b/web/src/features/sidebars/operation-profiles/ui/editor-save-actions.tsx new file mode 100644 index 00000000..fc4e7c92 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/editor-save-actions.tsx @@ -0,0 +1,45 @@ +import { Button, Group, Text } from '@mantine/core'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuSave, LuUndo2 } from 'react-icons/lu'; + +export type EditorSaveState = { + canSave: boolean; + canDiscard: boolean; + onSave: () => void; + onDiscard: () => void; +}; + +type Props = { + state: EditorSaveState | null; + compact?: boolean; +}; + +export const EditorSaveActions: React.FC = ({ state, compact = false }) => { + const { t } = useTranslation(); + if (!state || (compact && !state.canSave)) return null; + + return ( +
+ {compact && ( + + {t('operationProfiles.operationEditor.unsaved')} + + )} + + + + +
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/entity-actions-menu.tsx b/web/src/features/sidebars/operation-profiles/ui/entity-actions-menu.tsx new file mode 100644 index 00000000..5e261a00 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/entity-actions-menu.tsx @@ -0,0 +1,122 @@ +import { ActionIcon, Button, Group, Menu } from '@mantine/core'; +import React, { useRef } from 'react'; +import { LuCopyPlus, LuEllipsis, LuPlus, LuTrash2 } from 'react-icons/lu'; + +import { EXPORT_FILE_ICON, IMPORT_FILE_ICON } from '@ui/file-transfer-icons'; +import { toaster } from '@ui/toaster'; + +type Selection = { id: string; name: string } | null; + +type Props = { + selected: Selection; + labels: { + create: string; + more: string; + duplicate: string; + remove: string; + export: string; + import: string; + confirmRemove: string; + exportError: string; + importError: string; + }; + onCreate: () => void; + onDuplicate: (id: string) => void; + onRemove: (id: string) => void; + onExport: (id: string) => Promise<{ blob: Blob; filename: string }>; + onImport: (file: File) => Promise; +}; + +function downloadJson(filename: string, blob: Blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export const EntityActionsMenu: React.FC = ({ + selected, + labels, + onCreate, + onDuplicate, + onRemove, + onExport, + onImport, +}) => { + const fileInputRef = useRef(null); + + return ( + + + + + + + + + + } + disabled={!selected} + onClick={() => selected && onDuplicate(selected.id)} + > + {labels.duplicate} + + } + disabled={!selected} + onClick={() => { + if (!selected) return; + void onExport(selected.id) + .then((result) => downloadJson(result.filename, result.blob)) + .catch((error) => + toaster.error({ title: labels.exportError, description: error instanceof Error ? error.message : String(error) }), + ); + }} + > + {labels.export} + + } onClick={() => fileInputRef.current?.click()}> + {labels.import} + + + } + disabled={!selected} + onClick={() => { + if (!selected || !window.confirm(labels.confirmRemove)) return; + onRemove(selected.id); + }} + > + {labels.remove} + + + + + { + const file = event.currentTarget.files?.[0]; + if (!file) return; + void onImport(file) + .catch((error) => + toaster.error({ title: labels.importError, description: error instanceof Error ? error.message : String(error) }), + ) + .finally(() => { + event.currentTarget.value = ''; + }); + }} + /> + + ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx index a754a234..f7e44839 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx @@ -1,10 +1,10 @@ -import { Button, Group, Select, Stack, Text, TextInput } from '@mantine/core'; +import { Badge, Button, Group, Popover, Select, Stack, Text, TextInput } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import { useVirtualizer } from '@tanstack/react-virtual'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { LuPlus, LuSearch } from 'react-icons/lu'; +import { LuPlus, LuSearch, LuSlidersHorizontal } from 'react-icons/lu'; import { isOperationKind } from '../utils/operation-kind'; @@ -151,6 +151,7 @@ export const OperationList: React.FC = ({ if (!selectedOpId) return -1; return filteredRows.findIndex((row) => row.opId === selectedOpId); }, [filteredRows, selectedOpId]); + const activeFilterCount = [filters.kind, filters.enabled, filters.required].filter((value) => value !== 'all').length; useEffect(() => { if (!shouldVirtualize) return; @@ -195,65 +196,60 @@ export const OperationList: React.FC = ({ - setFilters((prev) => ({ ...prev, query: event.currentTarget.value }))} - placeholder={t('operationProfiles.filters.searchPlaceholder')} - leftSection={} - aria-label={t('operationProfiles.filters.searchAria')} - /> - - - - setFilters((prev) => ({ - ...prev, - enabled: - next === 'enabled' || next === 'disabled' || next === 'all' - ? next - : DEFAULT_FILTERS.enabled, - })) - } - comboboxProps={{ withinPortal: false }} - aria-label={t('operationProfiles.filters.byEnabledAria')} - /> - setFilters((prev) => ({ ...prev, kind: next === 'all' || next === null ? 'all' : (next as OperationFilterState['kind']) }))} + comboboxProps={{ withinPortal: false }} + /> + setFilters((prev) => ({ ...prev, required: next === 'required' || next === 'optional' || next === 'all' ? next : DEFAULT_FILTERS.required }))} + comboboxProps={{ withinPortal: false }} + /> +
+ +
diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx index f2c7b29d..db86bd04 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx @@ -1,5 +1,6 @@ import { Badge, Group, Paper, Stack, Text } from '@mantine/core'; import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import type { OperationKind } from '@shared/types/operation-profiles'; @@ -16,6 +17,7 @@ type OperationRowProps = { }; export const OperationRow: React.FC = memo(({ opId, index, name, kind, enabled, required, depsCount, selected, onSelect }) => { + const { t } = useTranslation(); return ( = memo(({ opId, index, na } }} > - + {name} - {opId} + {t(`operationProfiles.kind.${kind}`)} - - - #{index + 1} - - - {kind} - + + #{index + 1} + + + {(!enabled || required || depsCount > 0) && ( + {!enabled && ( - disabled + {t('operationProfiles.status.disabled')} )} {required && ( - required + {t('operationProfiles.sectionsLabels.required')} )} {depsCount > 0 && ( - deps {depsCount} + {t('operationProfiles.operations.dependencies', { count: depsCount })} )} - + )} ); }); diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.test.ts b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.test.ts new file mode 100644 index 00000000..740c1b04 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveOperationWorkspaceMode, shouldShowBlockSettings } from './operation-workspace-mode'; + +describe('operation-workspace-mode', () => { + it('uses the split workspace whenever the large layout is available', () => { + expect( + resolveOperationWorkspaceMode({ + useSplitLayout: true, + compactView: 'list', + selectedOpId: null, + }), + ).toBe('split'); + }); + + it('opens the selected operation as a focused compact screen', () => { + expect( + resolveOperationWorkspaceMode({ + useSplitLayout: false, + compactView: 'inspector', + selectedOpId: 'operation-1', + }), + ).toBe('inspector'); + }); + + it('falls back to the compact list when no operation is selected', () => { + expect( + resolveOperationWorkspaceMode({ + useSplitLayout: false, + compactView: 'inspector', + selectedOpId: null, + }), + ).toBe('list'); + }); + + it('keeps block settings out of the focused compact inspector', () => { + expect(shouldShowBlockSettings('list')).toBe(true); + expect(shouldShowBlockSettings('split')).toBe(true); + expect(shouldShowBlockSettings('inspector')).toBe(false); + }); +}); diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.ts b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.ts new file mode 100644 index 00000000..b98fe63f --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.ts @@ -0,0 +1,22 @@ +export type CompactOperationView = 'list' | 'inspector'; +export type OperationWorkspaceMode = 'split' | CompactOperationView; + +type ResolveModeParams = { + useSplitLayout: boolean; + compactView: CompactOperationView; + selectedOpId: string | null; +}; + +export function resolveOperationWorkspaceMode({ + useSplitLayout, + compactView, + selectedOpId, +}: ResolveModeParams): OperationWorkspaceMode { + if (useSplitLayout) return 'split'; + if (compactView === 'inspector' && selectedOpId) return 'inspector'; + return 'list'; +} + +export function shouldShowBlockSettings(mode: OperationWorkspaceMode): boolean { + return mode !== 'inspector'; +} diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-workspace.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-workspace.tsx new file mode 100644 index 00000000..3f29b00c --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/operation-workspace.tsx @@ -0,0 +1,103 @@ +import { Button, Stack, Text } from '@mantine/core'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuArrowLeft, LuPlus, LuWorkflow } from 'react-icons/lu'; + +import { OperationList } from './operation-list'; +import { getOperationListLayout } from './operation-list-layout'; + +import type { OperationWorkspaceMode } from './operation-workspace-mode'; +import type { OperationListRowMeta } from './types'; + +type Props = { + mode: OperationWorkspaceMode; + rows: OperationListRowMeta[]; + selectedOpId: string | null; + selectedIndex: number | null; + inspectorContent: React.ReactNode; + onAdd: () => void; + onMoveSelection: (direction: 'prev' | 'next') => void; + onSelect: (opId: string) => void; + onBackToList: () => void; +}; + +export const OperationWorkspace: React.FC = ({ + mode, + rows, + selectedOpId, + selectedIndex, + inspectorContent, + onAdd, + onMoveSelection, + onSelect, + onBackToList, +}) => { + const { t } = useTranslation(); + const listLayout = getOperationListLayout(mode === 'split'); + const list = ( + onSelect(selectedOpId) : undefined} + className={listLayout.listClassName} + scrollAreaClassName={listLayout.scrollAreaClassName} + /> + ); + + if (rows.length === 0) { + return ( +
+ + + {t('operationProfiles.operations.emptyTitle')} + + {t('operationProfiles.operations.empty')} + + + +
+ ); + } + + if (mode === 'split') { + return ( +
+ +
+
+ {t('operationProfiles.inspector.title')} + + {selectedIndex === null + ? t('operationProfiles.inspector.noneSelected') + : t('operationProfiles.inspector.operationNumber', { number: selectedIndex + 1 })} + +
+
{inspectorContent}
+
+
+ ); + } + + if (mode === 'list') { + return
{list}
; + } + + return ( +
+
+ + + {selectedIndex === null ? null : t('operationProfiles.inspector.operationNumber', { number: selectedIndex + 1 })} + +
+
{inspectorContent}
+
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx b/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx index 8cec79d3..c6763029 100644 --- a/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx @@ -1,26 +1,9 @@ -import { Group } from '@mantine/core'; -import React, { useRef } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuCopyPlus, LuPlus, LuTrash2 } from 'react-icons/lu'; -import { EXPORT_FILE_ICON, IMPORT_FILE_ICON } from '@ui/file-transfer-icons'; -import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; -import { toaster } from '@ui/toaster'; -import { TOOLTIP_PORTAL_SETTINGS } from '@ui/z-index'; +import { EntityActionsMenu } from './entity-actions-menu'; type SelectedProfile = { profileId: string; name: string } | null; -const QUICK_ACTION_TOOLTIP_SETTINGS = TOOLTIP_PORTAL_SETTINGS; - -function downloadJson(filename: string, blob: Blob) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} type Props = { selected: SelectedProfile; @@ -33,96 +16,25 @@ type Props = { export const ProfileActions: React.FC = ({ selected, onCreate, onDuplicate, onDelete, onExport, onImport }) => { const { t } = useTranslation(); - const fileInputRef = useRef(null); - return ( - - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={onCreate} - /> - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.profileId} - onClick={() => selected?.profileId && onDuplicate(selected.profileId)} - /> - } - size="input-sm" - colorPalette="red" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.profileId} - onClick={() => { - if (!selected?.profileId) return; - if (!window.confirm(t('operationProfiles.confirm.deleteProfile'))) return; - onDelete(selected.profileId); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.profileId} - onClick={async () => { - if (!selected?.profileId) return; - try { - const exported = await onExport(selected.profileId); - downloadJson(exported.filename, exported.blob); - } catch (e) { - toaster.error({ - title: t('operationProfiles.toasts.exportError'), - description: e instanceof Error ? e.message : String(e), - }); - } - }} - /> - - { - const file = e.currentTarget.files?.[0]; - if (!file) return; - void onImport(file) - .catch((err) => { - toaster.error({ - title: t('operationProfiles.toasts.importError'), - description: err instanceof Error ? err.message : String(err), - }); - }) - .finally(() => { - e.currentTarget.value = ''; - }); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={() => { - fileInputRef.current?.click(); - }} - /> - + ); }; diff --git a/web/src/features/sidebars/operation-profiles/ui/profile-settings-panel.tsx b/web/src/features/sidebars/operation-profiles/ui/profile-settings-panel.tsx new file mode 100644 index 00000000..d72cafb1 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/profile-settings-panel.tsx @@ -0,0 +1,105 @@ +import { Collapse, Stack, Text, UnstyledButton } from '@mantine/core'; +import React from 'react'; +import { useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { LuChevronDown, LuRotateCcw } from 'react-icons/lu'; + +import { FormInput, FormSelect, FormSwitch } from '@ui/form-components'; +import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; +import { TOOLTIP_PORTAL_SETTINGS } from '@ui/z-index'; + +type Props = { + blockCount: number; + onResetSessionId: () => void; +}; + +export const ProfileSettingsPanel: React.FC = ({ blockCount, onResetSessionId }) => { + const { t } = useTranslation(); + const [opened, setOpened] = React.useState(false); + const executionModeDescriptionId = React.useId(); + const [name, executionMode] = useWatch({ name: ['name', 'executionMode'] }) as [unknown, unknown]; + const profileName = typeof name === 'string' && name.trim() ? name.trim() : t('operationProfiles.profileSettings.title'); + const modeLabel = + executionMode === 'sequential' + ? t('operationProfiles.profileSettings.executionModeSequential') + : t('operationProfiles.profileSettings.executionModeConcurrent'); + const modeDescription = + executionMode === 'sequential' + ? t('operationProfiles.profileSettings.executionModeSequentialDescription') + : t('operationProfiles.profileSettings.executionModeConcurrentDescription'); + + return ( +
+
+ setOpened((value) => !value)} + aria-expanded={opened} + > +
+ + + + {profileName} + + + {modeLabel} · {t('operationProfiles.profileSettings.blockCount', { count: blockCount })} + + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + + {modeDescription} + +
+
+
+ + } + size="input-sm" + variant="ghost" + tooltipSettings={TOOLTIP_PORTAL_SETTINGS} + onClick={onResetSessionId} + /> +
+
+
+
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx b/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx index 1ce5e2eb..ce0388e3 100644 --- a/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx @@ -1,10 +1,11 @@ -import { Badge, Collapse, Group, Loader, Paper, Stack, Text, UnstyledButton } from '@mantine/core'; +import { Badge, Button, Collapse, Group, Loader, Paper, Stack, Text, UnstyledButton } from '@mantine/core'; import { useUnit } from 'effector-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuChevronDown, LuChevronRight, LuCircleCheck, LuCircleMinus, LuCircleX } from 'react-icons/lu'; +import { LuActivity, LuChevronDown, LuChevronRight, LuCircleCheck, LuCircleMinus, LuCircleX, LuMessageCircle } from 'react-icons/lu'; import { $lastRunTrace } from '@model/operation-run-trace'; +import { toggleSidebarOpen } from '@model/sidebars'; import { describeDestination, describeOperationSummary, runStatusColor } from './run-trace-summary'; @@ -110,9 +111,29 @@ export const RunTracePanel: React.FC = () => { if (!trace) { return ( - - {t('operationProfiles.runTrace.empty')} - +
+
+ +
+ + + {t('operationProfiles.runTrace.emptyTitle')} + + + {t('operationProfiles.runTrace.empty')} + + + {t('operationProfiles.runTrace.emptyHint')} + + + +
); } diff --git a/web/src/i18n/resources/en/drawer.ts b/web/src/i18n/resources/en/drawer.ts index 260d69a7..3dff76d4 100644 --- a/web/src/i18n/resources/en/drawer.ts +++ b/web/src/i18n/resources/en/drawer.ts @@ -4,6 +4,8 @@ close: 'Close panel', enterFullscreen: 'Open fullscreen', exitFullscreen: 'Exit fullscreen', + moveToStart: 'Move panel to the left', + moveToEnd: 'Move panel to the right', }; export default enDrawer; diff --git a/web/src/i18n/resources/en/operationProfiles.ts b/web/src/i18n/resources/en/operationProfiles.ts index 633a125f..1e281360 100644 --- a/web/src/i18n/resources/en/operationProfiles.ts +++ b/web/src/i18n/resources/en/operationProfiles.ts @@ -9,7 +9,10 @@ const enOperationProfiles = { }, runTrace: { title: 'Last run', + emptyTitle: 'Execution details will appear here', empty: 'No runs yet. Send a message in the chat — a report on operation activity will appear here.', + emptyHint: 'The report shows operation order, skips, errors, and committed effects.', + emptyAction: 'Return to chat', noOperations: 'No operations were executed in this run.', runStatus: { running: 'Generating', @@ -84,6 +87,8 @@ const enOperationProfiles = { newBlock: 'New block', }, blockSettingsTitle: 'Block settings', + operationCount_one: '{{count}} operation', + operationCount_other: '{{count}} operations', blockName: 'Block name', blockEnabled: 'Block enabled', selectBlock: 'Select block', @@ -100,9 +105,13 @@ const enOperationProfiles = { deleteBlock: 'Delete block', exportBlock: 'Export block', importBlocks: 'Import blocks', + moveBlockUp: 'Move block up', + moveBlockDown: 'Move block down', + removeBlockRef: 'Remove block from profile', }, }, actions: { + more: 'More actions', openNodeEditor: 'Open Node Editor', discard: 'Discard changes', resetSessionId: 'Reset session id', @@ -127,19 +136,35 @@ const enOperationProfiles = { operations: { title: 'Operations', visibleOfTotal: '{{visible}} shown of {{total}}', + emptyTitle: 'Add the first operation', empty: 'No operations yet. Add one to start editing.', + dependencies_one: '{{count}} dependency', + dependencies_other: '{{count}} dependencies', }, inspector: { title: 'Operation inspector', noneSelected: 'No operation selected', operationNumber: 'Operation #{{number}}', selectFromList: 'Select an operation from the list to edit.', + backToOperations: 'Back to operations', + }, + status: { + enabled: 'Enabled', + disabled: 'Disabled', }, profileSettings: { title: 'Profile settings', profileName: 'Profile name', profileEnabled: 'Profile enabled', executionMode: 'Execution mode', + executionModeConcurrent: 'Concurrent', + executionModeSequential: 'Sequential', + executionModeConcurrentDescription: + 'Independent operations run at the same time. Dependencies still preserve execution order.', + executionModeSequentialDescription: + 'Operations run one at a time: the next starts after the previous one finishes.', + blockCount_one: '{{count}} block', + blockCount_other: '{{count}} blocks', sessionId: 'Operation profile session id', sessionIdInfo: 'Reset this id if you need a new validation scope and change-grouping scope.', invalidJson: 'Invalid JSON', @@ -455,6 +480,10 @@ const enOperationProfiles = { }, }, filters: { + title: 'Filters', + kindLabel: 'Operation kind', + stateLabel: 'State', + requirementLabel: 'Requirement', allKinds: 'All kinds', searchPlaceholder: 'Search by name, id, or kind', searchAria: 'Search operations', diff --git a/web/src/i18n/resources/ru/drawer.ts b/web/src/i18n/resources/ru/drawer.ts index 20a786bb..cfd6ac80 100644 --- a/web/src/i18n/resources/ru/drawer.ts +++ b/web/src/i18n/resources/ru/drawer.ts @@ -1,9 +1,11 @@ const ruDrawer = { - toggleFullscreen: 'Переключить fullscreen', - togglePlacement: 'Переключить сторону', + toggleFullscreen: 'Переключить полноэкранный режим', + togglePlacement: 'Переместить панель', close: 'Закрыть панель', - enterFullscreen: 'Открыть в fullscreen', - exitFullscreen: 'Выйти из fullscreen', + enterFullscreen: 'Развернуть на весь экран', + exitFullscreen: 'Вернуть в боковую панель', + moveToStart: 'Переместить панель влево', + moveToEnd: 'Переместить панель вправо', }; export default ruDrawer; diff --git a/web/src/i18n/resources/ru/operationProfiles.ts b/web/src/i18n/resources/ru/operationProfiles.ts index f8879c9f..8046ddcc 100644 --- a/web/src/i18n/resources/ru/operationProfiles.ts +++ b/web/src/i18n/resources/ru/operationProfiles.ts @@ -9,7 +9,10 @@ const ruOperationProfiles = { }, runTrace: { title: 'Последний запуск', + emptyTitle: 'Здесь появится ход выполнения', empty: 'Запусков ещё не было. Отправьте сообщение в чате — здесь появится отчёт о работе операций.', + emptyHint: 'Вы увидите порядок операций, пропуски, ошибки и применённые эффекты.', + emptyAction: 'Вернуться в чат', noOperations: 'Операции в этом запуске не выполнялись.', runStatus: { running: 'Идёт генерация', @@ -84,6 +87,10 @@ const ruOperationProfiles = { newBlock: 'Новый блок', }, blockSettingsTitle: 'Настройки блока', + operationCount_one: '{{count}} операция', + operationCount_few: '{{count}} операции', + operationCount_many: '{{count}} операций', + operationCount_other: '{{count}} операций', blockName: 'Название блока', blockEnabled: 'Блок включен', selectBlock: 'Выберите блок', @@ -100,12 +107,16 @@ const ruOperationProfiles = { deleteBlock: 'Удалить блок', exportBlock: 'Экспортировать блок', importBlocks: 'Импортировать блоки', + moveBlockUp: 'Переместить блок выше', + moveBlockDown: 'Переместить блок ниже', + removeBlockRef: 'Убрать блок из профиля', }, }, actions: { - openNodeEditor: 'Открыть Node Editor', + more: 'Другие действия', + openNodeEditor: 'Открыть редактор графа', discard: 'Отменить изменения', - resetSessionId: 'Сбросить session id', + resetSessionId: 'Сбросить идентификатор сессии', createProfile: 'Создать профиль', duplicateProfile: 'Дублировать профиль', deleteProfile: 'Удалить профиль', @@ -127,20 +138,40 @@ const ruOperationProfiles = { operations: { title: 'Операции', visibleOfTotal: '{{visible}} показано из {{total}}', + emptyTitle: 'Добавьте первую операцию', empty: 'Операций пока нет. Добавьте первую, чтобы начать редактирование.', + dependencies_one: '{{count}} зависимость', + dependencies_few: '{{count}} зависимости', + dependencies_many: '{{count}} зависимостей', + dependencies_other: '{{count}} зависимостей', }, inspector: { title: 'Инспектор операции', noneSelected: 'Операция не выбрана', operationNumber: 'Операция #{{number}}', selectFromList: 'Выберите операцию из списка для редактирования.', + backToOperations: 'К операциям', + }, + status: { + enabled: 'Включён', + disabled: 'Выключен', }, profileSettings: { title: 'Настройки профиля', profileName: 'Имя профиля', - profileEnabled: 'Профиль включен', + profileEnabled: 'Профиль включён', executionMode: 'Режим выполнения', - sessionId: 'Session id профиля операций', + executionModeConcurrent: 'Параллельно', + executionModeSequential: 'Последовательно', + executionModeConcurrentDescription: + 'Независимые операции запускаются одновременно. Зависимости сохраняют порядок выполнения.', + executionModeSequentialDescription: + 'Операции запускаются по одной: следующая начнётся после завершения предыдущей.', + blockCount_one: '{{count}} блок', + blockCount_few: '{{count}} блока', + blockCount_many: '{{count}} блоков', + blockCount_other: '{{count}} блоков', + sessionId: 'Идентификатор сессии профиля', sessionIdInfo: 'Сбросьте этот id, если нужен новый scope валидации и группировки изменений.', invalidJson: 'Некорректный JSON', }, @@ -456,6 +487,10 @@ const ruOperationProfiles = { }, }, filters: { + title: 'Фильтры', + kindLabel: 'Тип операции', + stateLabel: 'Состояние', + requirementLabel: 'Обязательность', allKinds: 'Все типы', searchPlaceholder: 'Поиск по имени, id или типу', searchAria: 'Поиск операций', diff --git a/web/src/ui/drawer.tsx b/web/src/ui/drawer.tsx index 077edfd3..1a3c5268 100644 --- a/web/src/ui/drawer.tsx +++ b/web/src/ui/drawer.tsx @@ -83,8 +83,8 @@ export const Drawer = ({ } contentClassName={presentation.shellContentClassName} labels={{ - toggleFullscreen: t('drawer.toggleFullscreen'), - togglePlacement: t('drawer.togglePlacement'), + toggleFullscreen: fullScreen ? t('drawer.exitFullscreen') : t('drawer.enterFullscreen'), + togglePlacement: placement === 'start' ? t('drawer.moveToEnd') : t('drawer.moveToStart'), close: t('drawer.close'), }} > From 147c12874cdb7a6641af20230bc34a1fdfc8e337 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Thu, 16 Jul 2026 02:14:47 +0300 Subject: [PATCH 08/14] feat: redesign LLM provider settings --- server/src/api/llm.api.ts | 61 ++- .../src/services/llm/llm-definitions.test.ts | 33 +- server/src/services/llm/llm-definitions.ts | 40 +- .../services/llm/llm-gateway-adapter.test.ts | 52 +- .../src/services/llm/llm-gateway-adapter.ts | 124 ++++- server/src/services/llm/llm-repository.ts | 60 ++- server/src/services/llm/llm-service.test.ts | 202 ++++++-- server/src/services/llm/llm-service.ts | 80 ++- server/src/services/llm/openrouter-catalog.ts | 146 ++++++ shared/types/llm.ts | 54 +- web/src/api/llm.ts | 39 +- .../llm-provider/llm-connection-editor.tsx | 196 ++++++++ .../llm-provider/llm-disclosure-section.tsx | 36 ++ .../llm-provider/llm-model-picker-dialog.tsx | 185 +++++++ .../llm-provider/llm-model-utils.test.ts | 39 ++ .../features/llm-provider/llm-model-utils.ts | 45 ++ .../llm-provider/llm-preset-manager.tsx | 16 +- .../llm-provider-advanced-config.tsx | 84 +--- .../llm-provider/llm-provider-draft.test.ts | 23 + .../llm-provider/llm-provider-draft.ts | 34 ++ .../llm-provider/llm-provider-panel.tsx | 470 ++++++++++-------- .../llm-provider/llm-token-manager-dialog.tsx | 319 +++++++----- .../openrouter-routing-editor.tsx | 189 +++++++ .../features/llm-provider/provider-picker.tsx | 2 +- .../features/llm-provider/token-manager.tsx | 1 + .../sidebars/settings/preset-controls.tsx | 32 +- web/src/i18n/resources/en/provider.ts | 67 ++- web/src/i18n/resources/en/tokenManager.ts | 45 +- web/src/i18n/resources/ru/provider.ts | 70 ++- web/src/i18n/resources/ru/tokenManager.ts | 45 +- web/src/index.css | 6 + web/src/model/provider/index.ts | 71 ++- web/src/ui/dialog.tsx | 5 +- 33 files changed, 2272 insertions(+), 599 deletions(-) create mode 100644 server/src/services/llm/openrouter-catalog.ts create mode 100644 web/src/features/llm-provider/llm-connection-editor.tsx create mode 100644 web/src/features/llm-provider/llm-disclosure-section.tsx create mode 100644 web/src/features/llm-provider/llm-model-picker-dialog.tsx create mode 100644 web/src/features/llm-provider/llm-model-utils.test.ts create mode 100644 web/src/features/llm-provider/llm-model-utils.ts create mode 100644 web/src/features/llm-provider/llm-provider-draft.test.ts create mode 100644 web/src/features/llm-provider/llm-provider-draft.ts create mode 100644 web/src/features/llm-provider/openrouter-routing-editor.tsx diff --git a/server/src/api/llm.api.ts b/server/src/api/llm.api.ts index 9ccf77b2..1e0ea6d0 100644 --- a/server/src/api/llm.api.ts +++ b/server/src/api/llm.api.ts @@ -15,12 +15,17 @@ import { deleteToken, getProviderConfig, getRuntime, + getRuntimeProviderState, listProviders, listTokens, upsertProviderConfig, updateToken, } from "@services/llm/llm-repository"; -import { checkProviderConnection, getModels } from "@services/llm/llm-service"; +import { + checkProviderConnection, + getModels, + getOpenRouterModelEndpoints, +} from "@services/llm/llm-service"; import { updateLlmRuntime } from "../application/llm/use-cases/update-llm-runtime"; @@ -43,6 +48,10 @@ const runtimePatchSchema = z.object({ activeModel: z.string().min(1).nullable().optional(), }); +const runtimeProviderStateQuerySchema = runtimeQuerySchema.extend({ + providerId: providerIdSchema, +}); + const providerConnectionCheckBodySchema = z.object({ scope: scopeSchema.optional().default("global"), scopeId: z.string().min(1).optional().default("global"), @@ -62,7 +71,7 @@ router.get( })); return { data: { providers } }; - }) + }), ); router.get( @@ -80,7 +89,7 @@ router.get( } return { data: { ...runtime, activeTokenHint } }; - }) + }), ); router.patch( @@ -97,7 +106,16 @@ router.patch( activeModel: body.activeModel, }), }; - }) + }), +); + +router.get( + "/llm/runtime/provider-state", + validate({ query: runtimeProviderStateQuerySchema }), + asyncHandler(async (req: Request) => { + const query = runtimeProviderStateQuerySchema.parse(req.query); + return { data: await getRuntimeProviderState(query) }; + }), ); router.get( @@ -107,7 +125,7 @@ router.get( const providerId = req.params.providerId as LlmProviderId; const config = await getProviderConfig(providerId); return { data: config }; - }) + }), ); router.patch( @@ -126,7 +144,7 @@ router.patch( const saved = await upsertProviderConfig(providerId, parsed); return { data: saved }; - }) + }), ); router.post( @@ -148,7 +166,7 @@ router.post( configOverride: body.config, }), }; - }) + }), ); router.get( @@ -156,11 +174,11 @@ router.get( validate({ query: z.object({ providerId: providerIdSchema }) }), asyncHandler(async (req: Request) => { const providerId = providerIdSchema.parse( - (req.query as unknown as { providerId?: unknown }).providerId + (req.query as unknown as { providerId?: unknown }).providerId, ) as LlmProviderId; const tokens = await listTokens(providerId); return { data: { tokens } }; - }) + }), ); const tokenCreateSchema = z.object({ @@ -180,7 +198,7 @@ router.post( token: body.token, }); return { data: created }; - }) + }), ); const tokenPatchSchema = z @@ -201,7 +219,7 @@ router.patch( const body = req.body as z.infer; await updateToken({ id, name: body.name, token: body.token }); return { data: { success: true } }; - }) + }), ); router.delete( @@ -210,7 +228,7 @@ router.delete( asyncHandler(async (req: Request) => { await deleteToken(String(req.params.id)); return { data: { success: true } }; - }) + }), ); router.get( @@ -250,7 +268,20 @@ router.get( }); return { data: { models } }; - }) + }), +); + +router.get( + "/llm/openrouter/endpoints", + validate({ query: z.object({ modelId: z.string().min(1) }) }), + asyncHandler(async (req: Request) => { + const { modelId } = z + .object({ modelId: z.string().min(1) }) + .parse(req.query); + return { + data: { endpoints: await getOpenRouterModelEndpoints({ modelId }) }, + }; + }), ); // Guardrail: expose only the new endpoints; legacy configs should not be used. @@ -260,9 +291,9 @@ router.all( throw new HttpError( 410, "Legacy endpoint removed", - "LEGACY_ENDPOINT_REMOVED" + "LEGACY_ENDPOINT_REMOVED", ); - }) + }), ); export default router; diff --git a/server/src/services/llm/llm-definitions.test.ts b/server/src/services/llm/llm-definitions.test.ts index 829da670..9145ce56 100644 --- a/server/src/services/llm/llm-definitions.test.ts +++ b/server/src/services/llm/llm-definitions.test.ts @@ -9,6 +9,14 @@ describe("llm-definitions.parseProviderConfig", () => { tokenPolicy: { randomize: true, fallbackOnError: true }, anthropicCache: { enabled: true, depth: 2, ttl: "1h" }, messageNormalization: { enabled: false }, + openRouterRouting: { + strategy: "priority", + providerOrder: ["google-ai-studio", "google-vertex/global"], + allowFallbacks: false, + zdr: true, + dataCollection: "deny", + requireParameters: true, + }, custom: "ok", }); @@ -17,6 +25,14 @@ describe("llm-definitions.parseProviderConfig", () => { tokenPolicy: { randomize: true, fallbackOnError: true }, anthropicCache: { enabled: true, depth: 2, ttl: "1h" }, messageNormalization: { enabled: false }, + openRouterRouting: { + strategy: "priority", + providerOrder: ["google-ai-studio", "google-vertex/global"], + allowFallbacks: false, + zdr: true, + dataCollection: "deny", + requireParameters: true, + }, custom: "ok", }); }); @@ -43,12 +59,25 @@ describe("llm-definitions.parseProviderConfig", () => { expect(() => parseProviderConfig("openrouter", { anthropicCache: { enabled: true, depth: -1, ttl: "1h" }, - }) + }), ).toThrow(); expect(() => parseProviderConfig("openrouter", { anthropicCache: { enabled: true, depth: 0, ttl: "2h" }, - }) + }), + ).toThrow(); + }); + + test("throws for invalid OpenRouter routing preferences", () => { + expect(() => + parseProviderConfig("openrouter", { + openRouterRouting: { strategy: "priority", providerOrder: [] }, + }), + ).toThrow(); + expect(() => + parseProviderConfig("openrouter", { + openRouterRouting: { strategy: "unknown" }, + }), ).toThrow(); }); }); diff --git a/server/src/services/llm/llm-definitions.ts b/server/src/services/llm/llm-definitions.ts index bd22efdc..8d888e96 100644 --- a/server/src/services/llm/llm-definitions.ts +++ b/server/src/services/llm/llm-definitions.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import type { LlmOpenRouterRoutingStrategy } from "@shared/types/llm"; + export type LlmProviderId = "openrouter" | "openai_compatible"; export type LlmAnthropicCacheTtl = "5m" | "1h"; @@ -24,6 +26,36 @@ export const messageNormalizationSchema = z }) .strict(); +export const openRouterRoutingSchema = z + .object({ + strategy: z.enum([ + "auto", + "price", + "throughput", + "latency", + "priority", + "only", + ] satisfies LlmOpenRouterRoutingStrategy[]), + providerOrder: z.array(z.string().trim().min(1)).max(20).optional(), + allowFallbacks: z.boolean().optional(), + zdr: z.boolean().optional(), + dataCollection: z.enum(["allow", "deny"]).optional(), + requireParameters: z.boolean().optional(), + }) + .strict() + .superRefine((value, ctx) => { + if ( + (value.strategy === "priority" || value.strategy === "only") && + (!value.providerOrder || value.providerOrder.length === 0) + ) { + ctx.addIssue({ + code: "custom", + path: ["providerOrder"], + message: "Select at least one OpenRouter endpoint provider", + }); + } + }); + export type LlmProviderUiField = | { key: "baseUrl"; @@ -97,6 +129,7 @@ export const openRouterConfigSchema = z tokenPolicy: tokenPolicySchema.optional(), anthropicCache: anthropicCacheSchema.optional(), messageNormalization: messageNormalizationSchema.optional(), + openRouterRouting: openRouterRoutingSchema.optional(), }) .passthrough(); @@ -112,15 +145,16 @@ export const openAiCompatibleConfigSchema = z }) .passthrough(); -export type OpenAiCompatibleConfig = z.infer; +export type OpenAiCompatibleConfig = z.infer< + typeof openAiCompatibleConfigSchema +>; export function parseProviderConfig( providerId: LlmProviderId, - config: unknown + config: unknown, ): OpenRouterConfig | OpenAiCompatibleConfig { if (providerId === "openrouter") { return openRouterConfigSchema.parse(config ?? {}); } return openAiCompatibleConfigSchema.parse(config ?? {}); } - diff --git a/server/src/services/llm/llm-gateway-adapter.test.ts b/server/src/services/llm/llm-gateway-adapter.test.ts index 4bc7f7f5..3a848915 100644 --- a/server/src/services/llm/llm-gateway-adapter.test.ts +++ b/server/src/services/llm/llm-gateway-adapter.test.ts @@ -31,7 +31,10 @@ describe("llm-gateway-adapter", () => { const openAiCompatibleDefault = resolveGatewayModel({ providerId: "openai_compatible", runtimeModel: undefined, - providerConfig: { baseUrl: "http://localhost:1234/v1", defaultModel: "oa-default" }, + providerConfig: { + baseUrl: "http://localhost:1234/v1", + defaultModel: "oa-default", + }, }); const openAiCompatibleBuiltin = resolveGatewayModel({ providerId: "openai_compatible", @@ -40,7 +43,9 @@ describe("llm-gateway-adapter", () => { }); expect(openRouterDefault).toBe("router-default"); - expect(openRouterBuiltin).toBe("google/gemini-2.0-flash-lite-preview-02-05:free"); + expect(openRouterBuiltin).toBe( + "google/gemini-2.0-flash-lite-preview-02-05:free", + ); expect(openAiCompatibleDefault).toBe("oa-default"); expect(openAiCompatibleBuiltin).toBe("gpt-4o-mini"); }); @@ -214,4 +219,47 @@ describe("llm-gateway-adapter", () => { anthropicCache: { enabled: true, depth: 2, ttl: "1h" }, }); }); + + test("buildGatewayStreamRequest maps OpenRouter routing config to provider payload", () => { + const req = buildGatewayStreamRequest({ + providerId: "openrouter", + token: "tok", + providerConfig: { + openRouterRouting: { + strategy: "priority", + providerOrder: ["google-ai-studio", "google-vertex/global"], + allowFallbacks: false, + zdr: true, + dataCollection: "deny", + requireParameters: true, + }, + }, + runtimeModel: "google/gemini-3-flash-preview", + messages: [{ role: "user", content: "hi" }], + settings: {}, + }); + + expect(req.extra).toEqual({ + provider: { + order: ["google-ai-studio", "google-vertex/global"], + allow_fallbacks: false, + zdr: true, + data_collection: "deny", + require_parameters: true, + }, + }); + }); + + test("buildGatewayStreamRequest maps OpenRouter sort strategies", () => { + const req = buildGatewayStreamRequest({ + providerId: "openrouter", + token: "tok", + providerConfig: { openRouterRouting: { strategy: "latency" } }, + runtimeModel: "model-x", + messages: [{ role: "user", content: "hi" }], + settings: {}, + }); + + expect(req.extra).toEqual({ provider: { sort: "latency" } }); + }); }); diff --git a/server/src/services/llm/llm-gateway-adapter.ts b/server/src/services/llm/llm-gateway-adapter.ts index d418ca4c..88c7bc70 100644 --- a/server/src/services/llm/llm-gateway-adapter.ts +++ b/server/src/services/llm/llm-gateway-adapter.ts @@ -5,11 +5,17 @@ import { type LlmProviderId, } from "./llm-definitions"; -import type { LlmGatewayMessage, LlmGatewayRequest, LlmSamplingParams, LlmProviderSpec } from "@core/llm-gateway"; +import type { + LlmGatewayMessage, + LlmGatewayRequest, + LlmSamplingParams, + LlmProviderSpec, +} from "@core/llm-gateway"; import type { GenerateMessage } from "@shared/types/generate"; +import type { LlmOpenRouterRoutingConfig } from "@shared/types/llm"; - -const DEFAULT_OPENROUTER_MODEL = "google/gemini-2.0-flash-lite-preview-02-05:free"; +const DEFAULT_OPENROUTER_MODEL = + "google/gemini-2.0-flash-lite-preview-02-05:free"; const DEFAULT_OPENAI_COMPATIBLE_MODEL = "gpt-4o-mini"; export type MessageNormalizationGatewayFeature = { @@ -41,7 +47,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function pickFirst(settings: Record, keys: string[]): T | undefined { +function pickFirst( + settings: Record, + keys: string[], +): T | undefined { for (const key of keys) { if (Object.prototype.hasOwnProperty.call(settings, key)) { return settings[key] as T; @@ -52,7 +61,9 @@ function pickFirst(settings: Record, keys: string[]): T | un function asStringArray(value: unknown): string[] | null { if (!Array.isArray(value)) return null; - const out = value.filter((v) => typeof v === "string" && v.trim().length > 0) as string[]; + const out = value.filter( + (v) => typeof v === "string" && v.trim().length > 0, + ) as string[]; return out.length > 0 ? out : null; } @@ -77,6 +88,31 @@ function resolveGatewayFeatures(params: { return features; } +function resolveOpenRouterRoutingExtra( + config: unknown, +): Record { + const parsed = openRouterConfigSchema.parse(config ?? {}); + const routing = parsed.openRouterRouting as + | LlmOpenRouterRoutingConfig + | undefined; + if (!routing) return {}; + + const provider: Record = {}; + if (["price", "throughput", "latency"].includes(routing.strategy)) { + provider.sort = routing.strategy; + } else if (routing.strategy === "priority") { + provider.order = routing.providerOrder; + } else if (routing.strategy === "only") { + provider.only = routing.providerOrder; + } + if (typeof routing.allowFallbacks === "boolean") + provider.allow_fallbacks = routing.allowFallbacks; + if (routing.zdr === true) provider.zdr = true; + if (routing.dataCollection) provider.data_collection = routing.dataCollection; + if (routing.requireParameters === true) provider.require_parameters = true; + return Object.keys(provider).length > 0 ? { provider } : {}; +} + export function resolveMessageNormalizationFeature(params: { providerId: LlmProviderId; providerConfig: unknown; @@ -102,11 +138,21 @@ export function resolveGatewayModel(params: { if (params.providerId === "openrouter") { const parsed = openRouterConfigSchema.parse(params.providerConfig ?? {}); - return runtimeModel ?? normalizeNonEmptyString(parsed.defaultModel) ?? DEFAULT_OPENROUTER_MODEL; + return ( + runtimeModel ?? + normalizeNonEmptyString(parsed.defaultModel) ?? + DEFAULT_OPENROUTER_MODEL + ); } - const parsed = openAiCompatibleConfigSchema.parse(params.providerConfig ?? {}); - return runtimeModel ?? normalizeNonEmptyString(parsed.defaultModel) ?? DEFAULT_OPENAI_COMPATIBLE_MODEL; + const parsed = openAiCompatibleConfigSchema.parse( + params.providerConfig ?? {}, + ); + return ( + runtimeModel ?? + normalizeNonEmptyString(parsed.defaultModel) ?? + DEFAULT_OPENAI_COMPATIBLE_MODEL + ); } export function resolveGatewayProviderSpec(params: { @@ -118,7 +164,9 @@ export function resolveGatewayProviderSpec(params: { return { id: "openrouter", token: params.token }; } - const parsed = openAiCompatibleConfigSchema.parse(params.providerConfig ?? {}); + const parsed = openAiCompatibleConfigSchema.parse( + params.providerConfig ?? {}, + ); return { id: "openai_compatible", token: params.token, @@ -149,7 +197,8 @@ export function splitSamplingAndExtra(settings: Record): { if (isFiniteNumber(topA)) extra.top_a = topA; const maxTokens = pickFirst(settings, ["max_tokens", "maxTokens"]); - if (isFiniteNumber(maxTokens) && maxTokens > 0) sampling.max_tokens = maxTokens; + if (isFiniteNumber(maxTokens) && maxTokens > 0) + sampling.max_tokens = maxTokens; const stop = pickFirst(settings, ["stop"]); const stopSequences = pickFirst(settings, ["stopSequences"]); @@ -162,35 +211,58 @@ export function splitSamplingAndExtra(settings: Record): { const seed = pickFirst(settings, ["seed"]); if (isFiniteNumber(seed)) sampling.seed = seed; - const presencePenalty = pickFirst(settings, ["presence_penalty", "presencePenalty"]); - if (isFiniteNumber(presencePenalty)) sampling.presence_penalty = presencePenalty; + const presencePenalty = pickFirst(settings, [ + "presence_penalty", + "presencePenalty", + ]); + if (isFiniteNumber(presencePenalty)) + sampling.presence_penalty = presencePenalty; - const frequencyPenalty = pickFirst(settings, ["frequency_penalty", "frequencyPenalty"]); - if (isFiniteNumber(frequencyPenalty)) sampling.frequency_penalty = frequencyPenalty; + const frequencyPenalty = pickFirst(settings, [ + "frequency_penalty", + "frequencyPenalty", + ]); + if (isFiniteNumber(frequencyPenalty)) + sampling.frequency_penalty = frequencyPenalty; - const repetitionPenalty = pickFirst(settings, ["repetition_penalty", "repetitionPenalty"]); - if (isFiniteNumber(repetitionPenalty)) extra.repetition_penalty = repetitionPenalty; + const repetitionPenalty = pickFirst(settings, [ + "repetition_penalty", + "repetitionPenalty", + ]); + if (isFiniteNumber(repetitionPenalty)) + extra.repetition_penalty = repetitionPenalty; const rawReasoning = pickFirst(settings, ["reasoning"]); if (isRecord(rawReasoning)) { const reasoning: Record = {}; - if (typeof rawReasoning.enabled === "boolean") reasoning.enabled = rawReasoning.enabled; - if (typeof rawReasoning.effort === "string" && rawReasoning.effort.trim().length > 0) { + if (typeof rawReasoning.enabled === "boolean") + reasoning.enabled = rawReasoning.enabled; + if ( + typeof rawReasoning.effort === "string" && + rawReasoning.effort.trim().length > 0 + ) { reasoning.effort = rawReasoning.effort; } - if (isFiniteNumber(rawReasoning.max_tokens) && rawReasoning.max_tokens > 0) { + if ( + isFiniteNumber(rawReasoning.max_tokens) && + rawReasoning.max_tokens > 0 + ) { reasoning.max_tokens = rawReasoning.max_tokens; } if (isFiniteNumber(rawReasoning.maxTokens) && rawReasoning.maxTokens > 0) { reasoning.max_tokens = rawReasoning.maxTokens; } - if (typeof rawReasoning.exclude === "boolean") reasoning.exclude = rawReasoning.exclude; + if (typeof rawReasoning.exclude === "boolean") + reasoning.exclude = rawReasoning.exclude; if (Object.keys(reasoning).length > 0) { extra.reasoning = reasoning; } } - const reasoningEffort = pickFirst(settings, ["reasoning_effort", "reasoningEffort"]); + const reasoningEffort = pickFirst(settings, [ + "reasoning_effort", + "reasoningEffort", + ]); if ( !Object.prototype.hasOwnProperty.call(extra, "reasoning") && typeof reasoningEffort === "string" && @@ -260,7 +332,15 @@ export function buildGatewayStreamRequest(params: { providerConfig: params.providerConfig, }); - const { sampling, extra } = splitSamplingAndExtra(params.settings ?? {}); + const { sampling, extra: settingsExtra } = splitSamplingAndExtra( + params.settings ?? {}, + ); + const extra = { + ...settingsExtra, + ...(params.providerId === "openrouter" + ? resolveOpenRouterRoutingExtra(params.providerConfig) + : {}), + }; const features = resolveGatewayFeatures({ providerId: params.providerId, providerConfig: params.providerConfig, diff --git a/server/src/services/llm/llm-repository.ts b/server/src/services/llm/llm-repository.ts index 3e3f1ca1..e1b05141 100644 --- a/server/src/services/llm/llm-repository.ts +++ b/server/src/services/llm/llm-repository.ts @@ -34,6 +34,9 @@ export type LlmTokenListItem = { providerId: LlmProviderId; name: string; tokenHint: string; + createdAt: Date; + updatedAt: Date; + lastUsedAt: Date | null; }; export type ProviderConfigRow = { @@ -66,7 +69,9 @@ function parseConfigJson(raw: string): unknown { try { return JSON.parse(raw) as unknown; } catch (error) { - console.warn("Failed to parse provider config JSON. Falling back to {}", { error }); + console.warn("Failed to parse provider config JSON. Falling back to {}", { + error, + }); return {}; } } @@ -104,8 +109,8 @@ export async function ensureDefaultRuntimeGlobal(): Promise { .where( and( eq(llmRuntimeSettings.scope, "global"), - eq(llmRuntimeSettings.scopeId, "global") - ) + eq(llmRuntimeSettings.scopeId, "global"), + ), ); if (existing.length > 0) return; @@ -134,7 +139,7 @@ export async function listProviders(): Promise< export async function getRuntime( scope: LlmScope, - scopeId: string + scopeId: string, ): Promise { const database = await db(); const rows = await database @@ -143,8 +148,8 @@ export async function getRuntime( .where( and( eq(llmRuntimeSettings.scope, scope), - eq(llmRuntimeSettings.scopeId, scopeId) - ) + eq(llmRuntimeSettings.scopeId, scopeId), + ), ); if (rows[0]) { @@ -179,7 +184,7 @@ export async function getRuntime( } export async function upsertRuntime( - runtime: LlmRuntimeRow + runtime: LlmRuntimeRow, ): Promise { const database = await db(); const ts = nowDate(); @@ -220,8 +225,8 @@ export async function getRuntimeProviderState(params: { and( eq(llmRuntimeProviderState.scope, params.scope), eq(llmRuntimeProviderState.scopeId, params.scopeId), - eq(llmRuntimeProviderState.providerId, params.providerId) - ) + eq(llmRuntimeProviderState.providerId, params.providerId), + ), ); const row = rows[0]; @@ -244,7 +249,7 @@ export async function getRuntimeProviderState(params: { } export async function upsertRuntimeProviderState( - params: LlmRuntimeProviderStateRow + params: LlmRuntimeProviderStateRow, ): Promise { const database = await db(); const ts = nowDate(); @@ -273,7 +278,7 @@ export async function upsertRuntimeProviderState( } export async function getProviderConfig( - providerId: LlmProviderId + providerId: LlmProviderId, ): Promise { const database = await db(); const rows = await database @@ -291,7 +296,7 @@ export async function getProviderConfig( export async function upsertProviderConfig( providerId: LlmProviderId, - config: unknown + config: unknown, ): Promise { const database = await db(); const ts = nowDate(); @@ -315,7 +320,7 @@ export async function upsertProviderConfig( } export async function listTokens( - providerId: LlmProviderId + providerId: LlmProviderId, ): Promise { const database = await db(); const rows = await database @@ -327,6 +332,9 @@ export async function listTokens( providerId: r.providerId as LlmProviderId, name: r.name, tokenHint: r.tokenHint, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + lastUsedAt: r.lastUsedAt, })); } @@ -352,7 +360,15 @@ export async function createToken(params: { lastUsedAt: null, }); - return { id, providerId: params.providerId, name: params.name, tokenHint }; + return { + id, + providerId: params.providerId, + name: params.name, + tokenHint, + createdAt: ts, + updatedAt: ts, + lastUsedAt: null, + }; } export async function updateToken(params: { @@ -377,11 +393,21 @@ export async function updateToken(params: { export async function deleteToken(id: string): Promise { const database = await db(); - await database.delete(llmTokens).where(eq(llmTokens.id, id)); + await database.transaction(async (tx) => { + await tx + .update(llmRuntimeSettings) + .set({ activeTokenId: null, updatedAt: nowDate() }) + .where(eq(llmRuntimeSettings.activeTokenId, id)); + await tx + .update(llmRuntimeProviderState) + .set({ lastTokenId: null, updatedAt: nowDate() }) + .where(eq(llmRuntimeProviderState.lastTokenId, id)); + await tx.delete(llmTokens).where(eq(llmTokens.id, id)); + }); } function classifyDecryptFailure( - error: unknown + error: unknown, ): Extract { const message = error instanceof Error ? error.message : String(error); const normalized = message.trim().toLowerCase(); @@ -399,7 +425,7 @@ function classifyDecryptFailure( } export async function getTokenPlaintextResult( - id: string + id: string, ): Promise { const database = await db(); const rows = await database diff --git a/server/src/services/llm/llm-service.test.ts b/server/src/services/llm/llm-service.test.ts index 408573fb..291d8bbf 100644 --- a/server/src/services/llm/llm-service.test.ts +++ b/server/src/services/llm/llm-service.test.ts @@ -45,6 +45,7 @@ import { __resetTokenTouchThrottleForTests, checkProviderConnection, getModels, + getOpenRouterModelEndpoints, getProvidersForUi, getTokensForUi, streamGlobalChat, @@ -90,10 +91,7 @@ beforeEach(() => { })); mocks.axiosGet.mockResolvedValue({ data: { - data: [ - { id: "m1", name: "Model 1" }, - { id: "m2" }, - ], + data: [{ id: "m1", name: "Model 1" }, { id: "m2" }], }, }); mocks.llmGatewayStream.mockImplementation(async function* () { @@ -136,7 +134,7 @@ describe("llm-service", () => { providerId: "openrouter", scope: "global", scopeId: "global", - }) + }), ).resolves.toEqual([]); }); @@ -148,31 +146,105 @@ describe("llm-service", () => { providerId: "openrouter", scope: "global", scopeId: "global", - }) + }), ).resolves.toEqual([]); }); test("getModels fetches openrouter model list", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + data: { + data: [ + { + id: "m1", + name: "Model 1", + context_length: 128000, + pricing: { prompt: "0.000001", completion: "0.000002" }, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "reasoning"], + created: 123, + }, + { id: "m2" }, + ], + }, + }); const out = await getModels({ providerId: "openrouter", scope: "global", scopeId: "global", }); - expect(mocks.axiosGet).toHaveBeenCalledWith("https://openrouter.ai/api/v1/models", { - headers: { - "HTTP-Referer": "http://localhost:5000", - "X-Title": "TaleSpinner", - Authorization: "Bearer secret", + expect(mocks.axiosGet).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/models", + { + headers: { + "HTTP-Referer": "http://localhost:5000", + "X-Title": "TaleSpinner", + Authorization: "Bearer secret", + }, + timeout: 7000, }, - timeout: 7000, - }); + ); expect(out).toEqual([ - { id: "m1", name: "Model 1" }, + { + id: "m1", + name: "Model 1", + contextLength: 128000, + pricing: { prompt: "0.000001", completion: "0.000002" }, + inputModalities: ["text", "image"], + outputModalities: ["text"], + supportedParameters: ["tools", "reasoning"], + createdAt: 123, + }, { id: "m2", name: "m2" }, ]); }); + test("getOpenRouterModelEndpoints maps endpoint routing metadata", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + data: { + data: { + endpoints: [ + { + name: "Google AI Studio | model", + provider_name: "Google AI Studio", + tag: "google-ai-studio", + context_length: 1048576, + max_completion_tokens: 65536, + quantization: "unknown", + pricing: { prompt: "0.0000005", completion: "0.000003" }, + supported_parameters: ["tools", "reasoning"], + uptime_last_30m: 99.8, + }, + ], + }, + }, + }); + + await expect( + getOpenRouterModelEndpoints({ modelId: "google/gemini-3-flash-preview" }), + ).resolves.toEqual([ + { + name: "Google AI Studio | model", + providerName: "Google AI Studio", + tag: "google-ai-studio", + contextLength: 1048576, + maxCompletionTokens: 65536, + quantization: "unknown", + pricing: { prompt: "0.0000005", completion: "0.000003" }, + supportedParameters: ["tools", "reasoning"], + uptimeLast30m: 99.8, + }, + ]); + + expect(mocks.axiosGet).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/models/google/gemini-3-flash-preview/endpoints", + { timeout: 7000 }, + ); + }); + test("getModels fetches openai-compatible model list via resolved baseUrl", async () => { const out = await getModels({ providerId: "openai_compatible", @@ -185,12 +257,15 @@ describe("llm-service", () => { token: "secret", providerConfig: {}, }); - expect(mocks.axiosGet).toHaveBeenCalledWith("http://localhost:1234/v1/models", { - headers: { - Authorization: "Bearer secret", + expect(mocks.axiosGet).toHaveBeenCalledWith( + "http://localhost:1234/v1/models", + { + headers: { + Authorization: "Bearer secret", + }, + timeout: 7000, }, - timeout: 7000, - }); + ); expect(out).toEqual([ { id: "m1", name: "Model 1" }, { id: "m2", name: "m2" }, @@ -207,7 +282,7 @@ describe("llm-service", () => { providerId: "openrouter", scope: "global", scopeId: "global", - }) + }), ).resolves.toEqual([]); expect(mocks.axiosGet).toHaveBeenCalledTimes(2); }); @@ -246,7 +321,9 @@ describe("llm-service", () => { checkedUrl: "http://localhost:1234/v1/models", statusCode: 404, }); - expect(result.hints).toContain("For OpenAI-compatible backends the Base URL usually ends with /v1."); + expect(result.hints).toContain( + "For OpenAI-compatible backends the Base URL usually ends with /v1.", + ); }); test("checkProviderConnection returns success payload with model count", async () => { @@ -285,13 +362,16 @@ describe("llm-service", () => { const error = await iter.next().then( () => null, - (err) => err + (err) => err, ); expect(error).toBeInstanceOf(HttpError); expect(error).toMatchObject({ code: "LLM_TOKEN_MISSING", }); - await expect(iter.next()).resolves.toEqual({ value: undefined, done: true }); + await expect(iter.next()).resolves.toEqual({ + value: undefined, + done: true, + }); }); test("streamGlobalChat throws HttpError when active token is not found", async () => { @@ -321,14 +401,20 @@ describe("llm-service", () => { await expect(iter.next()).rejects.toMatchObject({ code: "LLM_TOKEN_NOT_FOUND", - message: "Active token cannot be decrypted with current TOKENS_MASTER_KEY", + message: + "Active token cannot be decrypted with current TOKENS_MASTER_KEY", }); }); test("streamGlobalChat falls back to next token when pre-stream error occurs", async () => { mocks.listTokens.mockResolvedValueOnce([ { id: "tok-1", providerId: "openrouter", name: "main", tokenHint: "***" }, - { id: "tok-2", providerId: "openrouter", name: "backup", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup", + tokenHint: "***", + }, ]); mocks.getProviderConfig.mockResolvedValueOnce({ providerId: "openrouter", @@ -352,24 +438,31 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); - expect(out).toEqual([{ content: "ok-from-second", reasoning: "", error: null }]); + expect(out).toEqual([ + { content: "ok-from-second", reasoning: "", error: null }, + ]); expect(mocks.buildGatewayStreamRequest).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ token: "secret-1" }) + expect.objectContaining({ token: "secret-1" }), ); expect(mocks.buildGatewayStreamRequest).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ token: "secret-2" }) + expect.objectContaining({ token: "secret-2" }), ); }); test("streamGlobalChat does not fallback when error happens after first chunk", async () => { mocks.listTokens.mockResolvedValueOnce([ { id: "tok-1", providerId: "openrouter", name: "main", tokenHint: "***" }, - { id: "tok-2", providerId: "openrouter", name: "backup", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup", + tokenHint: "***", + }, ]); mocks.getProviderConfig.mockResolvedValueOnce({ providerId: "openrouter", @@ -389,7 +482,7 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); expect(out).toEqual([ @@ -403,8 +496,18 @@ describe("llm-service", () => { const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); mocks.listTokens.mockResolvedValueOnce([ { id: "tok-1", providerId: "openrouter", name: "main", tokenHint: "***" }, - { id: "tok-2", providerId: "openrouter", name: "backup-1", tokenHint: "***" }, - { id: "tok-3", providerId: "openrouter", name: "backup-2", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup-1", + tokenHint: "***", + }, + { + id: "tok-3", + providerId: "openrouter", + name: "backup-2", + tokenHint: "***", + }, ]); mocks.getProviderConfig.mockResolvedValueOnce({ providerId: "openrouter", @@ -422,12 +525,12 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); expect(mocks.buildGatewayStreamRequest).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ token: "secret-tok-2" }) + expect.objectContaining({ token: "secret-tok-2" }), ); randomSpy.mockRestore(); }); @@ -441,7 +544,12 @@ describe("llm-service", () => { activeModel: null, }); mocks.listTokens.mockResolvedValueOnce([ - { id: "tok-2", providerId: "openrouter", name: "backup", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup", + tokenHint: "***", + }, ]); mocks.getTokenPlaintextResult.mockImplementation(async (id: string) => { if (id === "tok-2") return { status: "ok", plaintext: "secret-2" }; @@ -456,10 +564,12 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); - expect(out).toEqual([{ content: "from-backup", reasoning: "", error: null }]); + expect(out).toEqual([ + { content: "from-backup", reasoning: "", error: null }, + ]); }); test("streamGlobalChat yields delta/reasoning/error events and stops on error", async () => { @@ -474,7 +584,7 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: { temperature: 0.5 }, - }) + }), ); expect(mocks.touchTokenLastUsed).toHaveBeenCalledWith("tok-1"); @@ -502,7 +612,11 @@ describe("llm-service", () => { }); const ac = new AbortController(); - const received: Array<{ content: string; reasoning: string; error: string | null }> = []; + const received: Array<{ + content: string; + reasoning: string; + error: string | null; + }> = []; for await (const evt of streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, @@ -512,7 +626,9 @@ describe("llm-service", () => { ac.abort(); } - expect(received).toEqual([{ content: "first", reasoning: "", error: null }]); + expect(received).toEqual([ + { content: "first", reasoning: "", error: null }, + ]); }); test("streamGlobalChat throttles touchTokenLastUsed for repeated immediate calls", async () => { @@ -524,13 +640,13 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "first" }], settings: {}, - }) + }), ); await collect( streamGlobalChat({ messages: [{ role: "user", content: "second" }], settings: {}, - }) + }), ); expect(mocks.touchTokenLastUsed).toHaveBeenCalledTimes(1); diff --git a/server/src/services/llm/llm-service.ts b/server/src/services/llm/llm-service.ts index 20165bcf..7fc8b7ce 100644 --- a/server/src/services/llm/llm-service.ts +++ b/server/src/services/llm/llm-service.ts @@ -22,13 +22,21 @@ import { type LlmRuntimeRow, type LlmScope, } from "./llm-repository"; +import { + listOpenRouterModelEndpoints, + listOpenRouterModels, +} from "./openrouter-catalog"; import type { GenerateMessage } from "@shared/types/generate"; -import type { LlmProviderConnectionCheckResult } from "@shared/types/llm"; +import type { + LlmModel, + LlmOpenRouterEndpoint, + LlmProviderConnectionCheckResult, +} from "@shared/types/llm"; export async function getRuntimeOrThrow( scope: LlmScope, - scopeId: string + scopeId: string, ): Promise { return getRuntime(scope, scopeId); } @@ -40,7 +48,7 @@ export async function getProvidersForUi(): Promise< } export async function getTokensForUi( - providerId: LlmProviderId + providerId: LlmProviderId, ): Promise> { const tokens = await listTokens(providerId); return tokens.map((t) => ({ @@ -60,7 +68,10 @@ const MODELS_REQUEST_RETRIES = 1; const TOKEN_LAST_USED_TOUCH_INTERVAL_MS = 60_000; const tokenLastTouchedAt = new Map(); -function resolveTokenPolicy(providerId: LlmProviderId, config: unknown): TokenPolicy { +function resolveTokenPolicy( + providerId: LlmProviderId, + config: unknown, +): TokenPolicy { if (providerId === "openrouter") { const parsed = openRouterConfigSchema.safeParse(config ?? {}); const policy = parsed.success ? parsed.data.tokenPolicy : undefined; @@ -124,7 +135,7 @@ function describeTokenLookupFailure(params: { async function fetchModelsWithRetry( url: string, - headers: Record + headers: Record, ): Promise> { let attempt = 0; let lastError: unknown = null; @@ -135,7 +146,10 @@ async function fetchModelsWithRetry( headers, timeout: MODELS_REQUEST_TIMEOUT_MS, }); - return (response.data?.data ?? []) as Array<{ id: string; name?: string }>; + return (response.data?.data ?? []) as Array<{ + id: string; + name?: string; + }>; } catch (error) { lastError = error; if (attempt === MODELS_REQUEST_RETRIES) { @@ -149,7 +163,7 @@ async function fetchModelsWithRetry( } function buildConnectionCheckResult( - params: LlmProviderConnectionCheckResult + params: LlmProviderConnectionCheckResult, ): LlmProviderConnectionCheckResult { return params; } @@ -157,7 +171,8 @@ function buildConnectionCheckResult( function readProviderErrorStatus(error: unknown): number | null { if (!error || typeof error !== "object") return null; const response = (error as { response?: { status?: unknown } }).response; - return typeof response?.status === "number" && Number.isFinite(response.status) + return typeof response?.status === "number" && + Number.isFinite(response.status) ? response.status : null; } @@ -174,7 +189,9 @@ function readProviderErrorMessage(error: unknown): string | null { } if (!error || typeof error !== "object") return null; const message = (error as { message?: unknown }).message; - return typeof message === "string" && message.trim().length > 0 ? message : null; + return typeof message === "string" && message.trim().length > 0 + ? message + : null; } function buildProviderConnectivityFailure(params: { @@ -299,7 +316,7 @@ export async function getModels(params: { scopeId: string; tokenId?: string | null; modelOverride?: string | null; -}): Promise> { +}): Promise { const runtime = await getRuntime(params.scope, params.scopeId); const tokenId = params.tokenId ?? runtime.activeTokenId; if (!tokenId) { @@ -315,14 +332,7 @@ export async function getModels(params: { const config = await getProviderConfig(params.providerId); try { if (params.providerId === "openrouter") { - const raw = await fetchModelsWithRetry("https://openrouter.ai/api/v1/models", { - "HTTP-Referer": "http://localhost:5000", - "X-Title": "TaleSpinner", - Authorization: `Bearer ${token}`, - }); - return raw - .filter((m) => typeof m?.id === "string" && m.id.length > 0) - .map((m) => ({ id: m.id, name: m.name ?? m.id })); + return await listOpenRouterModels(token); } const providerSpec = resolveGatewayProviderSpec({ @@ -349,6 +359,12 @@ export async function getModels(params: { } } +export async function getOpenRouterModelEndpoints(params: { + modelId: string; +}): Promise { + return listOpenRouterModelEndpoints(params.modelId); +} + export async function checkProviderConnection(params: { providerId: LlmProviderId; scope: LlmScope; @@ -366,7 +382,9 @@ export async function checkProviderConnection(params: { resolvedBaseUrl: null, issueCode: "TOKEN_MISSING", message: "Select a token before checking provider connectivity.", - hints: ["Open token manager or choose an existing token in the provider runtime section."], + hints: [ + "Open token manager or choose an existing token in the provider runtime section.", + ], }); } @@ -379,7 +397,9 @@ export async function checkProviderConnection(params: { issueCode: "TOKEN_DECRYPT_FAILED", message: "Selected token cannot be decrypted with the current TOKENS_MASTER_KEY.", - hints: ["Re-save the token with the current backend key or restore the original TOKENS_MASTER_KEY."], + hints: [ + "Re-save the token with the current backend key or restore the original TOKENS_MASTER_KEY.", + ], }); } @@ -390,7 +410,9 @@ export async function checkProviderConnection(params: { resolvedBaseUrl: null, issueCode: "TOKEN_NOT_FOUND", message: "Selected token was not found.", - hints: ["Pick another token or recreate the missing token in token manager."], + hints: [ + "Pick another token or recreate the missing token in token manager.", + ], }); } @@ -472,7 +494,7 @@ export async function checkProviderConnection(params: { ? (response.data.data as Array<{ id?: unknown; name?: unknown }>) : []; const modelCount = rawModels.filter( - (item) => typeof item?.id === "string" && item.id.length > 0 + (item) => typeof item?.id === "string" && item.id.length > 0, ).length; return buildConnectionCheckResult({ @@ -486,7 +508,9 @@ export async function checkProviderConnection(params: { hints: modelCount > 0 ? [] - : ["The provider responded successfully, but no models were returned for this token."], + : [ + "The provider responded successfully, but no models were returned for this token.", + ], checkedUrl, resolvedBaseUrl, statusCode: @@ -510,7 +534,11 @@ export async function* streamGlobalChat(params: { settings: Record; scopeId?: string; abortController?: AbortController; -}): AsyncGenerator<{ content: string; reasoning: string; error: string | null }> { +}): AsyncGenerator<{ + content: string; + reasoning: string; + error: string | null; +}> { const runtime = await getRuntime("global", params.scopeId ?? "global"); const providerId = runtime.activeProviderId; const config = await getProviderConfig(providerId); @@ -527,7 +555,7 @@ export async function* streamGlobalChat(params: { throw new HttpError( 400, "No active token configured for the selected provider", - "LLM_TOKEN_MISSING" + "LLM_TOKEN_MISSING", ); } @@ -624,6 +652,6 @@ export async function* streamGlobalChat(params: { throw new HttpError( 400, "No active token configured for the selected provider", - "LLM_TOKEN_MISSING" + "LLM_TOKEN_MISSING", ); } diff --git a/server/src/services/llm/openrouter-catalog.ts b/server/src/services/llm/openrouter-catalog.ts new file mode 100644 index 00000000..a919c5bb --- /dev/null +++ b/server/src/services/llm/openrouter-catalog.ts @@ -0,0 +1,146 @@ +import axios from "axios"; + +import type { LlmModel, LlmOpenRouterEndpoint } from "@shared/types/llm"; + +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1"; +const REQUEST_TIMEOUT_MS = 7000; +const REQUEST_RETRIES = 1; + +type RawModel = { + id?: unknown; + name?: unknown; + context_length?: unknown; + pricing?: unknown; + architecture?: unknown; + supported_parameters?: unknown; + created?: unknown; +}; + +type RawEndpoint = { + name?: unknown; + provider_name?: unknown; + tag?: unknown; + context_length?: unknown; + max_completion_tokens?: unknown; + quantization?: unknown; + pricing?: unknown; + supported_parameters?: unknown; + uptime_last_30m?: unknown; +}; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const items = value.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + return items.length > 0 ? items : undefined; +} + +function pricing(value: unknown): LlmModel["pricing"] { + const raw = asRecord(value); + const prompt = optionalString(raw.prompt); + const completion = optionalString(raw.completion); + return prompt || completion ? { prompt, completion } : undefined; +} + +function mapModel(raw: RawModel): LlmModel | null { + const id = optionalString(raw.id); + if (!id) return null; + const architecture = asRecord(raw.architecture); + return { + id, + name: optionalString(raw.name) ?? id, + contextLength: optionalNumber(raw.context_length), + pricing: pricing(raw.pricing), + inputModalities: stringArray(architecture.input_modalities), + outputModalities: stringArray(architecture.output_modalities), + supportedParameters: stringArray(raw.supported_parameters), + createdAt: optionalNumber(raw.created), + }; +} + +function mapEndpoint(raw: RawEndpoint): LlmOpenRouterEndpoint | null { + const tag = optionalString(raw.tag); + if (!tag) return null; + return { + name: optionalString(raw.name) ?? tag, + providerName: optionalString(raw.provider_name) ?? tag, + tag, + contextLength: optionalNumber(raw.context_length), + maxCompletionTokens: optionalNumber(raw.max_completion_tokens), + quantization: optionalString(raw.quantization), + pricing: pricing(raw.pricing), + supportedParameters: stringArray(raw.supported_parameters), + uptimeLast30m: optionalNumber(raw.uptime_last_30m), + }; +} + +async function getWithRetry( + url: string, + headers?: Record, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await axios.get(url, { + ...(headers ? { headers } : {}), + timeout: REQUEST_TIMEOUT_MS, + }); + return response.data; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +export async function listOpenRouterModels(token: string): Promise { + const response = asRecord( + await getWithRetry(`${OPENROUTER_API_URL}/models`, { + "HTTP-Referer": "http://localhost:5000", + "X-Title": "TaleSpinner", + Authorization: `Bearer ${token}`, + }), + ); + const rows = Array.isArray(response.data) + ? (response.data as RawModel[]) + : []; + return rows.map(mapModel).filter((item): item is LlmModel => item !== null); +} + +export async function listOpenRouterModelEndpoints( + modelId: string, +): Promise { + const encodedModel = modelId + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); + const response = asRecord( + await getWithRetry( + `${OPENROUTER_API_URL}/models/${encodedModel}/endpoints`, + ), + ); + const data = asRecord(response.data); + const rows = Array.isArray(data.endpoints) + ? (data.endpoints as RawEndpoint[]) + : []; + return rows + .map(mapEndpoint) + .filter((item): item is LlmOpenRouterEndpoint => item !== null); +} diff --git a/shared/types/llm.ts b/shared/types/llm.ts index a488184b..70106980 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -19,6 +19,23 @@ export type LlmMessageNormalizationConfig = { enabled?: boolean; }; +export type LlmOpenRouterRoutingStrategy = + | "auto" + | "price" + | "throughput" + | "latency" + | "priority" + | "only"; + +export type LlmOpenRouterRoutingConfig = { + strategy: LlmOpenRouterRoutingStrategy; + providerOrder?: string[]; + allowFallbacks?: boolean; + zdr?: boolean; + dataCollection?: "allow" | "deny"; + requireParameters?: boolean; +}; + export type LlmProviderUiField = | { key: "baseUrl"; @@ -53,11 +70,22 @@ export type LlmRuntime = { activeModel: string | null; }; +export type LlmRuntimeProviderState = { + scope: LlmScope; + scopeId: string; + providerId: LlmProviderId; + lastTokenId: string | null; + lastModel: string | null; +}; + export type LlmTokenListItem = { id: string; providerId: LlmProviderId; name: string; tokenHint: string; + createdAt?: string; + updatedAt?: string; + lastUsedAt?: string | null; }; export type LlmProviderConfig = { @@ -66,6 +94,7 @@ export type LlmProviderConfig = { tokenPolicy?: LlmTokenPolicy; anthropicCache?: LlmAnthropicCacheConfig; messageNormalization?: LlmMessageNormalizationConfig; + openRouterRouting?: LlmOpenRouterRoutingConfig; [key: string]: unknown; }; @@ -97,6 +126,30 @@ export type LlmPresetSettings = { export type LlmModel = { id: string; name: string; + contextLength?: number; + pricing?: { + prompt?: string; + completion?: string; + }; + inputModalities?: string[]; + outputModalities?: string[]; + supportedParameters?: string[]; + createdAt?: number; +}; + +export type LlmOpenRouterEndpoint = { + name: string; + providerName: string; + tag: string; + contextLength?: number; + maxCompletionTokens?: number; + quantization?: string; + pricing?: { + prompt?: string; + completion?: string; + }; + supportedParameters?: string[]; + uptimeLast30m?: number; }; export type LlmProviderConnectionIssueCode = @@ -122,4 +175,3 @@ export type LlmProviderConnectionCheckResult = { statusCode: number | null; modelCount: number; }; - diff --git a/web/src/api/llm.ts b/web/src/api/llm.ts index 333fce48..27db3373 100644 --- a/web/src/api/llm.ts +++ b/web/src/api/llm.ts @@ -2,6 +2,7 @@ import { BASE_URL } from '../const'; import type { LlmModel, + LlmOpenRouterEndpoint, LlmPreset, LlmPresetPayload, LlmPresetSettings, @@ -10,6 +11,7 @@ import type { LlmProviderDefinition, LlmProviderId, LlmRuntime, + LlmRuntimeProviderState, LlmScope, LlmTokenListItem, } from '@shared/types/llm'; @@ -61,6 +63,15 @@ export async function patchRuntime(params: { }); } +export async function getRuntimeProviderState(params: { + scope: LlmScope; + scopeId: string; + providerId: LlmProviderId; +}): Promise { + const query = new URLSearchParams(params); + return apiJson(`/llm/runtime/provider-state?${query.toString()}`); +} + export async function getProviderConfig(providerId: LlmProviderId): Promise<{ providerId: LlmProviderId; config: LlmProviderConfig; @@ -90,18 +101,15 @@ export async function checkProviderConnection(params: { tokenId?: string | null; config?: LlmProviderConfig; }): Promise { - return apiJson( - `/llm/providers/${encodeURIComponent(params.providerId)}/check`, - { - method: 'POST', - body: JSON.stringify({ - scope: params.scope, - scopeId: params.scopeId, - tokenId: params.tokenId ?? null, - config: params.config, - }), - }, - ); + return apiJson(`/llm/providers/${encodeURIComponent(params.providerId)}/check`, { + method: 'POST', + body: JSON.stringify({ + scope: params.scope, + scopeId: params.scopeId, + tokenId: params.tokenId ?? null, + config: params.config, + }), + }); } export async function listTokens(providerId: LlmProviderId): Promise { @@ -150,6 +158,13 @@ export async function getModels(params: { return data.models; } +export async function getOpenRouterModelEndpoints(modelId: string): Promise { + const data = await apiJson<{ endpoints: LlmOpenRouterEndpoint[] }>( + `/llm/openrouter/endpoints?modelId=${encodeURIComponent(modelId)}`, + ); + return data.endpoints; +} + export type LlmPresetDto = Omit & { createdAt: string; updatedAt: string; diff --git a/web/src/features/llm-provider/llm-connection-editor.tsx b/web/src/features/llm-provider/llm-connection-editor.tsx new file mode 100644 index 00000000..24c69939 --- /dev/null +++ b/web/src/features/llm-provider/llm-connection-editor.tsx @@ -0,0 +1,196 @@ +import { Button, Group, Input, Select, Stack, Text, TextInput, UnstyledButton } from '@mantine/core'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuKeyRound, LuSearch } from 'react-icons/lu'; + +import { LlmModelPickerDialog } from './llm-model-picker-dialog'; +import { getModelMetadata } from './llm-model-utils'; +import { LlmTokenManagerDialog } from './llm-token-manager-dialog'; + +import type { + LlmModel, + LlmProviderConfig, + LlmProviderDefinition, + LlmProviderId, + LlmTokenListItem, +} from '@shared/types/llm'; + +type Props = { + providers: LlmProviderDefinition[]; + providerId: LlmProviderId; + tokens: LlmTokenListItem[]; + tokenId: string | null; + models: LlmModel[]; + modelId: string | null; + config: LlmProviderConfig; + isLoadingModels: boolean; + isChecking: boolean; + onProviderChange: (providerId: LlmProviderId) => Promise; + onTokenChange: (tokenId: string | null) => Promise; + onModelChange: (modelId: string) => Promise; + onConfigChange: (config: LlmProviderConfig) => void; + onRefreshModels: () => Promise; + onCheckConnection: () => Promise; +}; + +export const LlmConnectionEditor: React.FC = ({ + providers, + providerId, + tokens, + tokenId, + models, + modelId, + config, + isLoadingModels, + isChecking, + onProviderChange, + onTokenChange, + onModelChange, + onConfigChange, + onRefreshModels, + onCheckConnection, +}) => { + const { t } = useTranslation(); + const [tokenManagerOpen, setTokenManagerOpen] = useState(false); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const activeProvider = providers.find((provider) => provider.id === providerId); + const activeModel = models.find((model) => model.id === modelId); + const meta = activeModel ? getModelMetadata(activeModel) : null; + const modelDetails = meta + ? [ + meta.context ? t('provider.modelPicker.context', { value: meta.context }) : null, + meta.inputPrice ? t('provider.modelPicker.inputPrice', { value: meta.inputPrice }) : null, + meta.outputPrice ? t('provider.modelPicker.outputPrice', { value: meta.outputPrice }) : null, + ] + .filter(Boolean) + .join(' · ') + : ''; + const providerOptions = useMemo( + () => providers.filter((item) => item.enabled).map((item) => ({ value: item.id, label: item.name })), + [providers], + ); + const tokenOptions = useMemo( + () => tokens.map((token) => ({ value: token.id, label: `${token.name} · ${token.tokenHint}` })), + [tokens], + ); + + return ( + + {t('provider.connection.title')} + void onTokenChange(value ?? null)} + placeholder={tokens.length ? t('provider.placeholders.selectToken') : t('provider.placeholders.noTokens')} + clearable + searchable + style={{ flex: 1 }} + comboboxProps={{ withinPortal: false }} + /> + + + + + {providerId === 'openai_compatible' ? ( + + void onModelChange(event.currentTarget.value)} + placeholder={t('provider.model.manualCompatiblePlaceholder')} + /> + + + ) : ( + + setModelPickerOpen(true)} + disabled={!tokenId} + aria-label={t('provider.modelPicker.open')} + style={{ + width: '100%', + minHeight: 58, + border: '1px solid var(--mantine-color-default-border)', + borderRadius: 'var(--mantine-radius-md)', + padding: '9px 12px', + opacity: tokenId ? 1 : 0.55, + }} + > + + + + {activeModel?.name ?? modelId ?? t('provider.placeholders.selectModel')} + + {modelId ? ( + + {[modelId, modelDetails].filter(Boolean).join(' · ')} + + ) : null} + + + + + + )} + + + + + + void onTokenChange(value)} + /> + void onModelChange(value)} + showCapabilityFilters={providerId === 'openrouter'} + /> + + ); +}; diff --git a/web/src/features/llm-provider/llm-disclosure-section.tsx b/web/src/features/llm-provider/llm-disclosure-section.tsx new file mode 100644 index 00000000..dbd52fda --- /dev/null +++ b/web/src/features/llm-provider/llm-disclosure-section.tsx @@ -0,0 +1,36 @@ +import { Collapse, Group, Stack, Text, UnstyledButton } from '@mantine/core'; +import { useId, useState } from 'react'; +import { LuChevronDown, LuChevronUp } from 'react-icons/lu'; + +import type { ReactNode } from 'react'; + +type Props = { + title: string; + children: ReactNode; +}; + +export const LlmDisclosureSection: React.FC = ({ title, children }) => { + const [open, setOpen] = useState(false); + const contentId = useId(); + + return ( + + setOpen((value) => !value)} + aria-expanded={open} + aria-controls={contentId} + style={{ width: '100%', padding: '12px 0' }} + > + + {title} + {open ? : } + + + + + {children} + + + + ); +}; diff --git a/web/src/features/llm-provider/llm-model-picker-dialog.tsx b/web/src/features/llm-provider/llm-model-picker-dialog.tsx new file mode 100644 index 00000000..aec2bb5a --- /dev/null +++ b/web/src/features/llm-provider/llm-model-picker-dialog.tsx @@ -0,0 +1,185 @@ +import { ActionIcon, Button, Group, Stack, Text, TextInput, UnstyledButton } from '@mantine/core'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuRefreshCw, LuSearch } from 'react-icons/lu'; + +import { Dialog } from '@ui/dialog'; + +import { filterModels, getModelMetadata, type ModelFilter } from './llm-model-utils'; + +import type { LlmModel } from '@shared/types/llm'; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + models: LlmModel[]; + selectedModel: string | null; + isLoading: boolean; + onRefresh: () => Promise; + onSelect: (modelId: string) => void; + showCapabilityFilters?: boolean; +}; + +const ROW_HEIGHT = 96; + +export const LlmModelPickerDialog: React.FC = ({ + open, + onOpenChange, + models, + selectedModel, + isLoading, + onRefresh, + onSelect, + showCapabilityFilters = true, +}) => { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + const [scrollElement, setScrollElement] = useState(null); + const effectiveFilter = showCapabilityFilters ? filter : 'all'; + const filtered = useMemo(() => filterModels(models, query, effectiveFilter), [effectiveFilter, models, query]); + const exactMatch = models.some((model) => model.id === query.trim()); + const canUseExactId = query.trim().includes('/') && !exactMatch; + const virtualizer = useVirtualizer({ + count: filtered.length, + getScrollElement: () => scrollElement, + estimateSize: () => ROW_HEIGHT, + overscan: 7, + getItemKey: (index) => filtered[index]?.id ?? index, + }); + + const selectModel = (modelId: string) => { + onSelect(modelId); + onOpenChange(false); + }; + + return ( + onOpenChange(false)}> + {t('common.close')} + + } + > + + + } + value={query} + onChange={(event) => setQuery(event.currentTarget.value)} + style={{ flex: 1 }} + autoFocus + /> + void onRefresh()} + aria-label={t('provider.modelPicker.refresh')} + > + + + + + {showCapabilityFilters ? ( + + {(['all', 'free', 'vision', 'reasoning', 'tools'] as const).map((value) => ( + + ))} + + ) : null} + + + + {t('provider.modelPicker.visibleCount', { visible: filtered.length, total: models.length })} + + {canUseExactId ? ( + + ) : null} + + +
+ {filtered.length === 0 ? ( + + {isLoading ? t('provider.modelPicker.loading') : t('provider.modelPicker.empty')} + + ) : ( +
+ {virtualizer.getVirtualItems().map((item) => { + const model = filtered[item.index]; + const meta = getModelMetadata(model); + const metrics = [ + meta.context ? t('provider.modelPicker.context', { value: meta.context }) : null, + meta.inputPrice ? t('provider.modelPicker.inputPrice', { value: meta.inputPrice }) : null, + meta.outputPrice ? t('provider.modelPicker.outputPrice', { value: meta.outputPrice }) : null, + ] + .filter(Boolean) + .join(' · '); + const capabilities = [ + meta.vision ? t('provider.modelPicker.filters.vision') : null, + meta.reasoning ? t('provider.modelPicker.filters.reasoning') : null, + meta.tools ? t('provider.modelPicker.filters.tools') : null, + ] + .filter(Boolean) + .join(' · '); + return ( + selectModel(model.id)} + aria-label={model.name} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: item.size, + transform: `translateY(${item.start}px)`, + padding: '10px 12px', + borderBottom: '1px solid var(--mantine-color-default-border)', + background: selectedModel === model.id ? 'var(--mantine-primary-color-light)' : undefined, + }} + > + + + {model.name} + + + {model.id} + + {metrics ? {metrics} : null} + {capabilities ? ( + + {capabilities} + + ) : null} + + + ); + })} +
+ )} +
+
+
+ ); +}; diff --git a/web/src/features/llm-provider/llm-model-utils.test.ts b/web/src/features/llm-provider/llm-model-utils.test.ts new file mode 100644 index 00000000..7f820404 --- /dev/null +++ b/web/src/features/llm-provider/llm-model-utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; + +import { filterModels, formatContextLength, formatPricePerMillion, getModelMetadata } from './llm-model-utils'; + +import type { LlmModel } from '@shared/types/llm'; + +const models: LlmModel[] = [ + { + id: 'google/gemini:free', + name: 'Gemini Vision', + contextLength: 1_048_576, + pricing: { prompt: '0.0000005', completion: '0.000002' }, + inputModalities: ['text', 'image'], + supportedParameters: ['reasoning', 'tools'], + }, + { id: 'anthropic/claude', name: 'Claude' }, +]; + +describe('llm-model-utils', () => { + test('filters by text and capabilities', () => { + expect(filterModels(models, 'gemini', 'all')).toHaveLength(1); + expect(filterModels(models, '', 'free')).toEqual([models[0]]); + expect(filterModels(models, '', 'vision')).toEqual([models[0]]); + expect(filterModels(models, '', 'tools')).toEqual([models[0]]); + }); + + test('formats compact model metadata', () => { + expect(formatContextLength(1_048_576)).toBe('1M'); + expect(formatPricePerMillion('0.0000005')).toBe('$0.50'); + expect(getModelMetadata(models[0])).toEqual({ + context: '1M', + inputPrice: '$0.50', + outputPrice: '$2.00', + vision: true, + reasoning: true, + tools: true, + }); + }); +}); diff --git a/web/src/features/llm-provider/llm-model-utils.ts b/web/src/features/llm-provider/llm-model-utils.ts new file mode 100644 index 00000000..f24beb23 --- /dev/null +++ b/web/src/features/llm-provider/llm-model-utils.ts @@ -0,0 +1,45 @@ +import type { LlmModel } from '@shared/types/llm'; + +export type ModelFilter = 'all' | 'free' | 'vision' | 'reasoning' | 'tools'; + +function searchableText(model: LlmModel): string { + return `${model.name} ${model.id}`.toLocaleLowerCase(); +} + +export function filterModels(models: LlmModel[], query: string, filter: ModelFilter): LlmModel[] { + const normalizedQuery = query.trim().toLocaleLowerCase(); + return models.filter((model) => { + if (normalizedQuery && !searchableText(model).includes(normalizedQuery)) return false; + if (filter === 'free') return model.id.endsWith(':free'); + if (filter === 'vision') return model.inputModalities?.includes('image') === true; + if (filter === 'reasoning') return model.supportedParameters?.includes('reasoning') === true; + if (filter === 'tools') return model.supportedParameters?.includes('tools') === true; + return true; + }); +} + +export function formatContextLength(value?: number): string | null { + if (!value) return null; + if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`; + if (value >= 1_000) return `${Math.round(value / 1_000)}K`; + return String(value); +} + +export function formatPricePerMillion(value?: string): string | null { + if (!value) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + const perMillion = parsed * 1_000_000; + return `$${perMillion < 0.01 ? perMillion.toFixed(3) : perMillion.toFixed(2)}`; +} + +export function getModelMetadata(model: LlmModel) { + return { + context: formatContextLength(model.contextLength), + inputPrice: formatPricePerMillion(model.pricing?.prompt), + outputPrice: formatPricePerMillion(model.pricing?.completion), + vision: model.inputModalities?.includes('image') === true, + reasoning: model.supportedParameters?.includes('reasoning') === true, + tools: model.supportedParameters?.includes('tools') === true, + }; +} diff --git a/web/src/features/llm-provider/llm-preset-manager.tsx b/web/src/features/llm-provider/llm-preset-manager.tsx index c9629d38..11ce095c 100644 --- a/web/src/features/llm-provider/llm-preset-manager.tsx +++ b/web/src/features/llm-provider/llm-preset-manager.tsx @@ -7,17 +7,23 @@ import { PresetControls } from '../sidebars/settings/preset-controls'; import type { LlmPresetDto, LlmPresetSettingsDto } from '../../api/llm'; import type { LlmPresetPayload } from '@shared/types/llm'; - type Props = { presets: LlmPresetDto[]; presetSettings: LlmPresetSettingsDto | null; hasUnsavedChanges: boolean; buildCurrentPayload: () => LlmPresetPayload; onCreatePreset: (params: { name: string; payload: LlmPresetPayload }) => Promise; - onUpdatePreset: (params: { presetId: string; name?: string; description?: string | null; payload?: LlmPresetPayload }) => Promise; + onUpdatePreset: (params: { + presetId: string; + name?: string; + description?: string | null; + payload?: LlmPresetPayload; + }) => Promise; onDeletePreset: (presetId: string) => Promise<{ id: string }>; onSelectPreset: (presetId: string | null, options?: { skipUnsavedConfirm?: boolean }) => Promise | void; onPatchSettings: (params: { activePresetId?: string | null }) => Promise; + onSaveCurrent?: () => Promise; + showSaveAction?: boolean; }; export const LlmPresetManager: React.FC = ({ @@ -30,6 +36,8 @@ export const LlmPresetManager: React.FC = ({ onDeletePreset, onSelectPreset, onPatchSettings, + onSaveCurrent, + showSaveAction = true, }) => { const { t } = useTranslation(); @@ -58,6 +66,7 @@ export const LlmPresetManager: React.FC = ({ }; const savePreset = async () => { + if (onSaveCurrent) return onSaveCurrent(); if (!activePreset) return; try { await onUpdatePreset({ @@ -147,8 +156,9 @@ export const LlmPresetManager: React.FC = ({ onDelete={() => void deletePreset()} disableRename={!activePreset} disableDuplicate={!activePreset} - disableSave={!activePreset || !hasUnsavedChanges} + disableSave={!hasUnsavedChanges || (!onSaveCurrent && !activePreset)} disableDelete={!activePreset} + showSaveAction={showSaveAction} /> ); }; diff --git a/web/src/features/llm-provider/llm-provider-advanced-config.tsx b/web/src/features/llm-provider/llm-provider-advanced-config.tsx index fb7ebba1..01345e0b 100644 --- a/web/src/features/llm-provider/llm-provider-advanced-config.tsx +++ b/web/src/features/llm-provider/llm-provider-advanced-config.tsx @@ -1,16 +1,11 @@ -import { Alert, Button, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core'; +import { Select, Stack, Switch, Text, TextInput } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import type { LlmProviderConfig, LlmProviderConnectionCheckResult, LlmProviderId } from '@shared/types/llm'; +import type { LlmProviderConfig } from '@shared/types/llm'; type Props = { - activeProviderId: LlmProviderId; configDraft: LlmProviderConfig; onChange: (next: LlmProviderConfig) => void; - onSave: () => Promise; - onCheckConnection: () => Promise; - isCheckingConnection: boolean; - connectionCheckResult: LlmProviderConnectionCheckResult | null; }; const TTL_OPTIONS = [ @@ -18,15 +13,7 @@ const TTL_OPTIONS = [ { value: '1h', label: '1h' }, ]; -export const LlmProviderAdvancedConfig: React.FC = ({ - activeProviderId, - configDraft, - onChange, - onSave, - onCheckConnection, - isCheckingConnection, - connectionCheckResult, -}) => { +export const LlmProviderAdvancedConfig: React.FC = ({ configDraft, onChange }) => { const { t } = useTranslation(); const tokenPolicy = configDraft.tokenPolicy ?? {}; @@ -64,25 +51,7 @@ export const LlmProviderAdvancedConfig: React.FC = ({ }; return ( - - {t('provider.config.title')} - - {activeProviderId === 'openai_compatible' && ( - onChange({ ...configDraft, baseUrl: event.currentTarget.value })} - placeholder="http://localhost:1234/v1" - /> - )} - - onChange({ ...configDraft, defaultModel: event.currentTarget.value })} - placeholder="gpt-4o-mini" - /> - + {t('provider.config.tokenPolicy.title')} @@ -108,9 +77,6 @@ export const LlmProviderAdvancedConfig: React.FC = ({ onChange={(event) => updateMessageNormalization({ enabled: event.currentTarget.checked })} label={t('provider.config.messageNormalization.enabled')} /> - - {t('provider.config.messageNormalization.helpText')} - @@ -145,51 +111,9 @@ export const LlmProviderAdvancedConfig: React.FC = ({ allowDeselect={false} comboboxProps={{ withinPortal: false }} /> - - {t('provider.config.anthropicCache.helpText')} - )} - - - - - - - - {t('provider.config.checkConnectionHelp')} - - - {connectionCheckResult ? ( - - - {connectionCheckResult.message} - {connectionCheckResult.checkedUrl ? ( - - {t('provider.config.checkedEndpoint')}: {connectionCheckResult.checkedUrl} - - ) : null} - {connectionCheckResult.hints.map((hint, index) => ( - - • {hint} - - ))} - - - ) : null} ); }; diff --git a/web/src/features/llm-provider/llm-provider-draft.test.ts b/web/src/features/llm-provider/llm-provider-draft.test.ts new file mode 100644 index 00000000..4f6060f5 --- /dev/null +++ b/web/src/features/llm-provider/llm-provider-draft.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; + +import { createProviderDraft } from './llm-provider-draft'; + +describe('llm-provider-draft', () => { + test('restores the saved token, model, and config for each provider', () => { + const openRouter = createProviderDraft( + 'openrouter', + { openRouterRouting: { strategy: 'price', allowFallbacks: false } }, + { lastTokenId: 'or-token', lastModel: 'google/gemini' }, + ); + const compatible = createProviderDraft( + 'openai_compatible', + { baseUrl: 'http://localhost:1234/v1' }, + { lastTokenId: 'local-token', lastModel: 'local-model' }, + ); + + expect(openRouter).toMatchObject({ tokenId: 'or-token', modelId: 'google/gemini' }); + expect(openRouter.config.openRouterRouting?.strategy).toBe('price'); + expect(compatible).toMatchObject({ tokenId: 'local-token', modelId: 'local-model' }); + expect(compatible.config.baseUrl).toBe('http://localhost:1234/v1'); + }); +}); diff --git a/web/src/features/llm-provider/llm-provider-draft.ts b/web/src/features/llm-provider/llm-provider-draft.ts new file mode 100644 index 00000000..27bba2d0 --- /dev/null +++ b/web/src/features/llm-provider/llm-provider-draft.ts @@ -0,0 +1,34 @@ +import type { LlmProviderConfig, LlmProviderId, LlmRuntimeProviderState } from '@shared/types/llm'; + +export type ProviderConnectionDraft = { + providerId: LlmProviderId; + tokenId: string | null; + modelId: string | null; + config: LlmProviderConfig; +}; + +const DEFAULT_OPENROUTER_CONFIG: LlmProviderConfig = { + openRouterRouting: { strategy: 'auto', allowFallbacks: true }, +}; + +export function normalizeProviderConfig(providerId: LlmProviderId, config?: LlmProviderConfig): LlmProviderConfig { + if (providerId === 'openai_compatible') return { baseUrl: '', ...(config ?? {}) }; + return { + ...DEFAULT_OPENROUTER_CONFIG, + ...(config ?? {}), + openRouterRouting: config?.openRouterRouting ?? DEFAULT_OPENROUTER_CONFIG.openRouterRouting, + }; +} + +export function createProviderDraft( + providerId: LlmProviderId, + config: LlmProviderConfig | undefined, + state: Pick, +): ProviderConnectionDraft { + return { + providerId, + tokenId: state.lastTokenId, + modelId: state.lastModel, + config: normalizeProviderConfig(providerId, config), + }; +} diff --git a/web/src/features/llm-provider/llm-provider-panel.tsx b/web/src/features/llm-provider/llm-provider-panel.tsx index 4f6051e3..f12b6a9c 100644 --- a/web/src/features/llm-provider/llm-provider-panel.tsx +++ b/web/src/features/llm-provider/llm-provider-panel.tsx @@ -1,123 +1,217 @@ -import { Divider, Stack } from '@mantine/core'; +import { Alert, Button, Divider, Group, Stack, Text } from '@mantine/core'; import { useUnit } from 'effector-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { LuRotateCcw } from 'react-icons/lu'; import { llmProviderModel } from '@model/provider'; import { toaster } from '@ui/toaster'; +import { LlmConnectionEditor } from './llm-connection-editor'; +import { LlmDisclosureSection } from './llm-disclosure-section'; import { LlmPresetManager } from './llm-preset-manager'; import { LlmProviderAdvancedConfig } from './llm-provider-advanced-config'; -import { LlmRuntimeSelector } from './llm-runtime-selector'; +import { createProviderDraft, normalizeProviderConfig, type ProviderConnectionDraft } from './llm-provider-draft'; +import { OpenRouterRoutingEditor } from './openrouter-routing-editor'; import type { LlmPresetPayload, LlmProviderConfig, LlmProviderConnectionCheckResult, + LlmRuntime, LlmScope, } from '@shared/types/llm'; -type Props = { - scope: LlmScope; - scopeId: string; - showRuntime?: boolean; - showConfig?: boolean; - showPresets?: boolean; -}; +type Props = { scope: LlmScope; scopeId: string }; + +function toDraft(runtime: LlmRuntime, config?: LlmProviderConfig): ProviderConnectionDraft { + return { + providerId: runtime.activeProviderId, + tokenId: runtime.activeTokenId, + modelId: runtime.activeModel, + config: normalizeProviderConfig(runtime.activeProviderId, config), + }; +} -export const LlmProviderPanel: React.FC = ({ - scope, - scopeId, - showRuntime = true, - showConfig = true, - showPresets = true, -}) => { +export const LlmProviderPanel: React.FC = ({ scope, scopeId }) => { const { t } = useTranslation(); const [ providers, runtimeByKey, tokensByProvider, modelsByKey, - configByProvider, + endpointsByModel, + configs, presets, presetSettings, - mounted, - selectProvider, - selectToken, - selectModel, - openTokenManager, + mount, + loadTokensFx, + loadConfigFx, + loadProviderStateFx, loadModelsFx, - isCheckingProviderConnection, - loadProviderConfigFx, - patchProviderConfigFx, - checkProviderConnectionFx, - createLlmPresetFx, - updateLlmPresetFx, - deleteLlmPresetFx, - applyLlmPresetFx, - patchLlmPresetSettingsFx, + loadEndpointsFx, + checkConnectionFx, + saveConnectionFx, + createPresetFx, + updatePresetFx, + deletePresetFx, + applyPresetFx, + patchPresetSettingsFx, + isLoadingModels, + isLoadingEndpoints, + isChecking, + isSaving, ] = useUnit([ llmProviderModel.$providers, llmProviderModel.$runtimeByScopeKey, llmProviderModel.$tokensByProviderId, llmProviderModel.$modelsByProviderTokenKey, + llmProviderModel.$openRouterEndpointsByModel, llmProviderModel.$providerConfigById, llmProviderModel.$llmPresets, llmProviderModel.$llmPresetSettings, llmProviderModel.providerPickerMounted, - llmProviderModel.providerSelected, - llmProviderModel.tokenSelected, - llmProviderModel.modelSelected, - llmProviderModel.tokenManagerOpened, - llmProviderModel.loadModelsFx, - llmProviderModel.checkProviderConnectionFx.pending, + llmProviderModel.loadTokensFx, llmProviderModel.loadProviderConfigFx, - llmProviderModel.patchProviderConfigFx, + llmProviderModel.loadRuntimeProviderStateFx, + llmProviderModel.loadModelsFx, + llmProviderModel.loadOpenRouterEndpointsFx, llmProviderModel.checkProviderConnectionFx, + llmProviderModel.saveConnectionFx, llmProviderModel.createLlmPresetFx, llmProviderModel.updateLlmPresetFx, llmProviderModel.deleteLlmPresetFx, llmProviderModel.applyLlmPresetFx, llmProviderModel.patchLlmPresetSettingsFx, + llmProviderModel.loadModelsFx.pending, + llmProviderModel.loadOpenRouterEndpointsFx.pending, + llmProviderModel.checkProviderConnectionFx.pending, + llmProviderModel.saveConnectionFx.pending, ]); - const scopeKey = `${scope}:${scopeId}` as const; - const runtime = runtimeByKey[scopeKey]; - const activeProviderId = runtime?.activeProviderId ?? 'openrouter'; - const activeTokenId = runtime?.activeTokenId ?? null; - const activeModel = runtime?.activeModel ?? null; + const form = useForm({ + defaultValues: { + providerId: 'openrouter', + tokenId: null, + modelId: null, + config: normalizeProviderConfig('openrouter'), + }, + }); + const { control, formState, getValues, reset, setValue } = form; + const providerId = useWatch({ control, name: 'providerId' }); + const tokenId = useWatch({ control, name: 'tokenId' }); + const modelId = useWatch({ control, name: 'modelId' }); + const config = useWatch({ control, name: 'config' }); + const runtime = runtimeByKey[`${scope}:${scopeId}`]; + const initializedSignature = useRef(''); + const draftsByProvider = useRef>>({}); + const [connectionResult, setConnectionResult] = useState(null); useEffect(() => { - mounted({ scope, scopeId }); - }, [mounted, scope, scopeId]); + mount({ scope, scopeId }); + }, [mount, scope, scopeId]); + useEffect(() => { + if (!runtime) return; + const runtimeConfig = configs[runtime.activeProviderId]; + const signature = JSON.stringify([runtime, runtimeConfig ?? null]); + if (signature === initializedSignature.current) return; + initializedSignature.current = signature; + const draft = toDraft(runtime, runtimeConfig); + draftsByProvider.current[runtime.activeProviderId] = draft; + reset(draft); + if (runtime.activeProviderId === 'openrouter' && runtime.activeModel) void loadEndpointsFx(runtime.activeModel); + }, [configs, loadEndpointsFx, reset, runtime]); - const providerConfig = useMemo( - () => configByProvider[activeProviderId] ?? {}, - [configByProvider, activeProviderId], - ); - const [configDraft, setConfigDraft] = useState({}); - const [connectionCheckResult, setConnectionCheckResult] = useState(null); + useEffect(() => setConnectionResult(null), [config, modelId, providerId, tokenId]); - useEffect(() => { - if (activeProviderId === 'openai_compatible') { - setConfigDraft({ baseUrl: '', ...providerConfig }); - return; + const tokens = tokensByProvider[providerId] ?? []; + const models = modelsByKey[`${providerId}:${tokenId ?? 'none'}`] ?? []; + const endpoints = modelId ? (endpointsByModel[modelId] ?? []) : []; + const activePresetId = presetSettings?.activePresetId ?? null; + const refreshEndpoints = async (nextModelId: string) => { + try { + await loadEndpointsFx(nextModelId); + } catch { + toaster.warning({ title: t('provider.toasts.endpointsLoadFailed') }); } - setConfigDraft(providerConfig); - }, [providerConfig, activeProviderId]); + }; - useEffect(() => { - setConnectionCheckResult(null); - }, [activeProviderId, activeTokenId, configDraft]); + const buildPayload = (draft = getValues()): LlmPresetPayload => ({ + activeProviderId: draft.providerId, + activeTokenId: draft.tokenId, + activeModel: draft.modelId, + providerConfigsById: { + openrouter: draft.providerId === 'openrouter' ? draft.config : (configs.openrouter ?? {}), + openai_compatible: draft.providerId === 'openai_compatible' ? draft.config : (configs.openai_compatible ?? {}), + }, + }); + + const refreshModels = async () => { + if (!tokenId) return; + const result = await loadModelsFx({ providerId, scope, scopeId, tokenId }); + if (result.models.length === 0) toaster.warning({ title: t('provider.toasts.modelsEmpty') }); + }; + + const changeProvider = async (nextProviderId: ProviderConnectionDraft['providerId']) => { + draftsByProvider.current[providerId] = getValues(); + const cached = draftsByProvider.current[nextProviderId]; + const [loadedConfig, providerState] = await Promise.all([ + loadConfigFx(nextProviderId), + loadProviderStateFx({ scope, scopeId, providerId: nextProviderId }), + loadTokensFx(nextProviderId), + ]); + const nextDraft = cached ?? createProviderDraft(nextProviderId, loadedConfig.config, providerState); + draftsByProvider.current[nextProviderId] = nextDraft; + setValue('providerId', nextProviderId, { shouldDirty: true }); + setValue('tokenId', nextDraft.tokenId, { shouldDirty: true }); + setValue('modelId', nextDraft.modelId, { shouldDirty: true }); + setValue('config', nextDraft.config, { shouldDirty: true }); + if (nextDraft.tokenId) { + await loadModelsFx({ providerId: nextProviderId, scope, scopeId, tokenId: nextDraft.tokenId }); + } + }; - const saveConfig = async () => { + const changeToken = async (nextTokenId: string | null) => { + setValue('tokenId', nextTokenId, { shouldDirty: true }); + if (nextTokenId) await loadModelsFx({ providerId, scope, scopeId, tokenId: nextTokenId }); + }; + + const changeModel = async (nextModelId: string) => { + setValue('modelId', nextModelId, { shouldDirty: true }); + if (providerId === 'openrouter') await refreshEndpoints(nextModelId); + }; + + const validateDraft = (draft: ProviderConnectionDraft): boolean => { + if (!draft.tokenId || !draft.modelId) return false; + const routing = draft.config.openRouterRouting; + if (draft.providerId !== 'openrouter' || !routing) return true; + return !(['priority', 'only'].includes(routing.strategy) && !routing.providerOrder?.length); + }; + + const save = async () => { + const draft = getValues(); + if (!validateDraft(draft)) { + toaster.error({ title: t('provider.toasts.incompleteConnection') }); + return; + } try { - await patchProviderConfigFx({ providerId: activeProviderId, config: configDraft }); - await loadProviderConfigFx(activeProviderId); - toaster.success({ title: t('provider.toasts.configSaved') }); + const payload = buildPayload(draft); + await saveConnectionFx({ + scope, + scopeId, + providerId: draft.providerId, + tokenId: draft.tokenId, + model: draft.modelId, + config: draft.config, + preset: activePresetId ? { presetId: activePresetId, payload } : undefined, + }); + draftsByProvider.current[draft.providerId] = draft; + reset(draft); + toaster.success({ title: t('provider.toasts.connectionSaved') }); } catch (error) { toaster.error({ - title: t('provider.toasts.configSaveFailed'), + title: t('provider.toasts.connectionSaveFailed'), description: error instanceof Error ? error.message : String(error), }); } @@ -125,21 +219,8 @@ export const LlmProviderPanel: React.FC = ({ const checkConnection = async () => { try { - const result = await checkProviderConnectionFx({ - providerId: activeProviderId, - scope, - scopeId, - tokenId: activeTokenId, - config: configDraft, - }); - setConnectionCheckResult(result); - if (result.ok) { - toaster.success({ title: t('provider.toasts.connectionCheckPassed'), description: result.message }); - return; - } - toaster.error({ title: t('provider.toasts.connectionCheckFailed'), description: result.message }); + setConnectionResult(await checkConnectionFx({ providerId, scope, scopeId, tokenId, config })); } catch (error) { - setConnectionCheckResult(null); toaster.error({ title: t('provider.toasts.connectionCheckFailed'), description: error instanceof Error ? error.message : String(error), @@ -147,76 +228,28 @@ export const LlmProviderPanel: React.FC = ({ } }; - const tokens = tokensByProvider[activeProviderId] ?? []; - const modelsKey = `${activeProviderId}:${activeTokenId ?? 'none'}`; - const models = modelsByKey[modelsKey] ?? []; - - const buildCurrentPayload = (): LlmPresetPayload => ({ - activeProviderId, - activeModel: activeModel ?? null, - activeTokenId: activeTokenId ?? null, - providerConfigsById: { - openrouter: configByProvider.openrouter ?? {}, - openai_compatible: configByProvider.openai_compatible ?? {}, - }, - }); - - const activePresetId = presetSettings?.activePresetId ?? null; - const activePreset = presets.find((item) => item.presetId === activePresetId) ?? null; - - const normalizePayload = (payload: LlmPresetPayload): LlmPresetPayload => ({ - activeProviderId: payload.activeProviderId, - activeModel: payload.activeModel ?? null, - activeTokenId: payload.activeTokenId ?? null, - providerConfigsById: { - openrouter: payload.providerConfigsById.openrouter ?? {}, - openai_compatible: payload.providerConfigsById.openai_compatible ?? {}, - }, - }); - - const hasUnsavedPresetChanges = activePreset - ? JSON.stringify(normalizePayload(activePreset.payload)) !== JSON.stringify(normalizePayload(buildCurrentPayload())) - : false; - const normalizeConfigForCompare = (config: LlmProviderConfig): LlmProviderConfig => { - if (activeProviderId === 'openai_compatible') { - return { baseUrl: '', ...config }; - } - return config ?? {}; + const resetChanges = () => { + if (!runtime) return; + const draft = toDraft(runtime, configs[runtime.activeProviderId]); + draftsByProvider.current = { [runtime.activeProviderId]: draft }; + reset(draft); }; - const hasUnsavedConfigDraft = - JSON.stringify(normalizeConfigForCompare(configDraft ?? {})) !== - JSON.stringify(normalizeConfigForCompare(providerConfig ?? {})); - const hasUnsavedChanges = hasUnsavedPresetChanges || hasUnsavedConfigDraft; - const handleSelectPreset = async (presetId: string | null, options?: { skipUnsavedConfirm?: boolean }) => { + const selectPreset = async (presetId: string | null, options?: { skipUnsavedConfirm?: boolean }) => { if (presetId === activePresetId) return; - if (hasUnsavedChanges && !options?.skipUnsavedConfirm) { - if (!window.confirm(t('provider.presets.confirm.discardChanges'))) return; - } - - if (!presetId) { - await patchLlmPresetSettingsFx({ activePresetId: null }); + if ( + formState.isDirty && + !options?.skipUnsavedConfirm && + !window.confirm(t('provider.presets.confirm.discardChanges')) + ) return; - } - + if (!presetId) return void (await patchPresetSettingsFx({ activePresetId: null })); try { - const result = await applyLlmPresetFx({ - presetId, - scope, - scopeId, - }); - - if (result.warnings.length > 0) { - toaster.warning({ - title: t('provider.presets.toasts.appliedWithWarnings'), - description: result.warnings.join('; '), - }); - return; - } - + const result = await applyPresetFx({ presetId, scope, scopeId }); toaster.success({ - title: t('provider.presets.toasts.applied'), - description: result.preset.name, + title: t( + result.warnings.length ? 'provider.presets.toasts.appliedWithWarnings' : 'provider.presets.toasts.applied', + ), }); } catch (error) { toaster.error({ @@ -226,79 +259,92 @@ export const LlmProviderPanel: React.FC = ({ } }; + const canSave = formState.isDirty && validateDraft(getValues()); + const advancedSettings = ( + + setValue('config', next, { shouldDirty: true })} + /> + + ); + return ( - - {showPresets && ( + + createLlmPresetFx(params)} - onUpdatePreset={(params) => updateLlmPresetFx(params)} - onDeletePreset={(presetId) => deleteLlmPresetFx(presetId)} - onSelectPreset={handleSelectPreset} - onPatchSettings={(params) => patchLlmPresetSettingsFx(params)} + hasUnsavedChanges={formState.isDirty} + buildCurrentPayload={() => buildPayload()} + onCreatePreset={(params) => createPresetFx(params)} + onUpdatePreset={(params) => updatePresetFx(params)} + onDeletePreset={(id) => deletePresetFx(id)} + onSelectPreset={selectPreset} + onPatchSettings={(params) => patchPresetSettingsFx(params)} + onSaveCurrent={save} + showSaveAction /> - )} - - {showRuntime && ( - <> - {showPresets ? : null} - selectProvider({ scope, scopeId, providerId })} - onTokenSelect={(tokenId) => selectToken({ scope, scopeId, tokenId })} - onModelSelect={(model) => selectModel({ scope, scopeId, model })} - onLoadModels={async () => { - if (!activeTokenId) return; - try { - const result = await loadModelsFx({ - providerId: activeProviderId, - scope, - scopeId, - tokenId: activeTokenId, - }); - if (result.models.length === 0) { - toaster.warning({ - title: t('provider.toasts.modelsEmpty'), - description: t('provider.toasts.modelsEmptyHelp'), - }); + + setValue('config', next, { shouldDirty: true })} + onRefreshModels={refreshModels} + onCheckConnection={checkConnection} + /> + {providerId === 'openrouter' ? ( + <> + + + { + if (modelId) await refreshEndpoints(modelId); + }} + onChange={(routing) => + setValue('config', { ...config, openRouterRouting: routing }, { shouldDirty: true }) } - } catch (error) { - toaster.error({ - title: t('provider.toasts.modelsLoadFailed'), - description: error instanceof Error ? error.message : String(error), - }); - } - }} - onOpenTokenManager={openTokenManager} - /> - - )} - - {showConfig && ( - <> - {showPresets || showRuntime ? : null} - - - )} - - + /> + {advancedSettings} + + + ) : ( + advancedSettings + )} + {connectionResult ? ( + + {connectionResult.message} + + ) : null} + + + + + + + ); }; diff --git a/web/src/features/llm-provider/llm-token-manager-dialog.tsx b/web/src/features/llm-provider/llm-token-manager-dialog.tsx index fcb1b46c..ba966411 100644 --- a/web/src/features/llm-provider/llm-token-manager-dialog.tsx +++ b/web/src/features/llm-provider/llm-token-manager-dialog.tsx @@ -1,183 +1,266 @@ -import { Button, Divider, Flex, PasswordInput, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Badge, Button, Divider, Group, Menu, PasswordInput, Stack, Text, TextInput } from '@mantine/core'; import { useUnit } from 'effector-react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { LuEllipsis, LuKeyRound, LuPencil, LuPlus, LuTrash2 } from 'react-icons/lu'; import { llmProviderModel } from '@model/provider'; import { Dialog } from '@ui/dialog'; +import { toaster } from '@ui/toaster'; -import type { LlmProviderId } from '@shared/types/llm'; +import type { LlmProviderId, LlmTokenListItem } from '@shared/types/llm'; type Props = { open: boolean; onOpenChange: (open: boolean) => void; providerId: LlmProviderId; + providerName?: string; activeTokenId?: string | null; onTokenSelected?: (tokenId: string | null) => void; }; +type EditorMode = { type: 'list' } | { type: 'create' } | { type: 'edit'; token: LlmTokenListItem }; + export const LlmTokenManagerDialog: React.FC = ({ open, onOpenChange, providerId, + providerName, activeTokenId = null, onTokenSelected, }) => { const { t } = useTranslation(); - const [tokensByProviderId, createTokenFx, patchTokenFx, deleteTokenFx, loadTokensFx] = useUnit([ + const [ + tokensByProviderId, + createTokenFx, + patchTokenFx, + deleteTokenFx, + loadTokensFx, + isCreating, + isPatching, + isDeleting, + ] = useUnit([ llmProviderModel.$tokensByProviderId, llmProviderModel.createTokenFx, llmProviderModel.patchTokenFx, llmProviderModel.deleteTokenFx, llmProviderModel.loadTokensFx, + llmProviderModel.createTokenFx.pending, + llmProviderModel.patchTokenFx.pending, + llmProviderModel.deleteTokenFx.pending, ]); - const tokens = useMemo(() => tokensByProviderId[providerId] ?? [], [providerId, tokensByProviderId]); - const [newName, setNewName] = useState(''); - const [newToken, setNewToken] = useState(''); - const [editingId, setEditingId] = useState(null); - const editing = useMemo(() => tokens.find((item) => item.id === editingId) ?? null, [editingId, tokens]); - const [editName, setEditName] = useState(''); - const [editToken, setEditToken] = useState(''); + const [mode, setMode] = useState({ type: 'list' }); + const [name, setName] = useState(''); + const [tokenValue, setTokenValue] = useState(''); + const [deletingToken, setDeletingToken] = useState(null); - const resetDrafts = () => { - setEditingId(null); - setNewName(''); - setNewToken(''); - setEditName(''); - setEditToken(''); + const resetEditor = () => { + setMode({ type: 'list' }); + setName(''); + setTokenValue(''); + setDeletingToken(null); }; const handleOpenChange = (nextOpen: boolean) => { onOpenChange(nextOpen); - if (nextOpen) return; - resetDrafts(); + if (!nextOpen) resetEditor(); }; - const startEdit = (tokenId: string) => { - const token = tokens.find((item) => item.id === tokenId); - if (!token) return; - setEditingId(tokenId); - setEditName(token.name); - setEditToken(''); + const startCreate = () => { + setName(''); + setTokenValue(''); + setMode({ type: 'create' }); }; - const submitCreate = async () => { - const name = newName.trim(); - const token = newToken.trim(); - if (!name || !token) return; - - const created = await createTokenFx({ providerId, name, token }); - await loadTokensFx(providerId); - onTokenSelected?.(created.id); - setNewName(''); - setNewToken(''); + const startEdit = (token: LlmTokenListItem) => { + setName(token.name); + setTokenValue(''); + setMode({ type: 'edit', token }); }; - const submitEdit = async () => { - if (!editingId) return; - await patchTokenFx({ - id: editingId, - name: editName.trim() || undefined, - token: editToken.trim() || undefined, - }); - await loadTokensFx(providerId); - setEditingId(null); - setEditToken(''); + const submit = async () => { + try { + if (mode.type === 'create') { + const created = await createTokenFx({ providerId, name: name.trim(), token: tokenValue.trim() }); + await loadTokensFx(providerId); + onTokenSelected?.(created.id); + toaster.success({ title: t('tokenManager.toasts.created') }); + } else if (mode.type === 'edit') { + await patchTokenFx({ id: mode.token.id, name: name.trim(), token: tokenValue.trim() || undefined }); + await loadTokensFx(providerId); + toaster.success({ title: t('tokenManager.toasts.saved') }); + } + resetEditor(); + } catch (error) { + toaster.error({ + title: t('tokenManager.toasts.failed'), + description: error instanceof Error ? error.message : String(error), + }); + } }; - const submitDelete = async (tokenId: string) => { - await deleteTokenFx(tokenId); - await loadTokensFx(providerId); - if (tokenId === activeTokenId) { - onTokenSelected?.(null); + const submitDelete = async () => { + if (!deletingToken) return; + try { + await deleteTokenFx(deletingToken.id); + await loadTokensFx(providerId); + if (deletingToken.id === activeTokenId) onTokenSelected?.(null); + toaster.success({ title: t('tokenManager.toasts.deleted') }); + setDeletingToken(null); + } catch (error) { + toaster.error({ + title: t('tokenManager.toasts.failed'), + description: error instanceof Error ? error.message : String(error), + }); } }; + const isSubmitDisabled = !name.trim() || (mode.type === 'create' && !tokenValue.trim()); + const isBusy = isCreating || isPatching || isDeleting; + return ( - - + } - showCloseButton - closeOnEscape - closeOnInteractOutside > - - {t('tokenManager.addToken')} - - setNewName(event.currentTarget.value)} /> - setNewToken(event.currentTarget.value)} - /> - - - - - - - - {t('tokenManager.tokensFor', { providerId })} + - {tokens.length === 0 ? ( - {t('tokenManager.empty')} - ) : ( - - {tokens.map((token) => ( - - - - {token.name} {token.id === activeTokenId ? t('tokenManager.activeSuffix') : ''} - - - {token.tokenHint} - + {tokens.length === 0 ? ( + + + {t('tokenManager.emptyTitle')} + + {t('tokenManager.empty')} + + + + ) : ( + + {tokens.map((token, index) => ( + + + + + + {token.name} + + {token.id === activeTokenId ? ( + + {t('tokenManager.active')} + + ) : null} + + + {token.tokenHint} + + {token.lastUsedAt ? ( + + {t('tokenManager.lastUsed', { value: new Date(token.lastUsedAt).toLocaleString() })} + + ) : null} + + + {token.id !== activeTokenId ? ( + + ) : null} + + + + + + } onClick={() => startEdit(token)}> + {t('common.edit')} + + } onClick={() => setDeletingToken(token)}> + {t('common.delete')} + + + + + + {index < tokens.length - 1 ? : null} - - - - - - ))} - - )} - - {editing ? ( - - {t('tokenManager.editToken')} - setEditName(event.currentTarget.value)} placeholder={t('tokenManager.fields.name')} /> - setEditToken(event.currentTarget.value)} - placeholder={t('tokenManager.fields.newTokenPlaceholder', { hint: editing.tokenHint })} - /> - - - - + + + + ) : null} + + ) : ( + + + {t(mode.type === 'create' ? 'tokenManager.addToken' : 'tokenManager.editToken')} + + {t(mode.type === 'create' ? 'tokenManager.createHint' : 'tokenManager.editHint')} + - ) : null} - + setName(event.currentTarget.value)} + autoFocus + /> + setTokenValue(event.currentTarget.value)} + /> + + + + + + )} ); }; diff --git a/web/src/features/llm-provider/openrouter-routing-editor.tsx b/web/src/features/llm-provider/openrouter-routing-editor.tsx new file mode 100644 index 00000000..61989425 --- /dev/null +++ b/web/src/features/llm-provider/openrouter-routing-editor.tsx @@ -0,0 +1,189 @@ +import { ActionIcon, Button, Group, MultiSelect, Select, Stack, Switch, Text } from '@mantine/core'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuArrowDown, LuArrowUp, LuX } from 'react-icons/lu'; + +import { LlmDisclosureSection } from './llm-disclosure-section'; +import { formatContextLength, formatPricePerMillion } from './llm-model-utils'; + +import type { LlmOpenRouterEndpoint, LlmOpenRouterRoutingConfig } from '@shared/types/llm'; + +type Props = { + modelId: string | null; + value?: LlmOpenRouterRoutingConfig; + endpoints: LlmOpenRouterEndpoint[]; + isLoading: boolean; + onReload: () => Promise; + onChange: (value: LlmOpenRouterRoutingConfig) => void; +}; + +const DEFAULT_ROUTING: LlmOpenRouterRoutingConfig = { strategy: 'auto', allowFallbacks: true }; + +export const OpenRouterRoutingEditor: React.FC = ({ + modelId, + value, + endpoints, + isLoading, + onReload, + onChange, +}) => { + const { t } = useTranslation(); + const routing = value ?? DEFAULT_ROUTING; + const providerOrder = routing.providerOrder ?? []; + const endpointByTag = useMemo(() => new Map(endpoints.map((endpoint) => [endpoint.tag, endpoint])), [endpoints]); + const endpointOptions = useMemo( + () => endpoints.map((endpoint) => ({ value: endpoint.tag, label: `${endpoint.providerName} · ${endpoint.tag}` })), + [endpoints], + ); + const requiresProviders = routing.strategy === 'priority' || routing.strategy === 'only'; + + const patch = (next: Partial) => onChange({ ...routing, ...next }); + const move = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= providerOrder.length) return; + const next = [...providerOrder]; + [next[index], next[target]] = [next[target], next[index]]; + patch({ providerOrder: next }); + }; + + return ( + + {t('provider.routing.title')} + + patch({ dataCollection: dataCollection === 'deny' ? 'deny' : 'allow' })} + allowDeselect={false} + data={[ + { value: 'allow', label: t('provider.routing.dataCollectionAllow') }, + { value: 'deny', label: t('provider.routing.dataCollectionDeny') }, + ]} + comboboxProps={{ withinPortal: false }} + /> + + + ); +}; diff --git a/web/src/features/llm-provider/provider-picker.tsx b/web/src/features/llm-provider/provider-picker.tsx index 66bc1496..e76c0ce1 100644 --- a/web/src/features/llm-provider/provider-picker.tsx +++ b/web/src/features/llm-provider/provider-picker.tsx @@ -8,5 +8,5 @@ type Props = { }; export const ProviderPicker: React.FC = ({ scope, scopeId }) => { - return ; + return ; }; diff --git a/web/src/features/llm-provider/token-manager.tsx b/web/src/features/llm-provider/token-manager.tsx index c52140f9..7be25c4a 100644 --- a/web/src/features/llm-provider/token-manager.tsx +++ b/web/src/features/llm-provider/token-manager.tsx @@ -30,6 +30,7 @@ export const TokenManager: React.FC = ({ providerId, scope, scopeId }) => open={isOpen} onOpenChange={setOpen} providerId={providerId} + providerName={providerId} activeTokenId={activeTokenId} onTokenSelected={(tokenId) => llmProviderModel.tokenSelected({ scope, scopeId, tokenId })} /> diff --git a/web/src/features/sidebars/settings/preset-controls.tsx b/web/src/features/sidebars/settings/preset-controls.tsx index 715b1de5..9416af52 100644 --- a/web/src/features/sidebars/settings/preset-controls.tsx +++ b/web/src/features/sidebars/settings/preset-controls.tsx @@ -38,6 +38,7 @@ type Props = { disableDelete?: boolean; layout?: PresetControlsLayout; extraActions?: ReactNode; + showSaveAction?: boolean; }; export const PresetControls: React.FC = ({ @@ -56,11 +57,18 @@ export const PresetControls: React.FC = ({ disableDelete = false, layout = 'inline', extraActions, + showSaveAction = true, }) => { const actionButtons = ( <> } tooltip={labels.create} aria-label={labels.create} onClick={onCreate} /> - } tooltip={labels.rename} aria-label={labels.rename} onClick={onRename} disabled={disableRename} /> + } + tooltip={labels.rename} + aria-label={labels.rename} + onClick={onRename} + disabled={disableRename} + /> } tooltip={labels.duplicate} @@ -69,15 +77,23 @@ export const PresetControls: React.FC = ({ disabled={disableDuplicate} /> {extraActions} + {showSaveAction ? ( + } + tooltip={labels.save} + aria-label={labels.save} + onClick={onSave} + disabled={disableSave} + variant="solid" + /> + ) : null} } - tooltip={labels.save} - aria-label={labels.save} - onClick={onSave} - disabled={disableSave} - variant="solid" + icon={} + tooltip={labels.delete} + aria-label={labels.delete} + onClick={onDelete} + disabled={disableDelete} /> - } tooltip={labels.delete} aria-label={labels.delete} onClick={onDelete} disabled={disableDelete} /> ); diff --git a/web/src/i18n/resources/en/provider.ts b/web/src/i18n/resources/en/provider.ts index 275b33cf..0134ba62 100644 --- a/web/src/i18n/resources/en/provider.ts +++ b/web/src/i18n/resources/en/provider.ts @@ -11,8 +11,15 @@ const enProvider = { title: 'Tokens', manage: 'Manage tokens', }, + connection: { + title: 'Connection', + description: 'Choose the API, key, and model used by this preset.', + tokenRequired: 'Add or select an API key to load available models.', + modelRequired: 'Select a model to complete the connection.', + }, config: { title: 'Provider configuration', + advancedTitle: 'Advanced settings', baseUrl: 'Base URL', defaultModel: 'Default model (optional)', checkConnection: 'Check connection', @@ -45,9 +52,62 @@ const enProvider = { load: 'Load models', manual: 'Manual model id', manualPlaceholder: 'e.g. anthropic/claude-3.5-sonnet', + manualCompatiblePlaceholder: 'e.g. llama-3.1-8b-instruct', + browseCatalog: 'Choose from API catalog', applyManual: 'Apply', helpText: 'If no model is selected, provider `defaultModel` (if set) or provider default will be used.', }, + modelPicker: { + title: 'Choose model', + open: 'Open model picker', + searchLabel: 'Search', + searchPlaceholder: 'Model name or ID', + refresh: 'Refresh model catalog', + visibleCount: 'Showing {{visible}} of {{total}}', + context: 'Context {{value}}', + inputPrice: 'Input {{value}}/M', + outputPrice: 'Output {{value}}/M', + useExactId: 'Use entered ID', + loading: 'Loading models…', + empty: 'No matching models found', + filters: { + all: 'All', + free: 'Free', + vision: 'Vision', + reasoning: 'Reasoning', + tools: 'Tools', + }, + }, + routing: { + title: 'OpenRouter routing', + strategy: 'Routing mode', + strategies: { + auto: 'OpenRouter default', + price: 'sort: price — lowest price first', + latency: 'sort: latency — lowest latency first', + throughput: 'sort: throughput — highest throughput first', + priority: 'provider.order — explicit order', + only: 'provider.only — selected providers only', + }, + providers: 'Endpoint providers', + refreshProviders: 'Refresh', + providerPlaceholder: 'Select one or more endpoint providers', + selectModelFirst: 'Select a model first', + providerRequired: 'This routing mode requires at least one endpoint provider.', + allowFallbacks: 'Allow fallback to other available endpoints', + privacyTitle: 'Privacy and compatibility', + zdr: 'Use Zero Data Retention endpoints only', + requireParameters: 'Require support for every request parameter', + dataCollection: 'Provider data collection', + dataCollectionAllow: 'Allowed', + dataCollectionDeny: 'Denied', + moveUp: 'Move provider up', + moveDown: 'Move provider down', + }, + actions: { + reset: 'Discard changes', + saveChanges: 'Save changes', + }, presets: { title: 'Connection presets', active: 'Active preset', @@ -77,14 +137,17 @@ const enProvider = { }, }, toasts: { + connectionSaved: 'Connection saved', + connectionSaveFailed: 'Failed to save connection', + incompleteConnection: 'Select a key, model, and required routing providers', configSaved: 'Provider config saved', configSaveFailed: 'Failed to save provider config', connectionCheckPassed: 'Provider check passed', connectionCheckFailed: 'Provider check failed', modelsEmpty: 'Models list is empty', - modelsEmptyHelp: - 'The provider returned no models. Check Base URL, token, and use the connection check button.', + modelsEmptyHelp: 'The provider returned no models. Check Base URL, token, and use the connection check button.', modelsLoadFailed: 'Failed to load models', + endpointsLoadFailed: 'Failed to load model endpoint providers', }, }; diff --git a/web/src/i18n/resources/en/tokenManager.ts b/web/src/i18n/resources/en/tokenManager.ts index 3a8d809e..f581c883 100644 --- a/web/src/i18n/resources/en/tokenManager.ts +++ b/web/src/i18n/resources/en/tokenManager.ts @@ -1,16 +1,33 @@ -const enTokenManager = { - title: 'Token manager', - addToken: 'Add token', - tokensFor: 'Tokens for `{{providerId}}`', - empty: 'No tokens. Add your first token above.', - activeSuffix: '(active)', - editToken: 'Edit token', - fields: { - name: 'Name', - token: 'Token', - newTokenPlaceholder: 'New token (leave empty to keep {{hint}})', - }, - }; +const enTokenManager = { + title: 'Key manager', + titleWithProvider: 'API keys · {{providerName}}', + addToken: 'Add key', + addFirst: 'Add first key', + savedTitle: 'Saved keys', + savedHint: 'Key values are encrypted and cannot be displayed after saving.', + emptyTitle: 'No keys yet', + empty: 'Add an API key to connect models from this provider.', + active: 'Selected', + use: 'Select', + actions: 'Key actions', + lastUsed: 'Last used: {{value}}', + editToken: 'Edit key', + createHint: 'Use a recognizable name such as “Primary” or “Backup”.', + editHint: 'Rename the key or replace its value.', + deleteConfirmTitle: 'Delete API key?', + deleteConfirmText: '“{{name}}” will be deleted. Connections using it will remain without a selected key.', + fields: { + name: 'Name', + token: 'API key', + newToken: 'New API key (optional)', + currentHint: 'Current value: {{hint}}', + }, + toasts: { + created: 'API key added', + saved: 'API key updated', + deleted: 'API key deleted', + failed: 'Failed to change API key', + }, +}; export default enTokenManager; - diff --git a/web/src/i18n/resources/ru/provider.ts b/web/src/i18n/resources/ru/provider.ts index db912e1c..e4f1a41c 100644 --- a/web/src/i18n/resources/ru/provider.ts +++ b/web/src/i18n/resources/ru/provider.ts @@ -11,8 +11,15 @@ const ruProvider = { title: 'Токены', manage: 'Управление токенами', }, + connection: { + title: 'Подключение', + description: 'Выберите API, ключ и модель, которые будут использоваться в этом пресете.', + tokenRequired: 'Добавьте или выберите API-ключ, чтобы загрузить доступные модели.', + modelRequired: 'Выберите модель для завершения настройки подключения.', + }, config: { title: 'Конфигурация провайдера', + advancedTitle: 'Дополнительные настройки', baseUrl: 'Base URL', defaultModel: 'Модель по умолчанию (опционально)', checkConnection: 'Проверить подключение', @@ -29,7 +36,8 @@ const ruProvider = { messageNormalization: { title: 'Нормализация сообщений', enabled: 'Склеивать все system-инструкции в одно сообщение', - helpText: 'Включено по умолчанию для совместимости с провайдерами, которые принимают только одно system-сообщение.', + helpText: + 'Включено по умолчанию для совместимости с провайдерами, которые принимают только одно system-сообщение.', }, anthropicCache: { title: 'Anthropic prompt cache', @@ -45,8 +53,62 @@ const ruProvider = { load: 'Загрузить модели', manual: 'Ручной id модели', manualPlaceholder: 'например anthropic/claude-3.5-sonnet', + manualCompatiblePlaceholder: 'например llama-3.1-8b-instruct', + browseCatalog: 'Выбрать из каталога API', applyManual: 'Применить', - helpText: 'Если модель не выбрана, будет использоваться `defaultModel` провайдера (если задан) или дефолт провайдера.', + helpText: + 'Если модель не выбрана, будет использоваться `defaultModel` провайдера (если задан) или дефолт провайдера.', + }, + modelPicker: { + title: 'Выбор модели', + open: 'Открыть выбор модели', + searchLabel: 'Поиск', + searchPlaceholder: 'Название или ID модели', + refresh: 'Обновить каталог моделей', + visibleCount: 'Показано {{visible}} из {{total}}', + context: 'Контекст {{value}}', + inputPrice: 'Вход {{value}}/M', + outputPrice: 'Выход {{value}}/M', + useExactId: 'Использовать введённый ID', + loading: 'Загружаем модели…', + empty: 'Подходящие модели не найдены', + filters: { + all: 'Все', + free: 'Бесплатные', + vision: 'Vision', + reasoning: 'Reasoning', + tools: 'Tools', + }, + }, + routing: { + title: 'Маршрутизация OpenRouter', + strategy: 'Режим маршрутизации', + strategies: { + auto: 'По умолчанию OpenRouter', + price: 'sort: price — сначала дешевле', + latency: 'sort: latency — сначала ниже задержка', + throughput: 'sort: throughput — сначала выше скорость', + priority: 'provider.order — заданный порядок', + only: 'provider.only — только выбранные', + }, + providers: 'Endpoint-провайдеры', + refreshProviders: 'Обновить', + providerPlaceholder: 'Выберите один или несколько endpoint-провайдеров', + selectModelFirst: 'Сначала выберите модель', + providerRequired: 'Для выбранного режима нужен хотя бы один endpoint-провайдер.', + allowFallbacks: 'Разрешить fallback на другие доступные endpoints', + privacyTitle: 'Приватность и совместимость', + zdr: 'Использовать только Zero Data Retention endpoints', + requireParameters: 'Требовать поддержку всех переданных параметров', + dataCollection: 'Сбор данных провайдерами', + dataCollectionAllow: 'Разрешён', + dataCollectionDeny: 'Запрещён', + moveUp: 'Поднять провайдера', + moveDown: 'Опустить провайдера', + }, + actions: { + reset: 'Отменить изменения', + saveChanges: 'Сохранить изменения', }, presets: { title: 'Пресеты подключения', @@ -77,6 +139,9 @@ const ruProvider = { }, }, toasts: { + connectionSaved: 'Подключение сохранено', + connectionSaveFailed: 'Не удалось сохранить подключение', + incompleteConnection: 'Выберите ключ, модель и необходимые routing-провайдеры', configSaved: 'Конфиг провайдера сохранён', configSaveFailed: 'Не удалось сохранить конфиг провайдера', connectionCheckPassed: 'Проверка провайдера прошла', @@ -85,6 +150,7 @@ const ruProvider = { modelsEmptyHelp: 'Провайдер не вернул ни одной модели. Проверьте Base URL, токен и используйте кнопку «Проверить подключение».', modelsLoadFailed: 'Не удалось загрузить модели', + endpointsLoadFailed: 'Не удалось загрузить endpoint-провайдеров модели', }, }; diff --git a/web/src/i18n/resources/ru/tokenManager.ts b/web/src/i18n/resources/ru/tokenManager.ts index e66c2318..1ee872d2 100644 --- a/web/src/i18n/resources/ru/tokenManager.ts +++ b/web/src/i18n/resources/ru/tokenManager.ts @@ -1,16 +1,33 @@ -const ruTokenManager = { - title: 'Менеджер токенов', - addToken: 'Добавить токен', - tokensFor: 'Токены для `{{providerId}}`', - empty: 'Нет токенов. Добавьте первый токен выше.', - activeSuffix: '(активный)', - editToken: 'Редактировать токен', - fields: { - name: 'Имя', - token: 'Токен', - newTokenPlaceholder: 'Новый токен (оставьте пустым, чтобы сохранить {{hint}})', - }, - }; +const ruTokenManager = { + title: 'Менеджер ключей', + titleWithProvider: 'API-ключи · {{providerName}}', + addToken: 'Добавить ключ', + addFirst: 'Добавить первый ключ', + savedTitle: 'Сохранённые ключи', + savedHint: 'Значения ключей зашифрованы и после сохранения не отображаются.', + emptyTitle: 'Ключей пока нет', + empty: 'Добавьте API-ключ, чтобы подключить модели этого провайдера.', + active: 'Выбран', + use: 'Выбрать', + actions: 'Действия с ключом', + lastUsed: 'Последнее использование: {{value}}', + editToken: 'Редактировать ключ', + createHint: 'Дайте ключу понятное имя — например, «Основной» или «Резервный».', + editHint: 'Можно переименовать ключ или заменить его значение.', + deleteConfirmTitle: 'Удалить API-ключ?', + deleteConfirmText: 'Ключ «{{name}}» будет удалён. Связанные подключения останутся без выбранного ключа.', + fields: { + name: 'Название', + token: 'API-ключ', + newToken: 'Новый API-ключ (опционально)', + currentHint: 'Текущее значение: {{hint}}', + }, + toasts: { + created: 'API-ключ добавлен', + saved: 'API-ключ обновлён', + deleted: 'API-ключ удалён', + failed: 'Не удалось изменить API-ключ', + }, +}; export default ruTokenManager; - diff --git a/web/src/index.css b/web/src/index.css index 16297597..0cb1b934 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -735,6 +735,12 @@ a { padding: 8px 6px; } + .ts-sidebar-container--drawer { + width: 100% !important; + max-width: 100vw; + box-sizing: border-box; + } + .ts-rail-button-wrap[data-active='true']::before { left: -6px; } diff --git a/web/src/model/provider/index.ts b/web/src/model/provider/index.ts index fcd59e69..bfe1e244 100644 --- a/web/src/model/provider/index.ts +++ b/web/src/model/provider/index.ts @@ -4,11 +4,13 @@ import * as llmApi from '../../api/llm'; import type { LlmModel, + LlmOpenRouterEndpoint, LlmPresetPayload, LlmProviderConfig, LlmProviderDefinition, LlmProviderId, LlmRuntime, + LlmRuntimeProviderState, LlmScope, LlmTokenListItem, } from '@shared/types/llm'; @@ -25,6 +27,11 @@ export const loadRuntimeFx = createEffect(async (params: { scope: LlmScope; scop return llmApi.getRuntime(params); }); +export const loadRuntimeProviderStateFx = createEffect( + async (params: { scope: LlmScope; scopeId: string; providerId: LlmProviderId }): Promise => + llmApi.getRuntimeProviderState(params), +); + export const patchRuntimeFx = createEffect( async (params: { scope: LlmScope; @@ -100,6 +107,42 @@ export const loadModelsFx = createEffect( }, ); +export const loadOpenRouterEndpointsFx = createEffect( + async (modelId: string): Promise<{ modelId: string; endpoints: LlmOpenRouterEndpoint[] }> => ({ + modelId, + endpoints: await llmApi.getOpenRouterModelEndpoints(modelId), + }), +); + +export const saveConnectionFx = createEffect( + async (params: { + scope: LlmScope; + scopeId: string; + providerId: LlmProviderId; + tokenId: string | null; + model: string | null; + config: LlmProviderConfig; + preset?: { presetId: string; payload: LlmPresetPayload }; + }) => { + const config = await llmApi.patchProviderConfig(params.providerId, params.config); + const runtime = await llmApi.patchRuntime({ + scope: params.scope, + scopeId: params.scopeId, + activeProviderId: params.providerId, + activeTokenId: params.tokenId, + activeModel: params.model, + }); + const preset = params.preset + ? await llmApi.updateLlmPreset({ + presetId: params.preset.presetId, + ownerId: 'global', + payload: params.preset.payload, + }) + : null; + return { config, runtime, preset }; + }, +); + export const loadLlmPresetsFx = createEffect(async (): Promise => { return llmApi.listLlmPresets('global'); }); @@ -141,7 +184,11 @@ export const deleteLlmPresetFx = createEffect(async (presetId: string): Promise< }); export const applyLlmPresetFx = createEffect( - async (params: { presetId: string; scope: LlmScope; scopeId: string }): Promise<{ + async (params: { + presetId: string; + scope: LlmScope; + scopeId: string; + }): Promise<{ preset: llmApi.LlmPresetDto; runtime: LlmRuntime; warnings: string[]; @@ -179,6 +226,7 @@ export const $tokensByProviderId = createStore, ); export const $modelsByProviderTokenKey = createStore>({}); +export const $openRouterEndpointsByModel = createStore>({}); export const $isTokenManagerOpen = createStore(false); export const $llmPresets = createStore([]); export const $llmPresetSettings = createStore(null); @@ -223,6 +271,23 @@ $runtimeByScopeKey.on(applyLlmPresetFx.doneData, (state, payload) => ({ [toScopeKey(payload.runtime.scope, payload.runtime.scopeId)]: payload.runtime, })); +$openRouterEndpointsByModel.on(loadOpenRouterEndpointsFx.doneData, (state, payload) => ({ + ...state, + [payload.modelId]: payload.endpoints, +})); + +$providerConfigById.on(saveConnectionFx.doneData, (state, payload) => ({ + ...state, + [payload.config.providerId]: payload.config.config, +})); +$runtimeByScopeKey.on(saveConnectionFx.doneData, (state, payload) => ({ + ...state, + [toScopeKey(payload.runtime.scope, payload.runtime.scopeId)]: payload.runtime, +})); +$llmPresets.on(saveConnectionFx.doneData, (state, payload) => + payload.preset ? state.map((item) => (item.presetId === payload.preset?.presetId ? payload.preset : item)) : state, +); + sample({ clock: providerPickerMounted, target: loadProvidersFx, @@ -361,6 +426,7 @@ export const llmProviderModel = { $providerConfigById, $tokensByProviderId, $modelsByProviderTokenKey, + $openRouterEndpointsByModel, $isTokenManagerOpen, $llmPresets, $llmPresetSettings, @@ -373,12 +439,15 @@ export const llmProviderModel = { loadProvidersFx, loadRuntimeFx, + loadRuntimeProviderStateFx, patchRuntimeFx, loadTokensFx, createTokenFx, patchTokenFx, deleteTokenFx, loadModelsFx, + loadOpenRouterEndpointsFx, + saveConnectionFx, loadProviderConfigFx, patchProviderConfigFx, checkProviderConnectionFx, diff --git a/web/src/ui/dialog.tsx b/web/src/ui/dialog.tsx index a5c3be90..06ad0300 100644 --- a/web/src/ui/dialog.tsx +++ b/web/src/ui/dialog.tsx @@ -41,7 +41,9 @@ export const Dialog = ({ const fullScreen = size === 'cover'; const modalSize = fullScreen ? '100%' : size; const drawerLikeFullscreen = fullScreen && typeof fullScreenContentMaxWidth === 'number'; - const fullscreenPresentation = drawerLikeFullscreen ? getFullscreenSidebarPresentation(fullScreenContentMaxWidth) : null; + const fullscreenPresentation = drawerLikeFullscreen + ? getFullscreenSidebarPresentation(fullScreenContentMaxWidth) + : null; const content = (
Date: Fri, 17 Jul 2026 02:49:12 +0300 Subject: [PATCH 09/14] feat: redesign RAG provider settings --- server/src/api/rag.api.test.ts | 12 + server/src/api/rag.api.ts | 20 + server/src/services/rag.service.ts | 12 + .../services/rag/rag-connection-check.test.ts | 100 ++++ .../src/services/rag/rag-connection-check.ts | 132 +++++ shared/types/rag.ts | 19 + web/src/api/rag.ts | 13 +- .../llm-provider/llm-token-manager-dialog.tsx | 12 +- .../settings/rag-connection-editor.tsx | 179 +++++++ .../sidebars/settings/rag-preset-manager.tsx | 119 +++++ .../settings/rag-provider-advanced-config.tsx | 67 +++ .../settings/rag-provider-draft.test.ts | 38 ++ .../sidebars/settings/rag-provider-draft.ts | 42 ++ .../sidebars/settings/rag-settings-tab.tsx | 472 ++++++++---------- web/src/i18n/resources/en/rag.ts | 41 +- web/src/i18n/resources/ru/rag.ts | 41 +- web/src/model/rag-provider/index.ts | 43 +- 17 files changed, 1086 insertions(+), 276 deletions(-) create mode 100644 server/src/services/rag/rag-connection-check.test.ts create mode 100644 server/src/services/rag/rag-connection-check.ts create mode 100644 web/src/features/sidebars/settings/rag-connection-editor.tsx create mode 100644 web/src/features/sidebars/settings/rag-preset-manager.tsx create mode 100644 web/src/features/sidebars/settings/rag-provider-advanced-config.tsx create mode 100644 web/src/features/sidebars/settings/rag-provider-draft.test.ts create mode 100644 web/src/features/sidebars/settings/rag-provider-draft.ts diff --git a/server/src/api/rag.api.test.ts b/server/src/api/rag.api.test.ts index 2f9eefb4..7750756a 100644 --- a/server/src/api/rag.api.test.ts +++ b/server/src/api/rag.api.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'; import { ragEmbeddingsBodySchema, ragModelsQuerySchema, + ragProviderConnectionCheckBodySchema, ragPresetCreateBodySchema, ragPresetSettingsPatchBodySchema, ragPresetUpdateBodySchema, @@ -38,6 +39,17 @@ describe('rag route schemas', () => { expect(ragModelsQuerySchema.safeParse({ providerId: 'bad' }).success).toBe(false); }); + test('connection check accepts a draft token and config', () => { + expect(ragProviderConnectionCheckBodySchema.safeParse({ tokenId: null, config: {} }).success).toBe(true); + expect( + ragProviderConnectionCheckBodySchema.safeParse({ + tokenId: 'token-1', + config: { baseUrl: 'http://localhost:11434' }, + }).success, + ).toBe(true); + expect(ragProviderConnectionCheckBodySchema.safeParse({ tokenId: '' }).success).toBe(false); + }); + test('embeddings schema accepts non-empty string or non-empty array of non-empty strings', () => { expect(ragEmbeddingsBodySchema.safeParse({ input: 'hello' }).success).toBe(true); expect(ragEmbeddingsBodySchema.safeParse({ input: ['one', 'two'] }).success).toBe(true); diff --git a/server/src/api/rag.api.ts b/server/src/api/rag.api.ts index a42dd839..4e62f073 100644 --- a/server/src/api/rag.api.ts +++ b/server/src/api/rag.api.ts @@ -5,6 +5,7 @@ import { asyncHandler } from '@core/middleware/async-handler'; import { HttpError } from '@core/middleware/error-handler'; import { validate } from '@core/middleware/validate'; import { + checkRagProviderConnection, ensureRagPresetState, generateRagEmbedding, getRagProviderConfig, @@ -35,6 +36,10 @@ export const ragModelsQuerySchema = z.object({ providerId: ragProviderIdSchema, tokenId: z.string().min(1).optional(), }); +export const ragProviderConnectionCheckBodySchema = z.object({ + tokenId: z.string().min(1).nullable().optional(), + config: z.record(z.string(), z.unknown()).optional(), +}); export const ragEmbeddingsBodySchema = z.object({ input: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]), }); @@ -65,6 +70,21 @@ router.patch('/rag/providers/:providerId/config', validate({ params: ragProvider return { data: { providerId, config: await patchRagProviderConfig(providerId, req.body) } }; })); +router.post('/rag/providers/:providerId/check', validate({ + params: ragProviderParamsSchema, + body: ragProviderConnectionCheckBodySchema, +}), asyncHandler(async (req: Request) => { + const providerId = req.params.providerId as RagProviderId; + const body = ragProviderConnectionCheckBodySchema.parse(req.body); + return { + data: await checkRagProviderConnection({ + providerId, + tokenId: body.tokenId ?? null, + configOverride: body.config, + }), + }; +})); + router.get('/rag/tokens', validate({ query: ragTokensQuerySchema }), asyncHandler(async (req: Request) => { const providerId = ragProviderIdSchema.parse((req.query as { providerId?: string }).providerId); const tokens = await listRagTokens(providerId); diff --git a/server/src/services/rag.service.ts b/server/src/services/rag.service.ts index 5fd022bb..11f9ba29 100644 --- a/server/src/services/rag.service.ts +++ b/server/src/services/rag.service.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { HttpError } from "@core/middleware/error-handler"; import { getTokenPlaintext, listTokens } from "@services/llm/llm-repository"; +import { probeRagProviderConnection } from "@services/rag/rag-connection-check"; import { safeJsonParse, safeJsonStringify } from "../chat-core/json"; import { initDb } from "../db/client"; @@ -22,6 +23,7 @@ import type { RagPresetPayload, RagPresetSettings, RagProviderConfig, + RagProviderConnectionCheckResult, RagProviderDefinition, RagProviderId, RagRuntime, @@ -700,6 +702,16 @@ export async function listRagModels(params: { } } +export async function checkRagProviderConnection(params: { + providerId: RagProviderId; + tokenId: string | null; + configOverride?: RagProviderConfig; +}): Promise { + const savedConfig = await getRagProviderConfig(params.providerId); + const config = ragConfigSchema.parse({ ...savedConfig, ...(params.configOverride ?? {}) }); + return probeRagProviderConnection({ ...params, config }); +} + export async function getRagRuntime(): Promise { const runtime = ragRuntimeSchema.parse(await ragService.runtime.getConfig()); if (!runtime.activeTokenId || runtime.activeProviderId !== "openrouter") { diff --git a/server/src/services/rag/rag-connection-check.test.ts b/server/src/services/rag/rag-connection-check.test.ts new file mode 100644 index 00000000..e504162e --- /dev/null +++ b/server/src/services/rag/rag-connection-check.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + axiosGet: vi.fn(), + getTokenPlaintext: vi.fn(), +})); + +vi.mock("axios", () => ({ + default: { get: mocks.axiosGet }, +})); + +vi.mock("@services/llm/llm-repository", () => ({ + getTokenPlaintext: mocks.getTokenPlaintext, +})); + +import { probeRagProviderConnection } from "./rag-connection-check"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getTokenPlaintext.mockResolvedValue("secret"); +}); + +describe("RAG provider connection check", () => { + test("requires a token for OpenRouter", async () => { + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: null, + config: {}, + }); + + expect(result).toMatchObject({ ok: false, issueCode: "TOKEN_MISSING" }); + expect(mocks.axiosGet).not.toHaveBeenCalled(); + }); + + test("reports a missing saved token", async () => { + mocks.getTokenPlaintext.mockResolvedValueOnce(null); + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: "missing", + config: {}, + }); + expect(result).toMatchObject({ ok: false, issueCode: "TOKEN_NOT_FOUND" }); + }); + + test("checks the OpenRouter embedding catalog", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + status: 200, + data: { data: [{ id: "model-1" }, { id: "model-2" }] }, + }); + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: "token-1", + config: {}, + }); + + expect(mocks.axiosGet).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/embeddings/models", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer secret" }), + }) + ); + expect(result).toMatchObject({ ok: true, modelCount: 2, statusCode: 200 }); + }); + + test("checks the configured Ollama endpoint", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + status: 200, + data: { models: [{ name: "nomic-embed-text" }] }, + }); + const result = await probeRagProviderConnection({ + providerId: "ollama", + tokenId: null, + config: { baseUrl: "http://127.0.0.1:11434/" }, + }); + + expect(mocks.axiosGet).toHaveBeenCalledWith( + "http://127.0.0.1:11434/api/tags", + { timeout: 7000 } + ); + expect(result).toMatchObject({ ok: true, modelCount: 1 }); + }); + + test("maps provider authentication errors", async () => { + mocks.axiosGet.mockRejectedValueOnce({ + response: { status: 401, data: { error: { message: "Unauthorized" } } }, + }); + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: "token-1", + config: {}, + }); + + expect(result).toMatchObject({ + ok: false, + issueCode: "AUTH_ERROR", + message: "Unauthorized", + statusCode: 401, + }); + }); +}); diff --git a/server/src/services/rag/rag-connection-check.ts b/server/src/services/rag/rag-connection-check.ts new file mode 100644 index 00000000..6683e32a --- /dev/null +++ b/server/src/services/rag/rag-connection-check.ts @@ -0,0 +1,132 @@ +import axios from "axios"; + +import { getTokenPlaintext } from "@services/llm/llm-repository"; + +import type { + RagProviderConfig, + RagProviderConnectionCheckResult, + RagProviderId, +} from "@shared/types/rag"; + +const REQUEST_TIMEOUT_MS = 7000; + +function getErrorDetails(error: unknown): { + statusCode: number | null; + message: string; +} { + if (!error || typeof error !== "object") { + return { statusCode: null, message: String(error) }; + } + const value = error as { + message?: unknown; + response?: { + status?: unknown; + data?: { error?: { message?: unknown }; message?: unknown }; + }; + }; + const providerMessage = + value.response?.data?.error?.message ?? value.response?.data?.message; + return { + statusCode: + typeof value.response?.status === "number" ? value.response.status : null, + message: + typeof providerMessage === "string" + ? providerMessage + : typeof value.message === "string" + ? value.message + : "Provider request failed", + }; +} + +function failure( + providerId: RagProviderId, + checkedUrl: string, + issueCode: NonNullable, + message: string, + statusCode: number | null = null +): RagProviderConnectionCheckResult { + return { + ok: false, + providerId, + issueCode, + message, + checkedUrl, + statusCode, + modelCount: 0, + }; +} + +export async function probeRagProviderConnection(params: { + providerId: RagProviderId; + tokenId: string | null; + config: RagProviderConfig; +}): Promise { + const checkedUrl = + params.providerId === "openrouter" + ? "https://openrouter.ai/api/v1/embeddings/models" + : `${String(params.config.baseUrl ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`; + + if (params.providerId === "openrouter" && !params.tokenId) { + return failure( + params.providerId, + checkedUrl, + "TOKEN_MISSING", + "Select an OpenRouter token before checking the connection." + ); + } + const token = params.tokenId ? await getTokenPlaintext(params.tokenId) : null; + if (params.providerId === "openrouter" && !token) { + return failure( + params.providerId, + checkedUrl, + "TOKEN_NOT_FOUND", + "The selected OpenRouter token could not be found." + ); + } + + try { + const response = await axios.get( + checkedUrl, + params.providerId === "openrouter" + ? { + headers: { + "HTTP-Referer": "http://localhost:5000", + "X-Title": "TaleSpinner", + Authorization: `Bearer ${token}`, + }, + timeout: REQUEST_TIMEOUT_MS, + } + : { timeout: REQUEST_TIMEOUT_MS } + ); + const modelCount = + params.providerId === "openrouter" + ? (Array.isArray(response.data?.data) ? response.data.data.length : 0) + : (Array.isArray(response.data?.models) ? response.data.models.length : 0); + return { + ok: true, + providerId: params.providerId, + issueCode: null, + message: `Connection successful. ${modelCount} embedding model${modelCount === 1 ? "" : "s"} available.`, + checkedUrl, + statusCode: typeof response.status === "number" ? response.status : 200, + modelCount, + }; + } catch (error) { + const details = getErrorDetails(error); + const issueCode = + details.statusCode === 401 || details.statusCode === 403 + ? "AUTH_ERROR" + : details.statusCode === 404 + ? "ENDPOINT_NOT_FOUND" + : details.statusCode === null + ? "NETWORK_ERROR" + : "PROVIDER_ERROR"; + return failure( + params.providerId, + checkedUrl, + issueCode, + details.message, + details.statusCode + ); + } +} diff --git a/shared/types/rag.ts b/shared/types/rag.ts index 4b909828..3fc458ee 100644 --- a/shared/types/rag.ts +++ b/shared/types/rag.ts @@ -39,6 +39,25 @@ export type RagRuntime = { activeTokenHint: string | null; }; +export type RagProviderConnectionIssueCode = + | 'TOKEN_MISSING' + | 'TOKEN_NOT_FOUND' + | 'AUTH_ERROR' + | 'ENDPOINT_NOT_FOUND' + | 'NETWORK_ERROR' + | 'PROVIDER_ERROR' + | null; + +export type RagProviderConnectionCheckResult = { + ok: boolean; + providerId: RagProviderId; + issueCode: RagProviderConnectionIssueCode; + message: string; + checkedUrl: string; + statusCode: number | null; + modelCount: number; +}; + export type RagPresetPayload = { activeProviderId: RagProviderId; activeTokenId: string | null; diff --git a/web/src/api/rag.ts b/web/src/api/rag.ts index 22d5433e..5b76fece 100644 --- a/web/src/api/rag.ts +++ b/web/src/api/rag.ts @@ -1,7 +1,7 @@ import { apiJson } from './api-json'; import type { LlmTokenListItem } from '@shared/types/llm'; -import type { RagModel, RagPreset, RagPresetSettings, RagProviderConfig, RagProviderDefinition, RagProviderId, RagRuntime } from '@shared/types/rag'; +import type { RagModel, RagPreset, RagPresetSettings, RagProviderConfig, RagProviderConnectionCheckResult, RagProviderDefinition, RagProviderId, RagRuntime } from '@shared/types/rag'; export async function getRagProviders(): Promise { const data = await apiJson<{ providers: RagProviderDefinition[] }>('/rag/providers'); @@ -27,6 +27,17 @@ export async function patchRagProviderConfig(providerId: RagProviderId, config: }); } +export async function checkRagProviderConnection(params: { + providerId: RagProviderId; + tokenId: string | null; + config: RagProviderConfig; +}): Promise { + return apiJson(`/rag/providers/${encodeURIComponent(params.providerId)}/check`, { + method: 'POST', + body: JSON.stringify({ tokenId: params.tokenId, config: params.config }), + }); +} + export async function listRagTokens(providerId: RagProviderId): Promise { const data = await apiJson<{ tokens: LlmTokenListItem[] }>(`/rag/tokens?providerId=${encodeURIComponent(providerId)}`); return data.tokens; diff --git a/web/src/features/llm-provider/llm-token-manager-dialog.tsx b/web/src/features/llm-provider/llm-token-manager-dialog.tsx index ba966411..bf20cd89 100644 --- a/web/src/features/llm-provider/llm-token-manager-dialog.tsx +++ b/web/src/features/llm-provider/llm-token-manager-dialog.tsx @@ -16,7 +16,9 @@ type Props = { providerId: LlmProviderId; providerName?: string; activeTokenId?: string | null; + tokenItems?: LlmTokenListItem[]; onTokenSelected?: (tokenId: string | null) => void; + onTokensChanged?: () => Promise | void; }; type EditorMode = { type: 'list' } | { type: 'create' } | { type: 'edit'; token: LlmTokenListItem }; @@ -27,7 +29,9 @@ export const LlmTokenManagerDialog: React.FC = ({ providerId, providerName, activeTokenId = null, + tokenItems, onTokenSelected, + onTokensChanged, }) => { const { t } = useTranslation(); const [ @@ -49,7 +53,10 @@ export const LlmTokenManagerDialog: React.FC = ({ llmProviderModel.patchTokenFx.pending, llmProviderModel.deleteTokenFx.pending, ]); - const tokens = useMemo(() => tokensByProviderId[providerId] ?? [], [providerId, tokensByProviderId]); + const tokens = useMemo( + () => tokenItems ?? tokensByProviderId[providerId] ?? [], + [providerId, tokenItems, tokensByProviderId], + ); const [mode, setMode] = useState({ type: 'list' }); const [name, setName] = useState(''); const [tokenValue, setTokenValue] = useState(''); @@ -84,11 +91,13 @@ export const LlmTokenManagerDialog: React.FC = ({ if (mode.type === 'create') { const created = await createTokenFx({ providerId, name: name.trim(), token: tokenValue.trim() }); await loadTokensFx(providerId); + await onTokensChanged?.(); onTokenSelected?.(created.id); toaster.success({ title: t('tokenManager.toasts.created') }); } else if (mode.type === 'edit') { await patchTokenFx({ id: mode.token.id, name: name.trim(), token: tokenValue.trim() || undefined }); await loadTokensFx(providerId); + await onTokensChanged?.(); toaster.success({ title: t('tokenManager.toasts.saved') }); } resetEditor(); @@ -105,6 +114,7 @@ export const LlmTokenManagerDialog: React.FC = ({ try { await deleteTokenFx(deletingToken.id); await loadTokensFx(providerId); + await onTokensChanged?.(); if (deletingToken.id === activeTokenId) onTokenSelected?.(null); toaster.success({ title: t('tokenManager.toasts.deleted') }); setDeletingToken(null); diff --git a/web/src/features/sidebars/settings/rag-connection-editor.tsx b/web/src/features/sidebars/settings/rag-connection-editor.tsx new file mode 100644 index 00000000..b082d67c --- /dev/null +++ b/web/src/features/sidebars/settings/rag-connection-editor.tsx @@ -0,0 +1,179 @@ +import { Button, Group, Input, Select, Stack, Text, TextInput, UnstyledButton } from '@mantine/core'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuKeyRound, LuSearch } from 'react-icons/lu'; + +import { LlmModelPickerDialog } from '../../llm-provider/llm-model-picker-dialog'; +import { LlmTokenManagerDialog } from '../../llm-provider/llm-token-manager-dialog'; + +import type { LlmTokenListItem } from '@shared/types/llm'; +import type { RagModel, RagProviderConfig, RagProviderDefinition, RagProviderId } from '@shared/types/rag'; + +type Props = { + providers: RagProviderDefinition[]; + providerId: RagProviderId; + tokens: LlmTokenListItem[]; + tokenId: string | null; + models: RagModel[]; + modelId: string | null; + config: RagProviderConfig; + isLoadingModels: boolean; + isChecking: boolean; + onProviderChange: (providerId: RagProviderId) => Promise; + onTokenChange: (tokenId: string | null) => Promise; + onModelChange: (modelId: string | null) => Promise; + onConfigChange: (config: RagProviderConfig) => void; + onRefreshModels: () => Promise; + onRefreshTokens: () => Promise; + onCheckConnection: () => Promise; +}; + +export const RagConnectionEditor: React.FC = ({ + providers, + providerId, + tokens, + tokenId, + models, + modelId, + config, + isLoadingModels, + isChecking, + onProviderChange, + onTokenChange, + onModelChange, + onConfigChange, + onRefreshModels, + onRefreshTokens, + onCheckConnection, +}) => { + const { t } = useTranslation(); + const [tokenManagerOpen, setTokenManagerOpen] = useState(false); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const activeProvider = providers.find((provider) => provider.id === providerId); + const activeModel = models.find((model) => model.id === modelId); + const providerOptions = useMemo( + () => providers.filter((provider) => provider.enabled).map((provider) => ({ value: provider.id, label: provider.name })), + [providers], + ); + const tokenOptions = useMemo( + () => tokens.map((token) => ({ value: token.id, label: `${token.name} · ${token.tokenHint}` })), + [tokens], + ); + + return ( + + {t('rag.connection.title')} + void onTokenChange(value ?? null)} + placeholder={tokens.length ? t('rag.placeholders.selectToken') : t('rag.placeholders.noTokens')} + clearable + searchable + style={{ flex: 1 }} + comboboxProps={{ withinPortal: false }} + /> + + + + )} + + {providerId === 'openrouter' ? ( + + setModelPickerOpen(true)} + disabled={!tokenId} + aria-label={t('rag.model.openPicker')} + style={{ + width: '100%', + minHeight: 58, + border: '1px solid var(--mantine-color-default-border)', + borderRadius: 'var(--mantine-radius-md)', + padding: '9px 12px', + opacity: tokenId ? 1 : 0.55, + }} + > + + + + {activeModel?.name ?? modelId ?? t('rag.placeholders.selectModel')} + + {modelId ? {modelId} : null} + + + + + + ) : ( + void onModelChange(event.currentTarget.value || null)} + placeholder="nomic-embed-text" + /> + )} + + + + + + {providerId === 'openrouter' ? ( + <> + void onTokenChange(value)} + onTokensChanged={onRefreshTokens} + /> + void onModelChange(value)} + showCapabilityFilters={false} + /> + + ) : null} + + ); +}; diff --git a/web/src/features/sidebars/settings/rag-preset-manager.tsx b/web/src/features/sidebars/settings/rag-preset-manager.tsx new file mode 100644 index 00000000..9d9c384f --- /dev/null +++ b/web/src/features/sidebars/settings/rag-preset-manager.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from 'react-i18next'; + +import { toaster } from '@ui/toaster'; + +import { PresetControls } from './preset-controls'; + +import type { RagPreset, RagPresetPayload, RagPresetSettings } from '@shared/types/rag'; + +type Props = { + presets: RagPreset[]; + settings: RagPresetSettings | null; + hasUnsavedChanges: boolean; + buildPayload: () => RagPresetPayload; + onCreate: (params: { name: string; payload: RagPresetPayload }) => Promise; + onUpdate: (preset: RagPreset) => Promise; + onDelete: (id: string) => Promise<{ id: string }>; + onSelect: (id: string, options?: { skipUnsavedConfirm?: boolean }) => Promise; + onSaveCurrent: () => Promise; +}; + +export const RagPresetManager: React.FC = ({ + presets, + settings, + hasUnsavedChanges, + buildPayload, + onCreate, + onUpdate, + onDelete, + onSelect, + onSaveCurrent, +}) => { + const { t } = useTranslation(); + const activePreset = presets.find((preset) => preset.id === settings?.selectedId) ?? null; + const options = presets.map((preset) => ({ value: preset.id, label: preset.name })); + const fail = (error: unknown) => + toaster.error({ + title: t('rag.presets.toasts.failed'), + description: error instanceof Error ? error.message : String(error), + }); + + const create = async () => { + const name = window.prompt(t('rag.presets.actions.createPrompt'), t('rag.presets.defaults.newPresetName'))?.trim(); + if (!name) return; + try { + const preset = await onCreate({ name, payload: buildPayload() }); + await onSelect(preset.id, { skipUnsavedConfirm: true }); + toaster.success({ title: t('rag.presets.toasts.created'), description: preset.name }); + } catch (error) { + fail(error); + } + }; + + const rename = async () => { + if (!activePreset) return; + const name = window.prompt(t('rag.presets.actions.renamePrompt'), activePreset.name)?.trim(); + if (!name) return; + try { + await onUpdate({ ...activePreset, name, updatedAt: new Date().toISOString() }); + toaster.success({ title: t('rag.presets.toasts.saved'), description: name }); + } catch (error) { + fail(error); + } + }; + + const duplicate = async () => { + if (!activePreset) return; + try { + const preset = await onCreate({ name: `${activePreset.name} copy`, payload: activePreset.payload }); + await onSelect(preset.id, { skipUnsavedConfirm: true }); + toaster.success({ title: t('rag.presets.toasts.created'), description: preset.name }); + } catch (error) { + fail(error); + } + }; + + const remove = async () => { + if (!activePreset || !window.confirm(t('rag.presets.confirm.delete'))) return; + try { + await onDelete(activePreset.id); + toaster.success({ title: t('rag.presets.toasts.deleted'), description: activePreset.name }); + } catch (error) { + fail(error); + } + }; + + const select = async (id: string) => { + try { + await onSelect(id); + } catch (error) { + fail(error); + } + }; + + return ( + id && void select(id)} + onCreate={() => void create()} + onRename={() => void rename()} + onDuplicate={() => void duplicate()} + onSave={() => void onSaveCurrent()} + onDelete={() => void remove()} + disableRename={!activePreset} + disableDuplicate={!activePreset} + disableSave={!activePreset || !hasUnsavedChanges} + disableDelete={!activePreset} + /> + ); +}; diff --git a/web/src/features/sidebars/settings/rag-provider-advanced-config.tsx b/web/src/features/sidebars/settings/rag-provider-advanced-config.tsx new file mode 100644 index 00000000..416b44c5 --- /dev/null +++ b/web/src/features/sidebars/settings/rag-provider-advanced-config.tsx @@ -0,0 +1,67 @@ +import { Select, Stack, Switch, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +import type { RagProviderConfig, RagProviderId } from '@shared/types/rag'; + +type Props = { + providerId: RagProviderId; + config: RagProviderConfig; + onChange: (config: RagProviderConfig) => void; +}; + +export const RagProviderAdvancedConfig: React.FC = ({ providerId, config, onChange }) => { + const { t } = useTranslation(); + const update = (patch: RagProviderConfig) => onChange({ ...config, ...patch }); + + return ( + + update({ defaultModel: event.currentTarget.value || undefined })} + placeholder={providerId === 'openrouter' ? 'openai/text-embedding-3-small' : 'nomic-embed-text'} + /> + {providerId === 'openrouter' ? ( + <> + { + const value = Number.parseInt(event.currentTarget.value, 10); + update({ dimensions: Number.isInteger(value) && value > 0 ? value : undefined }); + }} + /> + ragProviderModel.ragProviderSelected((value ?? 'openrouter') as RagProviderId)} - allowDeselect={false} - comboboxProps={{ withinPortal: false }} + return ( + + + buildPayload()} + onCreate={createPresetFx} + onUpdate={updatePresetFx} + onDelete={deletePresetFx} + onSelect={selectPreset} + onSaveCurrent={save} /> - - - {activeProviderId === 'openrouter' && ( - - { - setModelDraft(value ?? ''); - ragProviderModel.ragModelSelected(value ?? null); + + setValue('config', next, { shouldDirty: true })} + onRefreshModels={refreshModels} + onRefreshTokens={async () => { + await loadTokensFx(providerId); }} - placeholder={canLoadModels ? t('provider.placeholders.selectModel') : t('provider.placeholders.selectTokenFirst')} - disabled={!canLoadModels} - clearable - searchable - comboboxProps={{ withinPortal: false }} + onCheckConnection={checkConnection} /> - - setModelDraft(event.currentTarget.value)} - onBlur={applyModelDraft} - placeholder={t('rag.model.manualPlaceholder')} - style={{ flex: 1 }} + + setValue('config', next, { shouldDirty: true })} /> - + - - {t('rag.config.title')} - - {activeProvider?.configFields.map((field) => { - if (field.type === 'select') { - return ( - { - const nextProviderId = (value ?? 'openrouter') as LlmProviderId; - if (nextProviderId === activeProviderId) return; - onProviderSelect(nextProviderId); - }} - placeholder={t('provider.placeholders.selectProvider')} - searchable - allowDeselect={false} - comboboxProps={{ withinPortal: false }} - /> - - - - - {t('provider.tokens.title')} - {allowTokenManager && onOpenTokenManager && tokenManagerScope && tokenManagerScopeId ? ( - - ) : null} - - - { - const nextModel = value ?? null; - if (nextModel !== activeModel) { - onModelSelect(nextModel); - } - setManualModel(nextModel ?? ''); - }} - clearable - searchable - placeholder={canLoadModels ? t('provider.placeholders.selectModel') : t('provider.placeholders.selectTokenFirst')} - disabled={!canLoadModels} - comboboxProps={{ withinPortal: false }} - /> - - - setManualModel(event.currentTarget.value)} - onBlur={applyManualModel} - placeholder={t('provider.model.manualPlaceholder')} - disabled={!canLoadModels} - style={{ flex: 1 }} - /> - - - - - {t('provider.model.helpText')} - - - - ); -}; diff --git a/web/src/features/llm-provider/token-manager.tsx b/web/src/features/llm-provider/token-manager.tsx deleted file mode 100644 index 7be25c4a..00000000 --- a/web/src/features/llm-provider/token-manager.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useUnit } from 'effector-react'; -import { useMemo } from 'react'; - -import { llmProviderModel } from '@model/provider'; - -import { LlmTokenManagerDialog } from './llm-token-manager-dialog'; - -import type { LlmProviderId, LlmScope } from '@shared/types/llm'; - -type Props = { - providerId: LlmProviderId; - scope: LlmScope; - scopeId: string; -}; - -export const TokenManager: React.FC = ({ providerId, scope, scopeId }) => { - const [isOpen, setOpen, runtimeByKey] = useUnit([ - llmProviderModel.$isTokenManagerOpen, - llmProviderModel.tokenManagerOpened, - llmProviderModel.$runtimeByScopeKey, - ]); - - const activeTokenId = useMemo(() => { - const runtime = runtimeByKey[`${scope}:${scopeId}`]; - return runtime?.activeTokenId ?? null; - }, [runtimeByKey, scope, scopeId]); - - return ( - llmProviderModel.tokenSelected({ scope, scopeId, tokenId })} - /> - ); -}; diff --git a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts index 082fe8a6..443dcc6e 100644 --- a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts +++ b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts @@ -316,6 +316,7 @@ describe('operation profile form mapping', () => { providerId: 'openrouter', credentialRef: 'cred-1', model: 'gpt-test', + llmPresetId: 'preset-1', }, }, }, @@ -323,6 +324,51 @@ describe('operation profile form mapping', () => { expect((llm as { config: { params: { params: { samplers?: unknown } } } }).config.params.params.samplers).toBeUndefined(); }); + it('restores the selected LLM preset from a saved operation', () => { + const profile: OperationProfileDto = { + profileId: 'profile-llm', + ownerId: 'owner-1', + name: 'Profile', + description: undefined, + enabled: true, + executionMode: 'sequential', + operationProfileSessionId: 'session-llm', + blockRefs: [], + operations: [ + { + opId: 'llm-1', + name: 'LLM op', + kind: 'llm', + config: { + enabled: true, + required: false, + hooks: ['before_main_llm'], + triggers: ['generate'], + order: 10, + params: { + params: { + providerId: 'openrouter', + credentialRef: 'cred-1', + model: 'gpt-test', + llmPresetId: 'preset-1', + prompt: 'Prompt', + }, + artifact: makeArtifact({ artifactId: 'artifact:llm-1', tag: 'llm_op' }), + }, + }, + }, + ], + meta: {}, + version: 1, + createdAt: '2026-07-17T00:00:00.000Z', + updatedAt: '2026-07-17T00:00:00.000Z', + }; + + const form = toOperationProfileForm(profile); + + expect(form.operations[0]?.config.params).toMatchObject({ llmPresetId: 'preset-1' }); + }); + it('normalizes knowledge operations into dedicated form params', () => { const profile: OperationProfileDto = { profileId: 'profile-knowledge', diff --git a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts index acb5620d..642ebfc1 100644 --- a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts +++ b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts @@ -252,7 +252,7 @@ function normalizeLlmKindParams(op: Extract providerId, credentialRef: typeof llmParamsRaw.credentialRef === 'string' ? llmParamsRaw.credentialRef : '', model: typeof llmParamsRaw.model === 'string' ? llmParamsRaw.model : '', - llmPresetId: '', + llmPresetId: typeof llmParamsRaw.llmPresetId === 'string' ? llmParamsRaw.llmPresetId : '', system: typeof llmParamsRaw.system === 'string' ? llmParamsRaw.system : '', prompt: typeof llmParamsRaw.prompt === 'string' ? llmParamsRaw.prompt : '', strictVariables: llmParamsRaw.strictVariables === true, @@ -424,6 +424,7 @@ export function fromOperationProfileForm( if (op.kind === 'llm') { const params = op.config.params as FormLlmKindParams; const model = params.model.trim(); + const llmPresetId = params.llmPresetId.trim(); const system = params.system.trim(); const samplerPresetId = params.samplerPresetId.trim(); const jsonCustomPattern = params.jsonCustomPattern.trim(); @@ -464,6 +465,7 @@ export function fromOperationProfileForm( providerId: params.providerId, credentialRef: params.credentialRef.trim(), model: model.length > 0 ? model : undefined, + llmPresetId: llmPresetId.length > 0 ? llmPresetId : undefined, system: system.length > 0 ? system : undefined, prompt: params.prompt, strictVariables: params.strictVariables ? true : undefined, diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx index e4d8380f..876df616 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx @@ -36,26 +36,32 @@ export const OperationLlmConfigControls: React.FC = ({ index }) => { modelsByProviderTokenKey, presets, samplerPresets, - loadProvidersFx, - loadTokensFx, + ensureProvidersFx, + ensureTokensFx, loadModelsFx, - loadLlmPresetsFx, + ensureModelsFx, + ensureLlmPresetsFx, createLlmPresetFx, updateLlmPresetFx, deleteLlmPresetFx, + isLoadingModels, + isEnsuringModels, ] = useUnit([ llmProviderModel.$providers, llmProviderModel.$tokensByProviderId, llmProviderModel.$modelsByProviderTokenKey, llmProviderModel.$llmPresets, samplersModel.$items, - llmProviderModel.loadProvidersFx, - llmProviderModel.loadTokensFx, + llmProviderModel.ensureProvidersFx, + llmProviderModel.ensureTokensFx, llmProviderModel.loadModelsFx, - llmProviderModel.loadLlmPresetsFx, + llmProviderModel.ensureModelsFx, + llmProviderModel.ensureLlmPresetsFx, llmProviderModel.createLlmPresetFx, llmProviderModel.updateLlmPresetFx, llmProviderModel.deleteLlmPresetFx, + llmProviderModel.loadModelsFx.pending, + llmProviderModel.ensureModelsFx.pending, ]); const fieldPrefix = `operations.${index}.config.params` as const; @@ -94,13 +100,23 @@ export const OperationLlmConfigControls: React.FC = ({ index }) => { ); useEffect(() => { - void loadProvidersFx(); - void loadLlmPresetsFx(); - }, [loadLlmPresetsFx, loadProvidersFx]); + void ensureProvidersFx(); + void ensureLlmPresetsFx(); + }, [ensureLlmPresetsFx, ensureProvidersFx]); useEffect(() => { - void loadTokensFx(providerId); - }, [loadTokensFx, providerId]); + void ensureTokensFx(providerId); + }, [ensureTokensFx, providerId]); + + useEffect(() => { + if (!credentialRef) return; + void ensureModelsFx({ + providerId, + scope: 'global', + scopeId: 'global', + tokenId: credentialRef, + }); + }, [credentialRef, ensureModelsFx, providerId]); const patchRuntime = (patch: Partial) => { if (typeof patch.providerId !== 'undefined') { @@ -185,6 +201,7 @@ export const OperationLlmConfigControls: React.FC = ({ index }) => { providers={providers} tokens={tokens} models={models} + isLoadingModels={isLoadingModels || isEnsuringModels} presets={presets} runtime={runtime} onRuntimeChange={patchRuntime} diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx index 73736041..3d0bbcd0 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx @@ -1,12 +1,9 @@ import { Button, Stack, Text } from '@mantine/core'; -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog } from '@ui/dialog'; -import { toaster } from '@ui/toaster'; -import { LlmTokenManagerDialog } from '../../../../../../../llm-provider/llm-token-manager-dialog'; -import { LlmRuntimeSelectorFields } from '../../../../../../../llm-provider/runtime-selector-fields'; +import { LlmConnectionSelector } from '../../../../../../../llm-provider/llm-connection-editor'; import { OperationLlmPresetManager } from './operation-llm-preset-manager'; @@ -20,6 +17,7 @@ type Props = { providers: LlmProviderDefinition[]; tokens: LlmTokenListItem[]; models: LlmModel[]; + isLoadingModels: boolean; presets: LlmPresetDto[]; runtime: OperationLlmRuntimeFields; onRuntimeChange: (patch: Partial) => void; @@ -41,6 +39,7 @@ export const OperationLlmRuntimeDialog: React.FC = ({ providers, tokens, models, + isLoadingModels, presets, runtime, onRuntimeChange, @@ -51,86 +50,64 @@ export const OperationLlmRuntimeDialog: React.FC = ({ onDeletePreset, }) => { const { t } = useTranslation(); - const [isTokenManagerOpen, setTokenManagerOpen] = useState(false); return ( - <> - onOpenChange(false)}> - {t('common.close')} - - } - > - - - {t('operationProfiles.llmRuntime.dialogDescription')} - + onOpenChange(false)}> + {t('common.close')} + + } + > + + + {t('operationProfiles.llmRuntime.dialogDescription')} + - + - - onRuntimeChange({ - providerId, - credentialRef: '', - model: '', - }) - } - onTokenSelect={(tokenId: string | null) => - onRuntimeChange({ - credentialRef: tokenId ?? '', - model: '', - }) - } - onModelSelect={(model: string | null) => onRuntimeChange({ model: model ?? '' })} - onLoadModels={async () => { - try { - await onLoadModels(); - } catch (error) { - toaster.error({ - title: t('provider.toasts.modelsLoadFailed'), - description: error instanceof Error ? error.message : String(error), - }); - } - }} - allowTokenManager - showInlineTokenManager={false} - tokenManagerScope="global" - tokenManagerScopeId="global" - onOpenTokenManager={setTokenManagerOpen} - /> - - {t('operationProfiles.llmRuntime.tokenHelp')} - - - - - onRuntimeChange({ credentialRef: tokenId ?? '', model: '' })} - /> - + + onRuntimeChange({ + providerId, + credentialRef: '', + model: '', + }) + } + onTokenChange={async (tokenId: string | null) => + onRuntimeChange({ + credentialRef: tokenId ?? '', + model: '', + }) + } + onModelChange={async (model: string) => onRuntimeChange({ model })} + onRefreshModels={onLoadModels} + showProviderConfig={false} + showConnectionCheck={false} + /> + + {t('operationProfiles.llmRuntime.tokenHelp')} + + + ); }; diff --git a/web/src/model/app-init.ts b/web/src/model/app-init.ts index a3979de5..5faff234 100644 --- a/web/src/model/app-init.ts +++ b/web/src/model/app-init.ts @@ -20,6 +20,7 @@ export const appInitFx = createEffect(async (): Promise => { // Instructions are global; load once on app start. instructionsInitRequested(); worldInfoInitRequested(); + const globalRuntimePromise = llmProviderModel.ensureRuntimeFx({ scope: 'global', scopeId: 'global' }); await Promise.all([ // UI state @@ -38,8 +39,24 @@ export const appInitFx = createEffect(async (): Promise => { samplersModel.getItemsFx(), // LLM provider runtime (needed for auto models load) - llmProviderModel.loadProvidersFx(), - llmProviderModel.loadRuntimeFx({ scope: 'global', scopeId: 'global' }), + llmProviderModel.ensureProvidersFx(), + llmProviderModel.ensureLlmPresetsFx(), + llmProviderModel.ensureLlmPresetSettingsFx(), + globalRuntimePromise, + ]); + + const runtime = await globalRuntimePromise; + await Promise.all([ + llmProviderModel.ensureTokensFx(runtime.activeProviderId), + llmProviderModel.ensureProviderConfigFx(runtime.activeProviderId), + runtime.activeTokenId + ? llmProviderModel.ensureModelsFx({ + providerId: runtime.activeProviderId, + scope: runtime.scope, + scopeId: runtime.scopeId, + tokenId: runtime.activeTokenId, + }) + : Promise.resolve(), ]); }); diff --git a/web/src/model/provider/action-effects.ts b/web/src/model/provider/action-effects.ts new file mode 100644 index 00000000..85cbab93 --- /dev/null +++ b/web/src/model/provider/action-effects.ts @@ -0,0 +1,97 @@ +import { createEffect } from 'effector'; + +import * as llmApi from '../../api/llm'; + +import { + cachePreset, + cachePresetSettings, + cacheProviderConfig, + cacheRuntime, +} from './resource-effects'; + +import type { + LlmPresetPayload, + LlmProviderConfig, + LlmProviderId, + LlmScope, + LlmTokenListItem, +} from '@shared/types/llm'; + +export const checkProviderConnectionFx = createEffect( + async (params: { + providerId: LlmProviderId; + scope: LlmScope; + scopeId: string; + tokenId?: string | null; + config?: LlmProviderConfig; + }) => llmApi.checkProviderConnection(params), +); + +export const createTokenFx = createEffect( + async (params: { providerId: LlmProviderId; name: string; token: string }): Promise => + llmApi.createToken(params), +); +export const patchTokenFx = createEffect((params: { id: string; name?: string; token?: string }): Promise => + llmApi.patchToken(params), +); +export const deleteTokenFx = createEffect((id: string): Promise => llmApi.deleteToken(id)); + +export const saveConnectionFx = createEffect( + async (params: { + scope: LlmScope; + scopeId: string; + providerId: LlmProviderId; + tokenId: string | null; + model: string | null; + config: LlmProviderConfig; + preset?: { presetId: string; payload: LlmPresetPayload }; + }) => { + const config = await llmApi.patchProviderConfig(params.providerId, params.config); + cacheProviderConfig(config); + const runtime = await llmApi.patchRuntime({ + scope: params.scope, + scopeId: params.scopeId, + activeProviderId: params.providerId, + activeTokenId: params.tokenId, + activeModel: params.model, + }); + cacheRuntime(runtime); + const preset = params.preset + ? await llmApi.updateLlmPreset({ + presetId: params.preset.presetId, + ownerId: 'global', + payload: params.preset.payload, + }) + : null; + if (preset) cachePreset(preset); + return { config, runtime, preset }; + }, +); + +export const createLlmPresetFx = createEffect( + async (params: { name: string; description?: string; payload: LlmPresetPayload }): Promise => + llmApi.createLlmPreset({ ownerId: 'global', ...params }), +); +export const updateLlmPresetFx = createEffect( + async (params: { + presetId: string; + name?: string; + description?: string | null; + payload?: LlmPresetPayload; + }): Promise => llmApi.updateLlmPreset({ ownerId: 'global', ...params }), +); +export const deleteLlmPresetFx = createEffect((presetId: string) => + llmApi.deleteLlmPreset({ ownerId: 'global', presetId }), +); +export const applyLlmPresetFx = createEffect( + async (params: { presetId: string; scope: LlmScope; scopeId: string }) => { + const result = await llmApi.applyLlmPreset({ ownerId: 'global', ...params }); + cacheRuntime(result.runtime); + return result; + }, +); +export const patchLlmPresetSettingsFx = createEffect(async (params: { activePresetId?: string | null }) => { + const settings = await llmApi.patchLlmPresetSettings({ ownerId: 'global', ...params }); + cachePresetSettings(settings); + return settings; +}); diff --git a/web/src/model/provider/async-resource-cache.test.ts b/web/src/model/provider/async-resource-cache.test.ts new file mode 100644 index 00000000..e8f82d0c --- /dev/null +++ b/web/src/model/provider/async-resource-cache.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { AsyncResourceCache } from './async-resource-cache'; + +describe('AsyncResourceCache', () => { + test('reuses a loaded value until a forced refresh', async () => { + const cache = new AsyncResourceCache(); + const loader = vi.fn().mockResolvedValueOnce(['first']).mockResolvedValueOnce(['refreshed']); + + await expect(cache.load('models', loader)).resolves.toEqual(['first']); + await expect(cache.load('models', loader)).resolves.toEqual(['first']); + await expect(cache.load('models', loader, true)).resolves.toEqual(['refreshed']); + expect(loader).toHaveBeenCalledTimes(2); + }); + + test('shares an in-flight request between consumers', async () => { + const cache = new AsyncResourceCache(); + let resolveRequest: ((value: string) => void) | undefined; + const loader = vi.fn( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + + const first = cache.load('providers', loader); + const second = cache.load('providers', loader); + resolveRequest?.('ready'); + + await expect(Promise.all([first, second])).resolves.toEqual(['ready', 'ready']); + expect(loader).toHaveBeenCalledTimes(1); + }); + + test('retries after a failed request', async () => { + const cache = new AsyncResourceCache(); + const loader = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce('ready'); + + await expect(cache.load('tokens', loader)).rejects.toThrow('offline'); + await expect(cache.load('tokens', loader)).resolves.toBe('ready'); + expect(loader).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/model/provider/async-resource-cache.ts b/web/src/model/provider/async-resource-cache.ts new file mode 100644 index 00000000..71d03442 --- /dev/null +++ b/web/src/model/provider/async-resource-cache.ts @@ -0,0 +1,30 @@ +export class AsyncResourceCache { + private readonly values = new Map(); + private readonly inFlight = new Map>(); + + load(key: Key, loader: () => Promise, force = false): Promise { + if (!force && this.values.has(key)) { + return Promise.resolve(this.values.get(key) as Value); + } + + const pending = this.inFlight.get(key); + if (pending) return pending; + + const request = loader() + .then((value) => { + this.values.set(key, value); + return value; + }) + .finally(() => this.inFlight.delete(key)); + this.inFlight.set(key, request); + return request; + } + + set(key: Key, value: Value): void { + this.values.set(key, value); + } + + peek(key: Key): Value | undefined { + return this.values.get(key); + } +} diff --git a/web/src/model/provider/index.ts b/web/src/model/provider/index.ts index bfe1e244..c155c1f1 100644 --- a/web/src/model/provider/index.ts +++ b/web/src/model/provider/index.ts @@ -1,16 +1,46 @@ -import { createEffect, createEvent, createStore, sample } from 'effector'; +import { createEvent, createStore, sample } from 'effector'; -import * as llmApi from '../../api/llm'; +import { + applyLlmPresetFx, + checkProviderConnectionFx, + createLlmPresetFx, + createTokenFx, + deleteLlmPresetFx, + deleteTokenFx, + patchLlmPresetSettingsFx, + patchTokenFx, + saveConnectionFx, + updateLlmPresetFx, +} from './action-effects'; +import { + ensureLlmPresetSettingsFx, + ensureLlmPresetsFx, + ensureModelsFx, + ensureOpenRouterEndpointsFx, + ensureProviderConfigFx, + ensureProvidersFx, + ensureRuntimeFx, + ensureRuntimeProviderStateFx, + ensureTokensFx, + loadLlmPresetSettingsFx, + loadLlmPresetsFx, + loadModelsFx, + loadOpenRouterEndpointsFx, + loadProviderConfigFx, + loadProvidersFx, + loadRuntimeFx, + loadRuntimeProviderStateFx, + loadTokensFx, +} from './resource-effects'; +import type { LlmPresetDto, LlmPresetSettingsDto } from '../../api/llm'; import type { LlmModel, LlmOpenRouterEndpoint, - LlmPresetPayload, LlmProviderConfig, LlmProviderDefinition, LlmProviderId, LlmRuntime, - LlmRuntimeProviderState, LlmScope, LlmTokenListItem, } from '@shared/types/llm'; @@ -19,203 +49,7 @@ export type ScopeKey = `${LlmScope}:${string}`; const toScopeKey = (scope: LlmScope, scopeId: string): ScopeKey => `${scope}:${scopeId}`; -export const loadProvidersFx = createEffect(async (): Promise => { - return llmApi.getProviders(); -}); - -export const loadRuntimeFx = createEffect(async (params: { scope: LlmScope; scopeId: string }): Promise => { - return llmApi.getRuntime(params); -}); - -export const loadRuntimeProviderStateFx = createEffect( - async (params: { scope: LlmScope; scopeId: string; providerId: LlmProviderId }): Promise => - llmApi.getRuntimeProviderState(params), -); - -export const patchRuntimeFx = createEffect( - async (params: { - scope: LlmScope; - scopeId: string; - activeProviderId: LlmProviderId; - activeTokenId?: string | null; - activeModel?: string | null; - }): Promise => { - return llmApi.patchRuntime(params); - }, -); - -export const loadProviderConfigFx = createEffect( - async (providerId: LlmProviderId): Promise<{ providerId: LlmProviderId; config: LlmProviderConfig }> => { - return llmApi.getProviderConfig(providerId); - }, -); - -export const patchProviderConfigFx = createEffect( - async (params: { - providerId: LlmProviderId; - config: LlmProviderConfig; - }): Promise<{ providerId: LlmProviderId; config: LlmProviderConfig }> => { - return llmApi.patchProviderConfig(params.providerId, params.config); - }, -); - -export const checkProviderConnectionFx = createEffect( - async (params: { - providerId: LlmProviderId; - scope: LlmScope; - scopeId: string; - tokenId?: string | null; - config?: LlmProviderConfig; - }) => { - return llmApi.checkProviderConnection(params); - }, -); - -export const loadTokensFx = createEffect( - async (providerId: LlmProviderId): Promise<{ providerId: LlmProviderId; tokens: LlmTokenListItem[] }> => { - const tokens = await llmApi.listTokens(providerId); - return { providerId, tokens }; - }, -); - -export const createTokenFx = createEffect( - async (params: { providerId: LlmProviderId; name: string; token: string }): Promise => { - return llmApi.createToken(params); - }, -); - -export const patchTokenFx = createEffect( - async (params: { id: string; name?: string; token?: string }): Promise => { - return llmApi.patchToken(params); - }, -); - -export const deleteTokenFx = createEffect(async (id: string): Promise => { - return llmApi.deleteToken(id); -}); - -export const loadModelsFx = createEffect( - async (params: { - providerId: LlmProviderId; - scope: LlmScope; - scopeId: string; - tokenId?: string | null; - }): Promise<{ key: string; models: LlmModel[] }> => { - const models = await llmApi.getModels(params); - const tokenKey = params.tokenId ?? 'none'; - return { key: `${params.providerId}:${tokenKey}`, models }; - }, -); - -export const loadOpenRouterEndpointsFx = createEffect( - async (modelId: string): Promise<{ modelId: string; endpoints: LlmOpenRouterEndpoint[] }> => ({ - modelId, - endpoints: await llmApi.getOpenRouterModelEndpoints(modelId), - }), -); - -export const saveConnectionFx = createEffect( - async (params: { - scope: LlmScope; - scopeId: string; - providerId: LlmProviderId; - tokenId: string | null; - model: string | null; - config: LlmProviderConfig; - preset?: { presetId: string; payload: LlmPresetPayload }; - }) => { - const config = await llmApi.patchProviderConfig(params.providerId, params.config); - const runtime = await llmApi.patchRuntime({ - scope: params.scope, - scopeId: params.scopeId, - activeProviderId: params.providerId, - activeTokenId: params.tokenId, - activeModel: params.model, - }); - const preset = params.preset - ? await llmApi.updateLlmPreset({ - presetId: params.preset.presetId, - ownerId: 'global', - payload: params.preset.payload, - }) - : null; - return { config, runtime, preset }; - }, -); - -export const loadLlmPresetsFx = createEffect(async (): Promise => { - return llmApi.listLlmPresets('global'); -}); - -export const loadLlmPresetSettingsFx = createEffect(async (): Promise => { - return llmApi.getLlmPresetSettings('global'); -}); - -export const createLlmPresetFx = createEffect( - async (params: { name: string; description?: string; payload: LlmPresetPayload }): Promise => { - return llmApi.createLlmPreset({ - ownerId: 'global', - name: params.name, - description: params.description, - payload: params.payload, - }); - }, -); - -export const updateLlmPresetFx = createEffect( - async (params: { - presetId: string; - name?: string; - description?: string | null; - payload?: LlmPresetPayload; - }): Promise => { - return llmApi.updateLlmPreset({ - ownerId: 'global', - presetId: params.presetId, - name: params.name, - description: params.description, - payload: params.payload, - }); - }, -); - -export const deleteLlmPresetFx = createEffect(async (presetId: string): Promise<{ id: string }> => { - return llmApi.deleteLlmPreset({ ownerId: 'global', presetId }); -}); - -export const applyLlmPresetFx = createEffect( - async (params: { - presetId: string; - scope: LlmScope; - scopeId: string; - }): Promise<{ - preset: llmApi.LlmPresetDto; - runtime: LlmRuntime; - warnings: string[]; - }> => { - return llmApi.applyLlmPreset({ - ownerId: 'global', - presetId: params.presetId, - scope: params.scope, - scopeId: params.scopeId, - }); - }, -); - -export const patchLlmPresetSettingsFx = createEffect( - async (params: { activePresetId?: string | null }): Promise => { - return llmApi.patchLlmPresetSettings({ - ownerId: 'global', - activePresetId: params.activePresetId, - }); - }, -); - export const providerPickerMounted = createEvent<{ scope: LlmScope; scopeId: string }>(); -export const providerSelected = createEvent<{ scope: LlmScope; scopeId: string; providerId: LlmProviderId }>(); -export const tokenSelected = createEvent<{ scope: LlmScope; scopeId: string; tokenId: string | null }>(); -export const modelSelected = createEvent<{ scope: LlmScope; scopeId: string; model: string | null }>(); -export const tokenManagerOpened = createEvent(); export const $providers = createStore([]); export const $runtimeByScopeKey = createStore>({} as Record); @@ -227,54 +61,46 @@ export const $tokensByProviderId = createStore>({}); export const $openRouterEndpointsByModel = createStore>({}); -export const $isTokenManagerOpen = createStore(false); -export const $llmPresets = createStore([]); -export const $llmPresetSettings = createStore(null); +export const $llmPresets = createStore([]); +export const $llmPresetSettings = createStore(null); -$providers.on(loadProvidersFx.doneData, (_, providers) => providers); +$providers.on([loadProvidersFx.doneData, ensureProvidersFx.doneData], (_, providers) => providers); -$runtimeByScopeKey.on(loadRuntimeFx.doneData, (state, runtime) => ({ - ...state, - [toScopeKey(runtime.scope, runtime.scopeId)]: runtime, -})); -$runtimeByScopeKey.on(patchRuntimeFx.doneData, (state, runtime) => ({ +$runtimeByScopeKey.on([loadRuntimeFx.doneData, ensureRuntimeFx.doneData], (state, runtime) => ({ ...state, [toScopeKey(runtime.scope, runtime.scopeId)]: runtime, })); - -$providerConfigById.on(loadProviderConfigFx.doneData, (state, payload) => ({ - ...state, - [payload.providerId]: payload.config, -})); -$providerConfigById.on(patchProviderConfigFx.doneData, (state, payload) => ({ +$providerConfigById.on([loadProviderConfigFx.doneData, ensureProviderConfigFx.doneData], (state, payload) => ({ ...state, [payload.providerId]: payload.config, })); -$tokensByProviderId.on(loadTokensFx.doneData, (state, payload) => ({ +$tokensByProviderId.on([loadTokensFx.doneData, ensureTokensFx.doneData], (state, payload) => ({ ...state, [payload.providerId]: payload.tokens, })); -$modelsByProviderTokenKey.on(loadModelsFx.doneData, (state, payload) => ({ +$modelsByProviderTokenKey.on([loadModelsFx.doneData, ensureModelsFx.doneData], (state, payload) => ({ ...state, [payload.key]: payload.models, })); -$isTokenManagerOpen.on(tokenManagerOpened, (_, isOpen) => isOpen); -$llmPresets.on(loadLlmPresetsFx.doneData, (_, presets) => presets); +$llmPresets.on([loadLlmPresetsFx.doneData, ensureLlmPresetsFx.doneData], (_, presets) => presets); $llmPresetSettings - .on(loadLlmPresetSettingsFx.doneData, (_, settings) => settings) + .on([loadLlmPresetSettingsFx.doneData, ensureLlmPresetSettingsFx.doneData], (_, settings) => settings) .on(patchLlmPresetSettingsFx.doneData, (_, settings) => settings); $runtimeByScopeKey.on(applyLlmPresetFx.doneData, (state, payload) => ({ ...state, [toScopeKey(payload.runtime.scope, payload.runtime.scopeId)]: payload.runtime, })); -$openRouterEndpointsByModel.on(loadOpenRouterEndpointsFx.doneData, (state, payload) => ({ +$openRouterEndpointsByModel.on( + [loadOpenRouterEndpointsFx.doneData, ensureOpenRouterEndpointsFx.doneData], + (state, payload) => ({ ...state, [payload.modelId]: payload.endpoints, -})); +}), +); $providerConfigById.on(saveConnectionFx.doneData, (state, payload) => ({ ...state, @@ -290,88 +116,28 @@ $llmPresets.on(saveConnectionFx.doneData, (state, payload) => sample({ clock: providerPickerMounted, - target: loadProvidersFx, + target: ensureProvidersFx, }); sample({ clock: providerPickerMounted, - target: [loadLlmPresetsFx, loadLlmPresetSettingsFx], + target: [ensureLlmPresetsFx, ensureLlmPresetSettingsFx], }); sample({ clock: providerPickerMounted, fn: ({ scope, scopeId }) => ({ scope, scopeId }), - target: loadRuntimeFx, + target: ensureRuntimeFx, }); sample({ - clock: loadRuntimeFx.doneData, + clock: [loadRuntimeFx.doneData, ensureRuntimeFx.doneData], fn: (runtime) => runtime.activeProviderId, - target: [loadTokensFx, loadProviderConfigFx], -}); - -sample({ - clock: loadRuntimeFx.doneData, - filter: (runtime) => Boolean(runtime.activeTokenId), - fn: (runtime) => ({ - providerId: runtime.activeProviderId, - scope: runtime.scope, - scopeId: runtime.scopeId, - tokenId: runtime.activeTokenId, - }), - target: loadModelsFx, + target: [ensureTokensFx, ensureProviderConfigFx], }); sample({ - clock: providerSelected, - fn: ({ scope, scopeId, providerId }) => ({ - scope, - scopeId, - activeProviderId: providerId, - }), - target: patchRuntimeFx, -}); - -sample({ - clock: providerSelected, - fn: ({ providerId }) => providerId, - target: [loadTokensFx, loadProviderConfigFx], -}); - -sample({ - clock: tokenSelected, - source: $runtimeByScopeKey, - fn: (runtimeByKey, { scope, scopeId, tokenId }) => { - const current = runtimeByKey[toScopeKey(scope, scopeId)]; - return { - scope, - scopeId, - activeProviderId: current?.activeProviderId ?? 'openrouter', - activeTokenId: tokenId, - activeModel: current?.activeModel ?? null, - }; - }, - target: patchRuntimeFx, -}); - -sample({ - clock: modelSelected, - source: $runtimeByScopeKey, - fn: (runtimeByKey, { scope, scopeId, model }) => { - const current = runtimeByKey[toScopeKey(scope, scopeId)]; - return { - scope, - scopeId, - activeProviderId: current?.activeProviderId ?? 'openrouter', - activeTokenId: current?.activeTokenId ?? null, - activeModel: model, - }; - }, - target: patchRuntimeFx, -}); - -sample({ - clock: patchRuntimeFx.doneData, + clock: [loadRuntimeFx.doneData, ensureRuntimeFx.doneData], filter: (runtime) => Boolean(runtime.activeTokenId), fn: (runtime) => ({ providerId: runtime.activeProviderId, @@ -379,7 +145,7 @@ sample({ scopeId: runtime.scopeId, tokenId: runtime.activeTokenId, }), - target: loadModelsFx, + target: ensureModelsFx, }); sample({ @@ -417,7 +183,13 @@ sample({ sample({ clock: applyLlmPresetFx.doneData, fn: (payload) => payload.runtime.activeProviderId, - target: [loadTokensFx, loadProviderConfigFx], + target: ensureTokensFx, +}); + +sample({ + clock: applyLlmPresetFx.doneData, + fn: (payload) => payload.runtime.activeProviderId, + target: loadProviderConfigFx, }); export const llmProviderModel = { @@ -427,32 +199,34 @@ export const llmProviderModel = { $tokensByProviderId, $modelsByProviderTokenKey, $openRouterEndpointsByModel, - $isTokenManagerOpen, $llmPresets, $llmPresetSettings, providerPickerMounted, - providerSelected, - tokenSelected, - modelSelected, - tokenManagerOpened, loadProvidersFx, + ensureProvidersFx, loadRuntimeFx, + ensureRuntimeFx, loadRuntimeProviderStateFx, - patchRuntimeFx, + ensureRuntimeProviderStateFx, loadTokensFx, + ensureTokensFx, createTokenFx, patchTokenFx, deleteTokenFx, loadModelsFx, + ensureModelsFx, loadOpenRouterEndpointsFx, + ensureOpenRouterEndpointsFx, saveConnectionFx, loadProviderConfigFx, - patchProviderConfigFx, + ensureProviderConfigFx, checkProviderConnectionFx, loadLlmPresetsFx, + ensureLlmPresetsFx, loadLlmPresetSettingsFx, + ensureLlmPresetSettingsFx, createLlmPresetFx, updateLlmPresetFx, deleteLlmPresetFx, diff --git a/web/src/model/provider/resource-effects.test.ts b/web/src/model/provider/resource-effects.test.ts new file mode 100644 index 00000000..24ec7aa9 --- /dev/null +++ b/web/src/model/provider/resource-effects.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + listTokens: vi.fn(), +})); + +vi.mock('../../api/llm', () => ({ + listTokens: mocks.listTokens, +})); + +import { ensureTokensFx, loadTokensFx } from './resource-effects'; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.listTokens.mockResolvedValue([]); +}); + +describe('LLM resource effects', () => { + test('treats an empty token list as cached and refreshes only when forced', async () => { + await expect(ensureTokensFx('openrouter')).resolves.toEqual({ providerId: 'openrouter', tokens: [] }); + await expect(ensureTokensFx('openrouter')).resolves.toEqual({ providerId: 'openrouter', tokens: [] }); + expect(mocks.listTokens).toHaveBeenCalledTimes(1); + + await expect(loadTokensFx('openrouter')).resolves.toEqual({ providerId: 'openrouter', tokens: [] }); + expect(mocks.listTokens).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/model/provider/resource-effects.ts b/web/src/model/provider/resource-effects.ts new file mode 100644 index 00000000..c0c09b12 --- /dev/null +++ b/web/src/model/provider/resource-effects.ts @@ -0,0 +1,148 @@ +import { createEffect } from 'effector'; + +import * as llmApi from '../../api/llm'; + +import { AsyncResourceCache } from './async-resource-cache'; + +import type { + LlmModel, + LlmOpenRouterEndpoint, + LlmProviderConfig, + LlmProviderDefinition, + LlmProviderId, + LlmRuntime, + LlmRuntimeProviderState, + LlmScope, + LlmTokenListItem, +} from '@shared/types/llm'; + +type RuntimeParams = { scope: LlmScope; scopeId: string }; +export type ModelsParams = RuntimeParams & { providerId: LlmProviderId; tokenId?: string | null }; +type ProviderConfigResult = { providerId: LlmProviderId; config: LlmProviderConfig }; +type TokensResult = { providerId: LlmProviderId; tokens: LlmTokenListItem[] }; +type ModelsResult = { key: string; models: LlmModel[] }; +type EndpointsResult = { modelId: string; endpoints: LlmOpenRouterEndpoint[] }; + +const providersCache = new AsyncResourceCache<'all', LlmProviderDefinition[]>(); +const runtimeCache = new AsyncResourceCache(); +const runtimeProviderStateCache = new AsyncResourceCache(); +const configCache = new AsyncResourceCache(); +const tokensCache = new AsyncResourceCache(); +const modelsCache = new AsyncResourceCache(); +const endpointsCache = new AsyncResourceCache(); +const presetsCache = new AsyncResourceCache<'global', llmApi.LlmPresetDto[]>(); +const presetSettingsCache = new AsyncResourceCache<'global', llmApi.LlmPresetSettingsDto>(); + +const runtimeKey = ({ scope, scopeId }: RuntimeParams) => `${scope}:${scopeId}`; +export const providerTokenKey = (providerId: LlmProviderId, tokenId?: string | null) => + `${providerId}:${tokenId ?? 'none'}`; + +export const loadProvidersFx = createEffect(() => providersCache.load('all', llmApi.getProviders, true)); +export const ensureProvidersFx = createEffect(() => providersCache.load('all', llmApi.getProviders)); + +const runtimeProviderStateKey = (params: RuntimeParams & { providerId: LlmProviderId }) => + `${runtimeKey(params)}:${params.providerId}`; +const fetchRuntime = async (params: RuntimeParams) => { + const runtime = await llmApi.getRuntime(params); + cacheRuntime(runtime); + return runtime; +}; +export const loadRuntimeFx = createEffect((params: RuntimeParams) => + runtimeCache.load(runtimeKey(params), () => fetchRuntime(params), true), +); +export const ensureRuntimeFx = createEffect((params: RuntimeParams) => + runtimeCache.load(runtimeKey(params), () => fetchRuntime(params)), +); + +const fetchRuntimeProviderState = (params: RuntimeParams & { providerId: LlmProviderId }) => + llmApi.getRuntimeProviderState(params); +export const loadRuntimeProviderStateFx = createEffect((params: RuntimeParams & { providerId: LlmProviderId }) => + runtimeProviderStateCache.load(runtimeProviderStateKey(params), () => fetchRuntimeProviderState(params), true), +); +export const ensureRuntimeProviderStateFx = createEffect((params: RuntimeParams & { providerId: LlmProviderId }) => + runtimeProviderStateCache.load(runtimeProviderStateKey(params), () => fetchRuntimeProviderState(params)), +); + +const fetchConfig = (providerId: LlmProviderId) => llmApi.getProviderConfig(providerId); +export const loadProviderConfigFx = createEffect((providerId: LlmProviderId) => + configCache.load(providerId, () => fetchConfig(providerId), true), +); +export const ensureProviderConfigFx = createEffect((providerId: LlmProviderId) => + configCache.load(providerId, () => fetchConfig(providerId)), +); + +const fetchTokens = async (providerId: LlmProviderId): Promise => ({ + providerId, + tokens: await llmApi.listTokens(providerId), +}); +export const loadTokensFx = createEffect((providerId: LlmProviderId) => + tokensCache.load(providerId, () => fetchTokens(providerId), true), +); +export const ensureTokensFx = createEffect((providerId: LlmProviderId) => + tokensCache.load(providerId, () => fetchTokens(providerId)), +); + +const fetchModels = async (params: ModelsParams): Promise => ({ + key: providerTokenKey(params.providerId, params.tokenId), + models: await llmApi.getModels(params), +}); +export const loadModelsFx = createEffect((params: ModelsParams) => { + const key = providerTokenKey(params.providerId, params.tokenId); + return modelsCache.load(key, () => fetchModels(params), true); +}); +export const ensureModelsFx = createEffect((params: ModelsParams) => { + const key = providerTokenKey(params.providerId, params.tokenId); + return modelsCache.load(key, () => fetchModels(params)); +}); + +const fetchEndpoints = async (modelId: string): Promise => ({ + modelId, + endpoints: await llmApi.getOpenRouterModelEndpoints(modelId), +}); +export const loadOpenRouterEndpointsFx = createEffect((modelId: string) => + endpointsCache.load(modelId, () => fetchEndpoints(modelId), true), +); +export const ensureOpenRouterEndpointsFx = createEffect((modelId: string) => + endpointsCache.load(modelId, () => fetchEndpoints(modelId)), +); + +export const loadLlmPresetsFx = createEffect(() => + presetsCache.load('global', () => llmApi.listLlmPresets('global'), true), +); +export const ensureLlmPresetsFx = createEffect(() => + presetsCache.load('global', () => llmApi.listLlmPresets('global')), +); +export const loadLlmPresetSettingsFx = createEffect(() => + presetSettingsCache.load('global', () => llmApi.getLlmPresetSettings('global'), true), +); +export const ensureLlmPresetSettingsFx = createEffect(() => + presetSettingsCache.load('global', () => llmApi.getLlmPresetSettings('global')), +); + +export function cacheRuntime(runtime: LlmRuntime): void { + runtimeCache.set(runtimeKey(runtime), runtime); + runtimeProviderStateCache.set(runtimeProviderStateKey({ ...runtime, providerId: runtime.activeProviderId }), { + scope: runtime.scope, + scopeId: runtime.scopeId, + providerId: runtime.activeProviderId, + lastTokenId: runtime.activeTokenId, + lastModel: runtime.activeModel, + }); +} + +export function cacheProviderConfig(result: ProviderConfigResult): void { + configCache.set(result.providerId, result); +} + +export function cachePreset(preset: llmApi.LlmPresetDto): void { + const current = presetsCache.peek('global'); + if (!current) return; + presetsCache.set( + 'global', + current.map((item) => (item.presetId === preset.presetId ? preset : item)), + ); +} + +export function cachePresetSettings(settings: llmApi.LlmPresetSettingsDto): void { + presetSettingsCache.set('global', settings); +} From d6bc4293ccab33f12c061ebff98eca5cbdc0bfde Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 19 Jul 2026 01:45:37 +0300 Subject: [PATCH 11/14] fix: harden operations security and resource limits --- BACKEND_OPERATIONS_AUDIT_2026-07-10.md | 50 ++++-- docs/docs/dev/backend/api-endpoints.md | 132 +++++++++------- docs/docs/dev/backend/api-overview.md | 20 +++ docs/docs/user/operations.md | 17 +- .../current/dev/backend/api-endpoints.md | 132 +++++++++------- .../current/dev/backend/api-overview.md | 20 +++ .../current/user/operations.md | 17 +- server/src/api/operation-blocks.core.api.ts | 10 +- server/src/api/operation-profiles.core.api.ts | 28 +++- server/src/app.ts | 15 +- .../get-chat-operation-runtime-state.ts | 7 +- .../use-cases/delete-operation-block.ts | 5 +- .../use-cases/export-operation-profile.ts | 12 +- .../use-cases/set-active-operation-profile.ts | 15 +- .../network/server-network-policy.test.ts | 60 +++++++ .../src/core/network/server-network-policy.ts | 63 ++++++++ .../request-context/request-context.test.ts | 20 +++ .../core/request-context/request-context.ts | 3 +- .../bundles/bundle-import-export.test.ts | 7 +- .../bundles/export-bundle-selection.ts | 15 +- .../profile-session-artifact-store.test.ts | 23 +++ .../profile-session-artifact-store.ts | 10 ++ .../artifacts/run-artifact-store.test.ts | 29 ++++ .../artifacts/run-artifact-store.ts | 10 +- .../execute-operations-phase.test.ts | 98 ++++++++++++ .../operations/execute-operations-phase.ts | 6 + .../operations/llm-operation-executor.ts | 15 ++ .../prepare/resolve-run-context.test.ts | 5 +- .../prepare/resolve-run-context.ts | 7 +- .../operations/guard-operation-params.ts | 16 +- .../operations/llm-operation-params.ts | 38 ++++- .../operation-block-validator.test.ts | 65 ++++++++ .../operations/operation-block-validator.ts | 32 +++- .../operations/operation-blocks-repository.ts | 66 +++++--- .../operation-owner-scope.integration.test.ts | 149 ++++++++++++++++++ .../operation-profile-resolver.test.ts | 5 +- .../operations/operation-profile-resolver.ts | 22 ++- .../operation-profile-settings-repository.ts | 19 +-- .../operation-profile-validator.test.ts | 18 +++ .../operations/operation-profile-validator.ts | 15 +- .../operation-profiles-repository.ts | 76 ++++++--- .../operations/operation-resource-limits.ts | 68 ++++++++ 42 files changed, 1202 insertions(+), 238 deletions(-) create mode 100644 server/src/core/network/server-network-policy.test.ts create mode 100644 server/src/core/network/server-network-policy.ts create mode 100644 server/src/core/request-context/request-context.test.ts create mode 100644 server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.test.ts create mode 100644 server/src/services/operations/operation-owner-scope.integration.test.ts create mode 100644 server/src/services/operations/operation-resource-limits.ts diff --git a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md index 07c8d785..e2717039 100644 --- a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md +++ b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md @@ -45,6 +45,10 @@ | OPS-001 | Выполнено | `baa690b` | Preparation failures наблюдаемы в SSE; созданная generation финализируется; пустой assistant scaffolding удаляется при ошибке до создания generation. | | OPS-002 | Выполнено | `3dad07e` | Assistant rewrite сохраняется в main-part; required/optional persistence failures соблюдают policy; UI получает `turn.assistant.canonicalized`. | | OPS-026 | Выполнено | Текущие изменения | `operation.finished` передаёт stable error code, безопасное сообщение и abort reason; frontend показывает причину и сохраняет её в Run Trace; required barrier перечисляет проблемные operation IDs. | +| OPS-003 | Выполнено | Текущие изменения | Backend слушает loopback по умолчанию; LAN требует opt-in; CORS использует allowlist и отклоняет чужие origins; request payload не переопределяет trusted owner. | +| OPS-004 | Выполнено | Текущие изменения | Profile/block CRUD, export, activation, settings, bundle export и runtime resolution используют обязательный owner scope; cross-owner block refs запрещены. | +| OPS-008 | Выполнено | Текущие изменения | Concurrent operation execution ограничен четырьмя задачами; queued DAG tasks сохраняют прежнюю abort semantics. | +| OPS-009 | Выполнено | Текущие изменения | Добавлены лимиты blocks/operations/dependencies/templates/retries/LLM output, artifact values и history; лимиты проверяются на validation/runtime boundaries. | Проверки после OPS-002: @@ -62,16 +66,20 @@ Phase A завершена: terminal generation state, assistant rewrite persistence и actionable operation errors покрыты regression-тестами. +Проверки после OPS-003, OPS-004, OPS-008 и OPS-009: + +- backend: 106 test files, 544 tests passed; +- `yarn verify:server` и `yarn build:server` прошли; +- `yarn docs:check` прошёл для RU и EN. + ### Важные незакрытые задачи -Следующий приоритетный batch — Phase B. Наиболее важный остаток: +Security и bounded-execution core из Phase B завершены. Наиболее важный остаток: -1. [ ] **OPS-004 (P0): owner scope во всех operation repositories и runtime resolution.** Сейчас cross-owner read/update/export и использование чужого active profile не исключены на уровне repository contract. -2. [ ] **OPS-003 (P0): строгая local network boundary.** Backend должен слушать loopback по умолчанию, LAN mode требовать opt-in, а CORS и owner identity — перестать доверять произвольному caller. -3. [ ] **OPS-008 + OPS-009 (P1): bounded execution.** Нужны conservative concurrency cap и лимиты размера profile, operation output и artifact history, иначе один профиль может породить неконтролируемое число provider calls и рост памяти/БД. -4. [ ] **OPS-014 (P1): compile/validate profile до activation.** Сохранённый активный профиль обязан гарантированно компилироваться до generation time; вместе с concurrency cap это оставшийся хвост первоначального узкого fix batch. -5. [ ] **OPS-005–OPS-007 + OPS-020 (P1): transaction/state correctness.** Knowledge mutations, activation counters и effects ещё способны оставить partial state или потерять updates при ошибках и concurrent runs. -6. [ ] **OPS-021 + OPS-022 (P1): atomic import/cutover и optimistic concurrency.** Ошибка multi-write import оставляет orphan/partial records, а параллельное редактирование profile/block молча перетирает изменения. +1. [ ] **OPS-014 (P1): compile/validate profile при create/update/import.** Activation теперь компилирует профиль заранее, но невалидную композицию всё ещё можно сохранить как неактивную. +2. [ ] **OPS-005–OPS-007 + OPS-020 (P1): transaction/state correctness.** Knowledge mutations, activation counters и effects ещё способны оставить partial state или потерять updates при ошибках и concurrent runs. +3. [ ] **OPS-021 + OPS-022 (P1): atomic import/cutover и optimistic concurrency.** Ошибка multi-write import оставляет orphan/partial records, а параллельное редактирование profile/block молча перетирает изменения. +4. [ ] **OPS-013 (P1): строгая sampler validation.** Общие execution limits уже действуют, но provider samplers всё ещё требуют корректных диапазонов. После security и bounded-execution batch следует переходить к полной transaction redesign, а не смешивать её с небольшими contract fixes. @@ -181,7 +189,9 @@ Operation может закончиться как `done`, effect — как `ap - required rewrite failure завершает run ошибкой; - optional failure сохраняет исходный assistant text. -### OPS-003. Local backend не имеет строгой сетевой границы +### OPS-003. Local backend не имеет строгой сетевой границы — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -207,7 +217,9 @@ Operation может закончиться как `done`, effect — как `ap - неподтверждённые origins отклоняются; - body не может переопределить trusted owner scope. -### OPS-004. Operation repositories не соблюдают owner scope +### OPS-004. Operation repositories не соблюдают owner scope — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -290,7 +302,9 @@ Effects применяются по одному. При ошибке она з - формализовать policy: `atomic_per_operation`, `atomic_per_hook` или `best_effort`; - для required operations по умолчанию использовать atomic semantics. -### OPS-008. `concurrent` запускает неограниченное число операций +### OPS-008. `concurrent` запускает неограниченное число операций — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -315,7 +329,9 @@ Runtime не передаёт `concurrency`, поэтому orchestrator исп - предупреждать или отклонять чрезмерно дорогие profiles; - сохранять abort semantics для queued tasks. -### OPS-009. Нет resource limits для operation profile и artifacts +### OPS-009. Нет resource limits для operation profile и artifacts — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -951,10 +967,10 @@ Config revision и manual reset должны иметь раздельно оп ### Phase B. Security и bounded execution -1. Исправить owner scope. -2. Bind на loopback и ограничить CORS. -3. Ввести concurrency cap. -4. Добавить profile/artifact resource limits. +1. [x] Исправить owner scope. +2. [x] Bind на loopback и ограничить CORS. +3. [x] Ввести concurrency cap. +4. [x] Добавить profile/artifact resource limits. 5. Усилить sampler validation. Критерии завершения: @@ -1018,8 +1034,8 @@ Config revision и manual reset должны иметь раздельно оп 2. [x] Изменить `runChatGenerationV3`, чтобы все failure paths завершались наблюдаемо. 3. [x] Persist-ить `turn.assistant.replace_text` через отдельный handler. 4. [x] Передавать error information в `operation.finished`. -5. [ ] Compile/validate profile до activation. -6. [ ] Добавить conservative concurrency cap. +5. [x] Compile/validate profile до activation. +6. [x] Добавить conservative concurrency cap. Этот batch исправит пользовательскую correctness, не требуя одновременно завершать полную transaction redesign. diff --git a/docs/docs/dev/backend/api-endpoints.md b/docs/docs/dev/backend/api-endpoints.md index 826b0ab1..ecec5d79 100644 --- a/docs/docs/dev/backend/api-endpoints.md +++ b/docs/docs/dev/backend/api-endpoints.md @@ -17,9 +17,25 @@ description: Автогенерируемый инвентарь backend endpoin | Method | Path | Source file | Handler section | | --- | --- | --- | --- | +| GET | /api/app-backgrounds | `server/src/api/app-backgrounds.core.api.ts` | L68 | +| DELETE | /api/app-backgrounds/:id | `server/src/api/app-backgrounds.core.api.ts` | L102 | +| PUT | /api/app-backgrounds/active | `server/src/api/app-backgrounds.core.api.ts` | L92 | +| POST | /api/app-backgrounds/import | `server/src/api/app-backgrounds.core.api.ts` | L76 | | USE | /api/app-settings | `server/src/api/app-settings.api.ts` | L7 | | POST | /api/bundles/export | `server/src/api/bundles.core.api.ts` | L46 | | POST | /api/bundles/import | `server/src/api/bundles.core.api.ts` | L69 | +| GET | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L182 | +| POST | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L196 | +| GET | /api/chat-knowledge/collections/:id | `server/src/api/chat-knowledge.core.api.ts` | L211 | +| GET | /api/chat-knowledge/collections/:id/export | `server/src/api/chat-knowledge.core.api.ts` | L221 | +| POST | /api/chat-knowledge/collections/import | `server/src/api/chat-knowledge.core.api.ts` | L241 | +| GET | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L332 | +| POST | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L347 | +| GET | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L257 | +| POST | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L274 | +| GET | /api/chat-knowledge/records/:id | `server/src/api/chat-knowledge.core.api.ts` | L306 | +| POST | /api/chat-knowledge/records/search | `server/src/api/chat-knowledge.core.api.ts` | L316 | +| POST | /api/chat-knowledge/reveal | `server/src/api/chat-knowledge.core.api.ts` | L363 | | DELETE | /api/chats/:id | `server/src/api/chats.core.api.ts` | L91 | | GET | /api/chats/:id | `server/src/api/chats.core.api.ts` | L29 | | PUT | /api/chats/:id | `server/src/api/chats.core.api.ts` | L76 | @@ -28,13 +44,13 @@ description: Автогенерируемый инвентарь backend endpoin | DELETE | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L178 | | PUT | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L159 | | POST | /api/chats/:id/branches/:branchId/activate | `server/src/api/chats.core.api.ts` | L135 | -| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L206 | -| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L230 | -| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L258 | +| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L62 | +| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L86 | +| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L114 | | PUT | /api/chats/:id/instruction | `server/src/api/chats.core.api.ts` | L44 | -| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L335 | -| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L350 | -| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L257 | +| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L191 | +| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L206 | +| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L288 | | GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L142 | | POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L150 | | DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L258 | @@ -44,16 +60,16 @@ description: Автогенерируемый инвентарь backend endpoin | POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L291 | | GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L223 | | POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L62 | -| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L421 | -| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L436 | -| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L313 | -| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L507 | -| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L287 | -| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L493 | -| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L365 | -| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L383 | -| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L453 | -| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L480 | +| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L298 | +| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L313 | +| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L169 | +| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L384 | +| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L143 | +| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L370 | +| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L221 | +| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L239 | +| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L330 | +| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L357 | | GET | /api/files/metadata/:filename | `server/src/api/files/routes.ts` | L71 | | POST | /api/files/upload | `server/src/api/files/routes.ts` | L53 | | POST | /api/files/upload-card | `server/src/api/files/routes.ts` | L59 | @@ -72,35 +88,37 @@ description: Автогенерируемый инвентарь backend endpoin | DELETE | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L132 | | PUT | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L108 | | POST | /api/llm-presets/:id/apply | `server/src/api/llm-presets.api.ts` | L150 | -| GET | /api/llm/models | `server/src/api/llm.api.ts` | L216 | -| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L53 | -| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L132 | -| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L103 | -| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L113 | -| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L68 | -| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L86 | -| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L154 | -| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | -| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L207 | -| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L193 | +| GET | /api/llm/models | `server/src/api/llm.api.ts` | L234 | +| GET | /api/llm/openrouter/endpoints | `server/src/api/llm.api.ts` | L274 | +| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L62 | +| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L150 | +| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L121 | +| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L131 | +| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L77 | +| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L95 | +| GET | /api/llm/runtime/provider-state | `server/src/api/llm.api.ts` | L112 | +| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | +| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L190 | +| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L225 | +| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L211 | | GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L33 | | POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L41 | -| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L79 | +| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L82 | | GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L53 | -| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L64 | -| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L93 | -| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L118 | +| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L67 | +| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L96 | +| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L124 | | GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L36 | | POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L44 | -| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L107 | -| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L81 | -| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L92 | -| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L121 | +| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L115 | +| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L86 | +| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L100 | +| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L132 | | GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L58 | -| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L70 | -| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L135 | -| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L530 | -| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L545 | +| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L72 | +| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L151 | +| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L407 | +| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L422 | | GET | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L77 | | POST | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L84 | | DELETE | /api/rag/chroma/collections/:name | `server/src/api/rag-chroma.api.ts` | L96 | @@ -110,24 +128,27 @@ description: Автогенерируемый инвентарь backend endpoin | GET | /api/rag/chroma/health | `server/src/api/rag-chroma.api.ts` | L70 | | POST | /api/rag/chroma/query | `server/src/api/rag-chroma.api.ts` | L145 | | POST | /api/rag/chroma/world-info/reindex | `server/src/api/rag-chroma.api.ts` | L161 | -| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L168 | -| GET | /api/rag/models | `server/src/api/rag.api.ts` | L74 | -| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L93 | -| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L98 | -| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L116 | -| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L102 | -| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L145 | -| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L46 | -| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L58 | -| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | -| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L50 | -| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L54 | -| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L68 | +| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L188 | +| GET | /api/rag/models | `server/src/api/rag.api.ts` | L94 | +| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L113 | +| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L118 | +| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L136 | +| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L122 | +| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L165 | +| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L51 | +| POST | /api/rag/providers/:providerId/check | `server/src/api/rag.api.ts` | L73 | +| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | +| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L68 | +| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L55 | +| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L59 | +| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L88 | | USE | /api/settings | `server/src/api/settings.api.ts` | L7 | -| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L124 | -| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L129 | +| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L144 | +| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L149 | | GET | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L105 | | POST | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L117 | +| POST | /api/sillytavern-import/import | `server/src/api/sillytavern-import.api.ts` | L47 | +| POST | /api/sillytavern-import/scan | `server/src/api/sillytavern-import.api.ts` | L34 | | GET | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L39 | | POST | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L49 | | DELETE | /api/ui-theme-presets/:id | `server/src/api/ui-theme.core.api.ts` | L88 | @@ -154,7 +175,8 @@ description: Автогенерируемый инвентарь backend endpoin | POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L372 | | GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L320 | | PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L330 | -| USE | /media | `server/src/api/static.api.ts` | L21 | +| USE | /defaults/backgrounds | `server/src/api/static.api.ts` | L36 | +| USE | /media | `server/src/api/static.api.ts` | L22 | ## Notes diff --git a/docs/docs/dev/backend/api-overview.md b/docs/docs/dev/backend/api-overview.md index 3495c0dd..50c7d2aa 100644 --- a/docs/docs/dev/backend/api-overview.md +++ b/docs/docs/dev/backend/api-overview.md @@ -22,6 +22,26 @@ app.use('/api', routes) Поэтому endpoint вида `/chats/:id` в router-файле становится `/api/chats/:id`. +## Сетевая граница + +По умолчанию backend слушает только `127.0.0.1`. Значение `TALESPINNER_HOST` +игнорируется, пока явно не включён LAN-режим: + +```bash +TALESPINNER_LAN_MODE=true +TALESPINNER_HOST=0.0.0.0 +TALESPINNER_CORS_ORIGINS=http://192.168.1.20:5173 +``` + +`TALESPINNER_CORS_ORIGINS` — список разрешённых origin через запятую. Без этой +настройки разрешены только dev-origin `http://localhost:5173` и +`http://127.0.0.1:5173`; запросы без `Origin` разрешены для локальных native-клиентов. +Запрос с другим origin отклоняется с `403`. + +LAN-режим открывает API другим устройствам в сети. В текущей локальной модели +полноценной аутентификации нет, поэтому включайте его только в доверенной сети и +защищайте порт системным firewall. + ## Основные группы API - chats / branches diff --git a/docs/docs/user/operations.md b/docs/docs/user/operations.md index f2b76319..33846d06 100644 --- a/docs/docs/user/operations.md +++ b/docs/docs/user/operations.md @@ -50,8 +50,23 @@ Operations - это встроенный pipeline вокруг основной - `template` - `llm` +- `guard` +- `knowledge_search` +- `knowledge_reveal` -Остальные kind пока отображаются в UI как draft и пропускаются при исполнении. +Остальные kind пока пропускаются при исполнении. + +Для защиты от чрезмерной нагрузки действуют системные лимиты: + +- до 64 операций в блоке, 16 блоков и 128 скомпилированных операций в профиле; +- не более 4 одновременно исполняемых операций; +- до 100 элементов истории артефакта; +- до 256 КиБ на одно значение артефакта или ответ вспомогательной LLM; +- до 1 МиБ на сериализованную историю артефакта; +- до 100 000 символов в шаблоне или prompt; +- до 3 попыток и 120 секунд на вспомогательный LLM-вызов. + +Конфигурация, превышающая эти лимиты, отклоняется при сохранении или активации. ## Практическое преимущество перед ручной сборкой prompt diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md index e91da75e..9d2f1549 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md @@ -17,9 +17,25 @@ Sources: | Method | Path | Source file | Handler section | | --- | --- | --- | --- | +| GET | /api/app-backgrounds | `server/src/api/app-backgrounds.core.api.ts` | L68 | +| DELETE | /api/app-backgrounds/:id | `server/src/api/app-backgrounds.core.api.ts` | L102 | +| PUT | /api/app-backgrounds/active | `server/src/api/app-backgrounds.core.api.ts` | L92 | +| POST | /api/app-backgrounds/import | `server/src/api/app-backgrounds.core.api.ts` | L76 | | USE | /api/app-settings | `server/src/api/app-settings.api.ts` | L7 | | POST | /api/bundles/export | `server/src/api/bundles.core.api.ts` | L46 | | POST | /api/bundles/import | `server/src/api/bundles.core.api.ts` | L69 | +| GET | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L182 | +| POST | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L196 | +| GET | /api/chat-knowledge/collections/:id | `server/src/api/chat-knowledge.core.api.ts` | L211 | +| GET | /api/chat-knowledge/collections/:id/export | `server/src/api/chat-knowledge.core.api.ts` | L221 | +| POST | /api/chat-knowledge/collections/import | `server/src/api/chat-knowledge.core.api.ts` | L241 | +| GET | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L332 | +| POST | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L347 | +| GET | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L257 | +| POST | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L274 | +| GET | /api/chat-knowledge/records/:id | `server/src/api/chat-knowledge.core.api.ts` | L306 | +| POST | /api/chat-knowledge/records/search | `server/src/api/chat-knowledge.core.api.ts` | L316 | +| POST | /api/chat-knowledge/reveal | `server/src/api/chat-knowledge.core.api.ts` | L363 | | DELETE | /api/chats/:id | `server/src/api/chats.core.api.ts` | L91 | | GET | /api/chats/:id | `server/src/api/chats.core.api.ts` | L29 | | PUT | /api/chats/:id | `server/src/api/chats.core.api.ts` | L76 | @@ -28,13 +44,13 @@ Sources: | DELETE | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L178 | | PUT | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L159 | | POST | /api/chats/:id/branches/:branchId/activate | `server/src/api/chats.core.api.ts` | L135 | -| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L206 | -| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L230 | -| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L258 | +| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L62 | +| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L86 | +| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L114 | | PUT | /api/chats/:id/instruction | `server/src/api/chats.core.api.ts` | L44 | -| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L335 | -| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L350 | -| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L257 | +| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L191 | +| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L206 | +| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L288 | | GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L142 | | POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L150 | | DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L258 | @@ -44,16 +60,16 @@ Sources: | POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L291 | | GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L223 | | POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L62 | -| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L421 | -| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L436 | -| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L313 | -| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L507 | -| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L287 | -| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L493 | -| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L365 | -| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L383 | -| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L453 | -| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L480 | +| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L298 | +| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L313 | +| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L169 | +| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L384 | +| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L143 | +| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L370 | +| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L221 | +| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L239 | +| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L330 | +| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L357 | | GET | /api/files/metadata/:filename | `server/src/api/files/routes.ts` | L71 | | POST | /api/files/upload | `server/src/api/files/routes.ts` | L53 | | POST | /api/files/upload-card | `server/src/api/files/routes.ts` | L59 | @@ -72,35 +88,37 @@ Sources: | DELETE | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L132 | | PUT | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L108 | | POST | /api/llm-presets/:id/apply | `server/src/api/llm-presets.api.ts` | L150 | -| GET | /api/llm/models | `server/src/api/llm.api.ts` | L216 | -| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L53 | -| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L132 | -| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L103 | -| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L113 | -| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L68 | -| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L86 | -| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L154 | -| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | -| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L207 | -| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L193 | +| GET | /api/llm/models | `server/src/api/llm.api.ts` | L234 | +| GET | /api/llm/openrouter/endpoints | `server/src/api/llm.api.ts` | L274 | +| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L62 | +| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L150 | +| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L121 | +| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L131 | +| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L77 | +| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L95 | +| GET | /api/llm/runtime/provider-state | `server/src/api/llm.api.ts` | L112 | +| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | +| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L190 | +| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L225 | +| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L211 | | GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L33 | | POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L41 | -| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L79 | +| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L82 | | GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L53 | -| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L64 | -| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L93 | -| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L118 | +| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L67 | +| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L96 | +| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L124 | | GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L36 | | POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L44 | -| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L107 | -| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L81 | -| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L92 | -| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L121 | +| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L115 | +| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L86 | +| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L100 | +| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L132 | | GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L58 | -| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L70 | -| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L135 | -| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L530 | -| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L545 | +| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L72 | +| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L151 | +| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L407 | +| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L422 | | GET | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L77 | | POST | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L84 | | DELETE | /api/rag/chroma/collections/:name | `server/src/api/rag-chroma.api.ts` | L96 | @@ -110,24 +128,27 @@ Sources: | GET | /api/rag/chroma/health | `server/src/api/rag-chroma.api.ts` | L70 | | POST | /api/rag/chroma/query | `server/src/api/rag-chroma.api.ts` | L145 | | POST | /api/rag/chroma/world-info/reindex | `server/src/api/rag-chroma.api.ts` | L161 | -| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L168 | -| GET | /api/rag/models | `server/src/api/rag.api.ts` | L74 | -| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L93 | -| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L98 | -| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L116 | -| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L102 | -| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L145 | -| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L46 | -| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L58 | -| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | -| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L50 | -| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L54 | -| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L68 | +| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L188 | +| GET | /api/rag/models | `server/src/api/rag.api.ts` | L94 | +| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L113 | +| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L118 | +| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L136 | +| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L122 | +| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L165 | +| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L51 | +| POST | /api/rag/providers/:providerId/check | `server/src/api/rag.api.ts` | L73 | +| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | +| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L68 | +| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L55 | +| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L59 | +| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L88 | | USE | /api/settings | `server/src/api/settings.api.ts` | L7 | -| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L124 | -| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L129 | +| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L144 | +| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L149 | | GET | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L105 | | POST | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L117 | +| POST | /api/sillytavern-import/import | `server/src/api/sillytavern-import.api.ts` | L47 | +| POST | /api/sillytavern-import/scan | `server/src/api/sillytavern-import.api.ts` | L34 | | GET | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L39 | | POST | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L49 | | DELETE | /api/ui-theme-presets/:id | `server/src/api/ui-theme.core.api.ts` | L88 | @@ -154,7 +175,8 @@ Sources: | POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L372 | | GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L320 | | PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L330 | -| USE | /media | `server/src/api/static.api.ts` | L21 | +| USE | /defaults/backgrounds | `server/src/api/static.api.ts` | L36 | +| USE | /media | `server/src/api/static.api.ts` | L22 | ## Notes diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md index f6b0555b..47cf9f21 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md @@ -22,6 +22,26 @@ app.use('/api', routes) Therefore `/chats/:id` in router files becomes `/api/chats/:id`. +## Network boundary + +By default, the backend binds only to `127.0.0.1`. `TALESPINNER_HOST` is ignored +until LAN mode is explicitly enabled: + +```bash +TALESPINNER_LAN_MODE=true +TALESPINNER_HOST=0.0.0.0 +TALESPINNER_CORS_ORIGINS=http://192.168.1.20:5173 +``` + +`TALESPINNER_CORS_ORIGINS` is a comma-separated origin allowlist. Without this +setting, only the development origins `http://localhost:5173` and +`http://127.0.0.1:5173` are allowed; requests without an `Origin` header remain +available to local native clients. Requests from other origins receive `403`. + +LAN mode exposes the API to other devices on the network. The current local model +does not provide full authentication, so enable it only on a trusted network and +protect the port with the system firewall. + ## Main API groups - chats / branches diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md index 96dcbd0e..819646dc 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md @@ -50,8 +50,23 @@ Operation kinds currently executed in runtime: - `template` - `llm` +- `guard` +- `knowledge_search` +- `knowledge_reveal` -Other kinds are visible in UI as draft but skipped at execution. +Other kinds are skipped at execution. + +System limits protect the runtime from excessive resource use: + +- up to 64 operations per block, 16 blocks and 128 compiled operations per profile; +- at most 4 operations running concurrently; +- up to 100 artifact history items; +- up to 256 KiB per artifact value or auxiliary LLM response; +- up to 1 MiB of serialized artifact history; +- up to 100,000 characters per template or prompt; +- up to 3 attempts and 120 seconds per auxiliary LLM call. + +Configurations above these limits are rejected during save or activation. ## Practical advantage over manual prompt assembly diff --git a/server/src/api/operation-blocks.core.api.ts b/server/src/api/operation-blocks.core.api.ts index 8cfc6394..4a527028 100644 --- a/server/src/api/operation-blocks.core.api.ts +++ b/server/src/api/operation-blocks.core.api.ts @@ -55,7 +55,10 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const item = await getOperationBlockById(params.id); + const item = await getOperationBlockById({ + ownerId: getRequestOwnerId(req), + blockId: params.id, + }); if (!item) throw new HttpError(404, "OperationBlock не найден", "NOT_FOUND"); return { data: item }; }) @@ -95,7 +98,10 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const item = await getOperationBlockById(params.id); + const item = await getOperationBlockById({ + ownerId: getRequestOwnerId(req), + blockId: params.id, + }); if (!item) throw new HttpError(404, "OperationBlock не найден", "NOT_FOUND"); return { data: { diff --git a/server/src/api/operation-profiles.core.api.ts b/server/src/api/operation-profiles.core.api.ts index 4eaffcab..855706d3 100644 --- a/server/src/api/operation-profiles.core.api.ts +++ b/server/src/api/operation-profiles.core.api.ts @@ -57,8 +57,10 @@ router.post( router.get( "/operation-profiles/active", - asyncHandler(async () => { - const settings = await getOperationProfileSettings(); + asyncHandler(async (req: Request) => { + const settings = await getOperationProfileSettings({ + ownerId: getRequestOwnerId(req), + }); return { data: settings }; }) ); @@ -73,7 +75,10 @@ router.put( asyncHandler(async (req: Request) => { const body = setActiveBodySchema.parse(req.body); return { - data: await setActiveOperationProfileWithValidation(body.activeProfileId), + data: await setActiveOperationProfileWithValidation({ + ownerId: getRequestOwnerId(req), + activeProfileId: body.activeProfileId, + }), }; }) ); @@ -83,7 +88,10 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const item = await getOperationProfileById(params.id); + const item = await getOperationProfileById({ + ownerId: getRequestOwnerId(req), + profileId: params.id, + }); if (!item) throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); return { data: item }; }) @@ -109,7 +117,10 @@ router.delete( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const exists = await getOperationProfileById(params.id); + const exists = await getOperationProfileById({ + ownerId: getRequestOwnerId(req), + profileId: params.id, + }); if (!exists) throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); await deleteOperationProfile({ ownerId: getRequestOwnerId(req), profileId: params.id }); return { data: { id: params.id } }; @@ -123,7 +134,12 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - return { data: await exportOperationProfileBundle(params.id) }; + return { + data: await exportOperationProfileBundle({ + ownerId: getRequestOwnerId(req), + profileId: params.id, + }), + }; }) ); diff --git a/server/src/app.ts b/server/src/app.ts index bdb01f9f..c4420082 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,10 @@ import { runBackendBootstrap } from "./core/bootstrap/bootstrap-coordinator"; import { structuredLogger } from "./core/logging/structured-logger"; import { errorHandler } from "./core/middleware/error-handler"; import { requestLifecycleLogger } from "./core/middleware/request-lifecycle-logger"; +import { + rejectDisallowedOrigin, + resolveServerNetworkPolicy, +} from "./core/network/server-network-policy"; import { requestContextMiddleware } from "./core/request-context/request-context"; export type BootstrapAppOptions = { @@ -26,11 +30,17 @@ function shouldUseRequestLogging(): boolean { export function createApp(): Express { const app = express(); + const networkPolicy = resolveServerNetworkPolicy(); if (shouldUseRequestLogging()) { app.use(morgan("dev")); } - app.use(cors()); + app.use(rejectDisallowedOrigin(networkPolicy)); + app.use( + cors({ + origin: (origin, callback) => callback(null, networkPolicy.isOriginAllowed(origin)), + }) + ); app.use(express.json({ limit: "10mb" })); app.use(requestContextMiddleware); if (shouldUseRequestLogging()) { @@ -53,9 +63,10 @@ export async function startAppServer(options: { }): Promise<{ app: Express; server: Server }> { await bootstrapApp({ dbPath: options.dbPath }); const app = createApp(); + const networkPolicy = resolveServerNetworkPolicy(); const server = await new Promise((resolve) => { - const s = app.listen(options.port, () => resolve(s)); + const s = app.listen(options.port, networkPolicy.host, () => resolve(s)); }); return { app, server }; diff --git a/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts b/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts index 875e481b..14bf156a 100644 --- a/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts +++ b/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts @@ -52,10 +52,13 @@ export async function getChatOperationRuntimeState( const empty = buildEmptyState(chat.id, branchId); - const settings = await getOperationProfileSettings(); + const settings = await getOperationProfileSettings({ ownerId: chat.ownerId }); if (!settings.activeProfileId) return empty; - const profile = await getOperationProfileById(settings.activeProfileId); + const profile = await getOperationProfileById({ + ownerId: chat.ownerId, + profileId: settings.activeProfileId, + }); if (!profile || !profile.enabled) return empty; const compiled = await resolveCompiledOperationProfile(profile); diff --git a/server/src/application/operations/use-cases/delete-operation-block.ts b/server/src/application/operations/use-cases/delete-operation-block.ts index be6987cb..a2af692b 100644 --- a/server/src/application/operations/use-cases/delete-operation-block.ts +++ b/server/src/application/operations/use-cases/delete-operation-block.ts @@ -10,7 +10,10 @@ export async function deleteOperationBlockWithValidation(params: { ownerId: string; blockId: string; }): Promise<{ id: string }> { - const exists = await getOperationBlockById(params.blockId); + const exists = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: params.blockId, + }); if (!exists) throw new HttpError(404, "OperationBlock не найден", "NOT_FOUND"); const profiles = await listOperationProfiles({ ownerId: params.ownerId }); diff --git a/server/src/application/operations/use-cases/export-operation-profile.ts b/server/src/application/operations/use-cases/export-operation-profile.ts index 7cc862a4..435ffb4f 100644 --- a/server/src/application/operations/use-cases/export-operation-profile.ts +++ b/server/src/application/operations/use-cases/export-operation-profile.ts @@ -3,7 +3,10 @@ import { HttpError } from "@core/middleware/error-handler"; import { getOperationBlockById } from "../../../services/operations/operation-blocks-repository"; import { getOperationProfileById } from "../../../services/operations/operation-profiles-repository"; -export async function exportOperationProfileBundle(profileId: string): Promise<{ +export async function exportOperationProfileBundle(params: { + ownerId: string; + profileId: string; +}): Promise<{ type: "operation_profile_bundle"; version: 2; profile: { @@ -29,12 +32,15 @@ export async function exportOperationProfileBundle(profileId: string): Promise<{ meta?: unknown; }>; }> { - const item = await getOperationProfileById(profileId); + const item = await getOperationProfileById(params); if (!item) throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); const blocks = []; for (const ref of item.blockRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "OperationBlock не найден", "VALIDATION_ERROR", { profileId: item.profileId, diff --git a/server/src/application/operations/use-cases/set-active-operation-profile.ts b/server/src/application/operations/use-cases/set-active-operation-profile.ts index 65471d5d..de34ce20 100644 --- a/server/src/application/operations/use-cases/set-active-operation-profile.ts +++ b/server/src/application/operations/use-cases/set-active-operation-profile.ts @@ -1,17 +1,22 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveCompiledOperationProfile } from "../../../services/operations/operation-profile-resolver"; import { setActiveOperationProfile } from "../../../services/operations/operation-profile-settings-repository"; import { getOperationProfileById } from "../../../services/operations/operation-profiles-repository"; export async function setActiveOperationProfileWithValidation( - activeProfileId: string | null + params: { ownerId: string; activeProfileId: string | null } ) { - if (activeProfileId !== null) { - const exists = await getOperationProfileById(activeProfileId); - if (!exists) { + if (params.activeProfileId !== null) { + const profile = await getOperationProfileById({ + ownerId: params.ownerId, + profileId: params.activeProfileId, + }); + if (!profile) { throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); } + await resolveCompiledOperationProfile(profile); } - return setActiveOperationProfile({ activeProfileId }); + return setActiveOperationProfile(params); } diff --git a/server/src/core/network/server-network-policy.test.ts b/server/src/core/network/server-network-policy.test.ts new file mode 100644 index 00000000..aefbd03f --- /dev/null +++ b/server/src/core/network/server-network-policy.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; + +import { createApp } from "../../app"; + +import { resolveServerNetworkPolicy } from "./server-network-policy"; + +describe("server network policy", () => { + test("binds to loopback and restricts CORS by default", () => { + const policy = resolveServerNetworkPolicy({}); + + expect(policy.host).toBe("127.0.0.1"); + expect(policy.lanMode).toBe(false); + expect(policy.isOriginAllowed("http://localhost:5173")).toBe(true); + expect(policy.isOriginAllowed("http://127.0.0.1:5173")).toBe(true); + expect(policy.isOriginAllowed("https://attacker.example")).toBe(false); + }); + + test("requires explicit LAN opt-in before honoring a public host", () => { + expect( + resolveServerNetworkPolicy({ TALESPINNER_HOST: "0.0.0.0" }).host + ).toBe("127.0.0.1"); + + const policy = resolveServerNetworkPolicy({ + TALESPINNER_LAN_MODE: "true", + TALESPINNER_HOST: "0.0.0.0", + TALESPINNER_CORS_ORIGINS: "http://192.168.1.20:5173", + }); + expect(policy.host).toBe("0.0.0.0"); + expect(policy.lanMode).toBe(true); + expect(policy.isOriginAllowed("http://192.168.1.20:5173")).toBe(true); + expect(policy.isOriginAllowed("http://192.168.1.21:5173")).toBe(false); + }); + + test("allows requests without an Origin header for local native clients", () => { + expect(resolveServerNetworkPolicy({}).isOriginAllowed(undefined)).toBe(true); + }); + + test("returns 403 before routing requests from a disallowed origin", async () => { + const app = createApp(); + const server = await new Promise>((resolve) => { + const started = app.listen(0, "127.0.0.1", () => resolve(started)); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test address"); + const response = await fetch(`http://127.0.0.1:${address.port}/api/unknown`, { + headers: { origin: "https://attacker.example" }, + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "ORIGIN_NOT_ALLOWED" }, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/server/src/core/network/server-network-policy.ts b/server/src/core/network/server-network-policy.ts new file mode 100644 index 00000000..3ddafe13 --- /dev/null +++ b/server/src/core/network/server-network-policy.ts @@ -0,0 +1,63 @@ +const LOOPBACK_HOST = "127.0.0.1"; +const DEFAULT_LAN_HOST = "0.0.0.0"; +const DEFAULT_CORS_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", +] as const; + +type NetworkEnvironment = Record; + +export type ServerNetworkPolicy = { + host: string; + lanMode: boolean; + allowedOrigins: ReadonlySet; + isOriginAllowed: (origin: string | undefined) => boolean; +}; + +export function rejectDisallowedOrigin( + policy: ServerNetworkPolicy +): RequestHandler { + return (request, response, next) => { + const origin = request.header("origin"); + if (policy.isOriginAllowed(origin)) { + next(); + return; + } + response.status(403).json({ + error: { + code: "ORIGIN_NOT_ALLOWED", + message: "Request origin is not allowed", + }, + }); + }; +} + +function isEnabled(value: string | undefined): boolean { + return value === "1" || value?.toLowerCase() === "true"; +} + +function parseAllowedOrigins(value: string | undefined): Set { + const configured = value + ?.split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + return new Set(configured?.length ? configured : DEFAULT_CORS_ORIGINS); +} + +export function resolveServerNetworkPolicy( + environment: NetworkEnvironment = process.env +): ServerNetworkPolicy { + const lanMode = isEnabled(environment.TALESPINNER_LAN_MODE); + const host = lanMode + ? environment.TALESPINNER_HOST?.trim() || DEFAULT_LAN_HOST + : LOOPBACK_HOST; + const allowedOrigins = parseAllowedOrigins(environment.TALESPINNER_CORS_ORIGINS); + + return { + host, + lanMode, + allowedOrigins, + isOriginAllowed: (origin) => origin === undefined || allowedOrigins.has(origin), + }; +} +import type { RequestHandler } from "express"; diff --git a/server/src/core/request-context/request-context.test.ts b/server/src/core/request-context/request-context.test.ts new file mode 100644 index 00000000..f648880a --- /dev/null +++ b/server/src/core/request-context/request-context.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; + +import { getRequestOwnerId } from "./request-context"; + +import type { Request } from "express"; + +describe("request owner context", () => { + test("does not let request input override the trusted owner scope", () => { + const request = { + context: { + requestId: "request-1", + ownerScope: { ownerId: "trusted-owner", source: "explicit" }, + actor: { type: "system", id: null }, + tenant: { id: null }, + }, + } as Request; + + expect(getRequestOwnerId(request, "attacker-owner")).toBe("trusted-owner"); + }); +}); diff --git a/server/src/core/request-context/request-context.ts b/server/src/core/request-context/request-context.ts index c06c3ed2..cbb46324 100644 --- a/server/src/core/request-context/request-context.ts +++ b/server/src/core/request-context/request-context.ts @@ -69,5 +69,6 @@ export function resolveOwnerId( } export function getRequestOwnerId(req: Request, requestedOwnerId?: string | null): string { - return resolveOwnerId(requestedOwnerId, getRequestContext(req).ownerScope.ownerId); + void requestedOwnerId; + return getRequestContext(req).ownerScope.ownerId; } diff --git a/server/src/services/bundles/bundle-import-export.test.ts b/server/src/services/bundles/bundle-import-export.test.ts index 20cfb5f3..f4a5303d 100644 --- a/server/src/services/bundles/bundle-import-export.test.ts +++ b/server/src/services/bundles/bundle-import-export.test.ts @@ -82,6 +82,7 @@ describe("bundle import/export", () => { templateText: "{{char.name}}", }); const block = await createOperationBlock({ + ownerId: "global", input: { name: "Scene block", enabled: true, @@ -89,6 +90,7 @@ describe("bundle import/export", () => { }, }); const profile = await createOperationProfile({ + ownerId: "global", input: { name: "Main profile", enabled: true, @@ -121,7 +123,10 @@ describe("bundle import/export", () => { expect(imported.applied.operationProfileId).toBe(imported.created.operationProfiles[0]!.profileId); expect(imported.skippedApply).toEqual([]); - const createdProfile = await getOperationProfileById(imported.created.operationProfiles[0]!.profileId); + const createdProfile = await getOperationProfileById({ + ownerId: "global", + profileId: imported.created.operationProfiles[0]!.profileId, + }); const blocks = await listOperationBlocks({ ownerId: "global" }); expect(createdProfile?.blockRefs).toHaveLength(1); diff --git a/server/src/services/bundles/export-bundle-selection.ts b/server/src/services/bundles/export-bundle-selection.ts index 703733dc..06eff009 100644 --- a/server/src/services/bundles/export-bundle-selection.ts +++ b/server/src/services/bundles/export-bundle-selection.ts @@ -81,7 +81,10 @@ export async function exportBundleSelection(params: { } if (handle.kind === "operation_block") { - const block = await getOperationBlockById(handle.id); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: handle.id, + }); if (!block) throw new Error(`Operation block not found: ${handle.id}`); const resourceId = createBundleResourceId("operation_block", `${block.name}-${block.blockId}`); resources.push({ @@ -103,13 +106,19 @@ export async function exportBundleSelection(params: { } if (handle.kind === "operation_profile") { - const profile = await getOperationProfileById(handle.id); + const profile = await getOperationProfileById({ + ownerId: params.ownerId, + profileId: handle.id, + }); if (!profile) throw new Error(`Operation profile not found: ${handle.id}`); const enabledRefs = profile.blockRefs.filter((ref) => ref.enabled); const exportedBlockResources: Array<{ blockId: string; resourceId: string }> = []; for (const ref of enabledRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block || !block.enabled) continue; const resourceId = createBundleResourceId("operation_block", `${block.name}-${block.blockId}`); exportedBlockResources.push({ blockId: block.blockId, resourceId }); diff --git a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.test.ts b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.test.ts new file mode 100644 index 00000000..f513f456 --- /dev/null +++ b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; + +import { ProfileSessionArtifactStore } from "./profile-session-artifact-store"; + +describe("ProfileSessionArtifactStore resource limits", () => { + test("rejects oversized values before touching persistence", async () => { + await expect( + ProfileSessionArtifactStore.upsert({ + ownerId: "global", + sessionKey: "session", + chatId: "chat", + branchId: "branch", + profile: null, + tag: "oversized", + format: "text", + semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + value: "x".repeat(256 * 1024 + 1), + }) + ).rejects.toMatchObject({ code: "ARTIFACT_VALUE_TOO_LARGE" }); + }); +}); diff --git a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts index 2ae7561d..dc29986c 100644 --- a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts +++ b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts @@ -5,6 +5,11 @@ import { and, eq, inArray } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../../chat-core/json"; import { initDb } from "../../../db/client"; import { operationProfileSessionArtifacts } from "../../../db/schema"; +import { + assertArtifactHistoryItemLimit, + assertArtifactHistoryWithinLimits, + assertArtifactValueWithinLimits, +} from "../../operations/operation-resource-limits"; import type { ArtifactValue } from "../contracts"; import type { OperationActivationState } from "../operations/operation-activation-intervals"; @@ -120,12 +125,15 @@ export class ProfileSessionArtifactStore { }; value: unknown; }): Promise { + assertArtifactValueWithinLimits(params.value); + assertArtifactHistoryItemLimit(params.history.maxItems); const db = await initDb(); const existingRows = await db .select() .from(operationProfileSessionArtifacts) .where( and( + eq(operationProfileSessionArtifacts.ownerId, params.ownerId), eq(operationProfileSessionArtifacts.sessionKey, params.sessionKey), eq(operationProfileSessionArtifacts.tag, params.tag) ) @@ -141,6 +149,7 @@ export class ProfileSessionArtifactStore { params.history.maxItems ) : []; + assertArtifactHistoryWithinLimits(history); if (existing) { await db @@ -198,6 +207,7 @@ export class ProfileSessionArtifactStore { .from(operationProfileSessionArtifacts) .where( and( + eq(operationProfileSessionArtifacts.ownerId, params.ownerId), eq(operationProfileSessionArtifacts.sessionKey, params.sessionKey), eq(operationProfileSessionArtifacts.tag, tag) ) diff --git a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts index 67045e93..f18978a8 100644 --- a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts +++ b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts @@ -3,6 +3,35 @@ import { describe, expect, test } from "vitest"; import { RunArtifactStore } from "./run-artifact-store"; describe("RunArtifactStore", () => { + test("rejects artifact values larger than 256 KiB", () => { + const store = new RunArtifactStore(); + + expect(() => + store.upsert({ + artifactId: "oversized", + format: "text", + semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + value: "x".repeat(256 * 1024 + 1), + }) + ).toThrow(/artifact value exceeds/i); + }); + + test("rejects runtime history limits above 100 items", () => { + const store = new RunArtifactStore(); + expect(() => + store.upsert({ + artifactId: "history", + format: "text", + semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 101 }, + value: "ok", + }) + ).toThrow(/history maxItems/i); + }); + test("returns null for unknown tag", () => { const store = new RunArtifactStore(); expect(store.get("missing")).toBeNull(); diff --git a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts index 203cb241..17fd97fa 100644 --- a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts +++ b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts @@ -1,3 +1,9 @@ +import { + assertArtifactHistoryItemLimit, + assertArtifactHistoryWithinLimits, + assertArtifactValueWithinLimits, +} from "../../operations/operation-resource-limits"; + import type { ArtifactValue } from "../contracts"; import type { ArtifactFormat, @@ -5,7 +11,6 @@ import type { ArtifactWriteMode, } from "@shared/types/operation-profiles"; - export class RunArtifactStore { private readonly byTag = new Map(); @@ -30,6 +35,8 @@ export class RunArtifactStore { }; value: unknown; }): ArtifactValue { + assertArtifactValueWithinLimits(params.value); + assertArtifactHistoryItemLimit(params.history.maxItems); const existing = this.byTag.get(params.artifactId); const nextHistory = existing ? [...existing.history, params.value] @@ -37,6 +44,7 @@ export class RunArtifactStore { const history = params.history.enabled ? nextHistory.slice(-params.history.maxItems) : []; + assertArtifactHistoryWithinLimits(history); if (existing) { const next: ArtifactValue = { diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts index eccafb5b..e788a3f8 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts @@ -319,6 +319,104 @@ beforeEach(() => { }); describe("executeOperationsPhase", () => { + test("caps concurrent operation execution at four tasks", async () => { + let active = 0; + let maxActive = 0; + mocks.llmGatewayStream.mockImplementation(() => + (async function* () { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + yield { type: "delta", text: "ok" }; + active -= 1; + yield { type: "done", status: "done" }; + })() + ); + + const operations = Array.from({ length: 10 }, (_, index) => + makeLlmOp({ + opId: `llm-${index}`, + order: index, + prompt: "bounded", + output: artifactOutput(`result_${index}`), + }) + ); + + const out = await executeOperationsPhase({ + runId: "bounded-concurrency", + hook: "before_main_llm", + trigger: "generate", + operations, + executionMode: "concurrent", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out.every((item) => item.status === "done")).toBe(true); + expect(maxActive).toBe(4); + }); + + test("rejects auxiliary LLM output larger than 256 KiB", async () => { + mocks.llmGatewayStream.mockImplementation(() => + streamOf([ + { type: "delta", text: "x".repeat(256 * 1024 + 1) }, + { type: "done", status: "done" }, + ]) + ); + + const out = await executeOperationsPhase({ + runId: "bounded-output", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeLlmOp({ + opId: "llm-large-output", + order: 1, + prompt: "bounded", + output: artifactOutput("large_output"), + }), + ], + executionMode: "concurrent", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]).toMatchObject({ + status: "error", + error: { code: "LLM_OUTPUT_TOO_LARGE" }, + }); + }); + + test("rejects oversized template output before effects are created", async () => { + const out = await executeOperationsPhase({ + runId: "bounded-template-output", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeTemplateOp({ + opId: "large-template-output", + order: 1, + template: "x".repeat(256 * 1024 + 1), + output: artifactOutput("large_template"), + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]).toMatchObject({ + status: "error", + error: { code: "ARTIFACT_VALUE_TOO_LARGE" }, + }); + }); + test("returns activation skip with skip details", async () => { const out = await executeOperationsPhase({ runId: "run-eligible-filter", diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts index ca9b9231..a2bdec0e 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts @@ -1,6 +1,10 @@ import { createTaskSkip, runOrchestrator } from "@core/operation-orchestrator"; import { renderLiquidTemplate } from "../../chat-core/prompt-template-renderer"; +import { + assertArtifactValueWithinLimits, + OPERATION_RESOURCE_LIMITS, +} from "../../operations/operation-resource-limits"; import { compileArtifactExposureEffect, getArtifactPrimaryEffectType, @@ -539,6 +543,7 @@ export async function executeOperationsPhase(params: { hook: params.hook, trigger: params.trigger, executionMode: params.executionMode, + concurrency: OPERATION_RESOURCE_LIMITS.concurrentTasks, signal: params.abortSignal, tasks: executableOps.map((op) => ({ taskId: op.opId, @@ -615,6 +620,7 @@ export async function executeOperationsPhase(params: { return knowledgeResult; } + assertArtifactValueWithinLimits(resolvedRendered); const artifact = op.config.params.artifact; const effects: RuntimeEffect[] = [ { diff --git a/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts b/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts index 2bfd560b..d323a180 100644 --- a/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts +++ b/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts @@ -6,6 +6,7 @@ import { buildGatewayStreamRequest } from "../../llm/llm-gateway-adapter"; import { getProviderConfig, getTokenPlaintext } from "../../llm/llm-repository"; import { compileLlmJsonSchemaSpec } from "../../operations/llm-json-schema-spec"; import { parseLlmOperationParams } from "../../operations/llm-operation-params"; +import { OPERATION_RESOURCE_LIMITS } from "../../operations/operation-resource-limits"; import type { GenerateMessage } from "@shared/types/generate"; import type { OperationInProfile } from "@shared/types/operation-profiles"; @@ -258,6 +259,12 @@ async function callLlmOnce(params: { for await (const event of llmGateway.stream(request)) { if (event.type === "delta") { text += event.text; + if (Buffer.byteLength(text, "utf8") > OPERATION_RESOURCE_LIMITS.llmOutputBytes) { + throw createCodedError( + "LLM_OUTPUT_TOO_LARGE", + `LLM output exceeds ${OPERATION_RESOURCE_LIMITS.llmOutputBytes} bytes` + ); + } continue; } if (event.type === "error") { @@ -331,6 +338,14 @@ export async function executeLlmOperation(params: { }) ); } + const inputBytes = Buffer.byteLength(renderedPrompt, "utf8") + + Buffer.byteLength(renderedSystem, "utf8"); + if (inputBytes > OPERATION_RESOURCE_LIMITS.llmOutputBytes) { + throw createCodedError( + "LLM_PROMPT_TOO_LARGE", + `Rendered LLM prompt exceeds ${OPERATION_RESOURCE_LIMITS.llmOutputBytes} bytes` + ); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); throw createCodedError("LLM_TEMPLATE_RENDER_ERROR", message); diff --git a/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts b/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts index e7c851c0..eb7c4d2e 100644 --- a/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts +++ b/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts @@ -178,7 +178,10 @@ describe("resolveRunContext", () => { const { context, profile } = await resolveRunContext({ request: makeRequest() }); - expect(mocks.getOperationProfileById).toHaveBeenCalledWith("profile-1"); + expect(mocks.getOperationProfileById).toHaveBeenCalledWith({ + ownerId: "owner-1", + profileId: "profile-1", + }); expect(profile).toMatchObject({ profileId: "profile-1", enabled: true, diff --git a/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts b/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts index 88b1a191..947e1253 100644 --- a/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts +++ b/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts @@ -59,9 +59,12 @@ export async function resolveRunContext(params: { providerConfig: providerConfig.config, }); - const settings = await getOperationProfileSettings(); + const settings = await getOperationProfileSettings({ ownerId }); const activeProfile = settings.activeProfileId - ? await getOperationProfileById(settings.activeProfileId) + ? await getOperationProfileById({ + ownerId, + profileId: settings.activeProfileId, + }) : null; const profile = activeProfile && activeProfile.enabled ? activeProfile : null; const compiledProfile = profile ? await resolveCompiledOperationProfile(profile) : null; diff --git a/server/src/services/operations/guard-operation-params.ts b/server/src/services/operations/guard-operation-params.ts index 059ba983..9b056bcf 100644 --- a/server/src/services/operations/guard-operation-params.ts +++ b/server/src/services/operations/guard-operation-params.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; + import type { GuardAuxLlmParams, GuardLiquidParams, @@ -26,6 +28,7 @@ const guardOutputDefinitionSchema: z.ZodType = z.object({ export const guardOutputContractSchema: z.ZodType = z .array(guardOutputDefinitionSchema) .min(1) + .max(32) .superRefine((items, ctx) => { const seen = new Set(); for (const item of items) { @@ -69,7 +72,7 @@ const samplersSchema: z.ZodType = z const retrySchema: z.ZodType = z .object({ - maxAttempts: z.number().int().min(1).max(10), + maxAttempts: z.number().int().min(1).max(3), backoffMs: z.number().int().min(0).max(120_000).optional(), retryOn: z.array(retryOnSchema).min(1).optional(), }) @@ -79,7 +82,7 @@ export const liquidGuardParamsSchema = z .object({ engine: z.literal("liquid"), outputContract: guardOutputContractSchema, - template: z.string(), + template: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), }) .strict(); @@ -91,11 +94,14 @@ export const auxLlmGuardParamsSchema = z providerId: z.enum(["openrouter", "openai_compatible"]), credentialRef: z.string().trim().min(1), model: z.string().trim().min(1).optional(), - system: z.string().optional(), - prompt: z.string().min(1), + system: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters).optional(), + prompt: z + .string() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), samplers: samplersSchema.optional(), - timeoutMs: z.number().int().min(1).max(300_000).optional(), + timeoutMs: z.number().int().min(1).max(120_000).optional(), retry: retrySchema.optional(), }) .strict(); diff --git a/server/src/services/operations/llm-operation-params.ts b/server/src/services/operations/llm-operation-params.ts index 548f52a8..fdf9d807 100644 --- a/server/src/services/operations/llm-operation-params.ts +++ b/server/src/services/operations/llm-operation-params.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { + OPERATION_RESOURCE_LIMITS, + serializedJsonByteLength, +} from "./operation-resource-limits"; + import type { LlmJsonParseMode, LlmOperationParams, @@ -44,7 +49,7 @@ const samplersSchema: z.ZodType = z const retrySchema: z.ZodType = z .object({ - maxAttempts: z.number().int().min(1).max(10), + maxAttempts: z.number().int().min(1).max(3), backoffMs: z.number().int().min(0).max(120_000).optional(), retryOn: z.array(retryOnSchema).min(1).optional(), }) @@ -56,21 +61,44 @@ export const llmOperationParamsSchema: z.ZodType = z credentialRef: z.string().trim().min(1), model: z.string().trim().min(1).optional(), llmPresetId: z.string().trim().min(1).optional(), - system: z.string().optional(), - prompt: z.string().min(1), + system: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters).optional(), + prompt: z + .string() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), outputMode: z.enum(["text", "json"]).optional(), jsonSchema: z.unknown().optional(), strictSchemaValidation: z.boolean().optional(), jsonParseMode: jsonParseModeSchema.optional(), - jsonCustomPattern: z.string().trim().min(1).optional(), + jsonCustomPattern: z.string().trim().min(1).max(2_000).optional(), jsonCustomFlags: z.string().trim().optional(), samplerPresetId: z.string().trim().min(1).optional(), samplers: samplersSchema.optional(), - timeoutMs: z.number().int().min(1).max(300_000).optional(), + timeoutMs: z.number().int().min(1).max(120_000).optional(), retry: retrySchema.optional(), }) .superRefine((value, ctx) => { + if (typeof value.jsonSchema !== "undefined") { + let schemaBytes = Number.POSITIVE_INFINITY; + try { + schemaBytes = serializedJsonByteLength(value.jsonSchema); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["jsonSchema"], + message: "jsonSchema must be JSON serializable", + }); + } + if (schemaBytes > OPERATION_RESOURCE_LIMITS.jsonSchemaBytes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["jsonSchema"], + message: `jsonSchema exceeds ${OPERATION_RESOURCE_LIMITS.jsonSchemaBytes} bytes`, + }); + } + } + if (value.outputMode !== "json" && typeof value.jsonParseMode !== "undefined") { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/server/src/services/operations/operation-block-validator.test.ts b/server/src/services/operations/operation-block-validator.test.ts index d10ab859..6c435e26 100644 --- a/server/src/services/operations/operation-block-validator.test.ts +++ b/server/src/services/operations/operation-block-validator.test.ts @@ -5,6 +5,71 @@ import { describe, expect, test } from "vitest"; import { validateOperationBlockUpsertInput } from "./operation-block-validator"; describe("operation block validator", () => { + test("rejects blocks with more than 64 operations", () => { + const operations = Array.from({ length: 65 }, (_, index) => ({ + opId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + name: `operation-${index}`, + kind: "template" as const, + config: { + enabled: true, + required: false, + hooks: ["before_main_llm" as const], + order: index, + params: { template: "ok" }, + }, + })); + + expect(() => + validateOperationBlockUpsertInput({ + name: "oversized", + enabled: true, + operations, + }) + ).toThrow(/Validation error/); + }); + + test("rejects oversized templates and artifact histories", () => { + const base = { + name: "block", + enabled: true, + operations: [ + { + opId: "6ff77029-5037-4d21-8ace-c9836f58a14b", + name: "template-op", + kind: "template" as const, + config: { + enabled: true, + required: false, + hooks: ["before_main_llm" as const], + order: 10, + params: { + template: "x".repeat(100_001), + }, + }, + }, + ], + }; + expect(() => validateOperationBlockUpsertInput(base)).toThrow(/Validation error/); + + const withLargeHistory = structuredClone(base); + withLargeHistory.operations[0]!.config.params.template = "ok"; + Object.assign(withLargeHistory.operations[0]!.config.params, { + artifact: { + artifactId: "artifact:test", + tag: "test", + title: "Test", + format: "text", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 101 }, + exposures: [], + }, + }); + expect(() => validateOperationBlockUpsertInput(withLargeHistory)).toThrow( + /Validation error/ + ); + }); + test("rejects legacy operation kind", () => { expect(() => validateOperationBlockUpsertInput({ diff --git a/server/src/services/operations/operation-block-validator.ts b/server/src/services/operations/operation-block-validator.ts index 5bf4425f..92ac8867 100644 --- a/server/src/services/operations/operation-block-validator.ts +++ b/server/src/services/operations/operation-block-validator.ts @@ -35,6 +35,7 @@ import { } from "./knowledge-operation-params"; import { compileLlmJsonSchemaSpec } from "./llm-json-schema-spec"; import { llmOperationParamsSchema } from "./llm-operation-params"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; import type { KnowledgeRevealOperationParams, @@ -135,10 +136,16 @@ const artifactConfigSchema = z.object({ writeMode: artifactWriteModeSchema, history: z.object({ enabled: z.boolean(), - maxItems: z.number().int().min(1), + maxItems: z + .number() + .int() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.artifactHistoryItems), }), semantics: z.string().trim().min(1).optional(), - exposures: z.array(artifactExposureSchema), + exposures: z + .array(artifactExposureSchema) + .max(OPERATION_RESOURCE_LIMITS.exposuresPerArtifact), }); const legacyArtifactWriteTargetSchema = z.object({ @@ -186,7 +193,7 @@ const legacyOperationOutputSchema = z.discriminatedUnion("type", [ ]); const templateParamsSchema = z.object({ - template: z.string(), + template: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), artifact: artifactConfigSchema.optional(), output: legacyOperationOutputSchema.optional(), @@ -219,8 +226,14 @@ const operationConfigBaseSchema = z.object({ triggers: z.array(operationTriggerSchema).min(1).optional(), activation: operationActivationSchema.optional(), order: z.number().finite(), - dependsOn: z.array(uuidSchema).optional(), - runConditions: z.array(runConditionSchema).optional(), + dependsOn: z + .array(uuidSchema) + .max(OPERATION_RESOURCE_LIMITS.dependenciesPerOperation) + .optional(), + runConditions: z + .array(runConditionSchema) + .max(OPERATION_RESOURCE_LIMITS.runConditionsPerOperation) + .optional(), }); const operationConfigTemplateSchema = operationConfigBaseSchema.extend({ @@ -234,7 +247,10 @@ const operationConfigOtherSchema = operationConfigBaseSchema.extend({ const knowledgeRequestSourceSchema = z.discriminatedUnion("mode", [ z.object({ mode: z.literal("inline"), - requestTemplate: z.string().min(1), + requestTemplate: z + .string() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), }), z.object({ @@ -373,7 +389,9 @@ const upsertInputSchema: z.ZodType = z.object({ name: z.string().trim().min(1), description: z.string().trim().min(1).optional(), enabled: z.boolean(), - operations: z.array(operationInProfileSchema), + operations: z + .array(operationInProfileSchema) + .max(OPERATION_RESOURCE_LIMITS.operationsPerBlock), meta: z.unknown().optional(), }); diff --git a/server/src/services/operations/operation-blocks-repository.ts b/server/src/services/operations/operation-blocks-repository.ts index 7a710dc2..52bc73b7 100644 --- a/server/src/services/operations/operation-blocks-repository.ts +++ b/server/src/services/operations/operation-blocks-repository.ts @@ -57,37 +57,40 @@ export function resolveImportedOperationBlockName(input: string, existingNames: return `${base} (imported ${Date.now()})`; } -export async function listOperationBlocks(params?: { - ownerId?: string; +export async function listOperationBlocks(params: { + ownerId: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; const rows = await db .select() .from(operationBlocks) - .where(eq(operationBlocks.ownerId, ownerId)) + .where(eq(operationBlocks.ownerId, params.ownerId)) .orderBy(asc(operationBlocks.name)); return rows.map(rowToDto); } export async function getOperationBlockById( - id: string + params: { ownerId: string; blockId: string } ): Promise { const db = await initDb(); const rows = await db .select() .from(operationBlocks) - .where(eq(operationBlocks.id, id)) + .where( + and( + eq(operationBlocks.id, params.blockId), + eq(operationBlocks.ownerId, params.ownerId) + ) + ) .limit(1); return rows[0] ? rowToDto(rows[0]) : null; } export async function createOperationBlock(params: { - ownerId?: string; + ownerId: string; input: OperationBlockUpsertInput; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; const ts = new Date(); const blockId = uuidv4(); @@ -95,7 +98,7 @@ export async function createOperationBlock(params: { await db.insert(operationBlocks).values({ id: blockId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description ?? null, enabled: validated.enabled, @@ -106,11 +109,14 @@ export async function createOperationBlock(params: { updatedAt: ts, }); - const created = await getOperationBlockById(blockId); + const created = await getOperationBlockById({ + ownerId: params.ownerId, + blockId, + }); if (created) return created; return { blockId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description, enabled: validated.enabled, @@ -123,13 +129,15 @@ export async function createOperationBlock(params: { } export async function updateOperationBlock(params: { - ownerId?: string; + ownerId: string; blockId: string; patch: Partial; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - const current = await getOperationBlockById(params.blockId); + const current = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: params.blockId, + }); if (!current) return null; const nextInput: OperationBlockUpsertInput = { @@ -163,18 +171,32 @@ export async function updateOperationBlock(params: { metaJson: validated.meta === null ? null : safeJsonStringify(validated.meta), updatedAt: ts, }) - .where(and(eq(operationBlocks.id, params.blockId), eq(operationBlocks.ownerId, ownerId))); - - return getOperationBlockById(params.blockId); + .where( + and( + eq(operationBlocks.id, params.blockId), + eq(operationBlocks.ownerId, params.ownerId) + ) + ); + + return getOperationBlockById({ + ownerId: params.ownerId, + blockId: params.blockId, + }); } export async function deleteOperationBlock(params: { - ownerId?: string; + ownerId: string; blockId: string; -}): Promise { +}): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - await db + const deleted = await db .delete(operationBlocks) - .where(and(eq(operationBlocks.id, params.blockId), eq(operationBlocks.ownerId, ownerId))); + .where( + and( + eq(operationBlocks.id, params.blockId), + eq(operationBlocks.ownerId, params.ownerId) + ) + ) + .returning({ id: operationBlocks.id }); + return deleted.length > 0; } diff --git a/server/src/services/operations/operation-owner-scope.integration.test.ts b/server/src/services/operations/operation-owner-scope.integration.test.ts new file mode 100644 index 00000000..94a4c90e --- /dev/null +++ b/server/src/services/operations/operation-owner-scope.integration.test.ts @@ -0,0 +1,149 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { exportOperationProfileBundle } from "../../application/operations/use-cases/export-operation-profile"; +import { setActiveOperationProfileWithValidation } from "../../application/operations/use-cases/set-active-operation-profile"; +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; + +import { + createOperationBlock, + deleteOperationBlock, + getOperationBlockById, + updateOperationBlock, +} from "./operation-blocks-repository"; +import { + getOperationProfileSettings, + setActiveOperationProfile, +} from "./operation-profile-settings-repository"; +import { + createOperationProfile, + getOperationProfileById, + updateOperationProfile, +} from "./operation-profiles-repository"; + +describe("operation owner scope", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-operation-owner-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("blocks cannot be read, updated, or deleted by another owner", async () => { + const block = await createOperationBlock({ + ownerId: "owner-a", + input: { name: "Private block", enabled: true, operations: [] }, + }); + + expect( + await getOperationBlockById({ ownerId: "owner-b", blockId: block.blockId }) + ).toBeNull(); + expect( + await updateOperationBlock({ + ownerId: "owner-b", + blockId: block.blockId, + patch: { name: "Stolen" }, + }) + ).toBeNull(); + expect( + await deleteOperationBlock({ ownerId: "owner-b", blockId: block.blockId }) + ).toBe(false); + + const original = await getOperationBlockById({ + ownerId: "owner-a", + blockId: block.blockId, + }); + expect(original?.name).toBe("Private block"); + }); + + test("profiles cannot reference, read, update, or export another owner's data", async () => { + const block = await createOperationBlock({ + ownerId: "owner-a", + input: { name: "Owner A block", enabled: true, operations: [] }, + }); + const profile = await createOperationProfile({ + ownerId: "owner-a", + input: { + name: "Owner A profile", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "11111111-1111-4111-8111-111111111111", + blockRefs: [{ blockId: block.blockId, enabled: true, order: 0 }], + }, + }); + + await expect( + createOperationProfile({ + ownerId: "owner-b", + input: { + name: "Cross-owner profile", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "22222222-2222-4222-8222-222222222222", + blockRefs: [{ blockId: block.blockId, enabled: true, order: 0 }], + }, + }) + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + expect( + await getOperationProfileById({ + ownerId: "owner-b", + profileId: profile.profileId, + }) + ).toBeNull(); + expect( + await updateOperationProfile({ + ownerId: "owner-b", + profileId: profile.profileId, + patch: { name: "Stolen" }, + }) + ).toBeNull(); + await expect( + exportOperationProfileBundle({ + ownerId: "owner-b", + profileId: profile.profileId, + }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + test("active profile settings are isolated by owner", async () => { + const profile = await createOperationProfile({ + ownerId: "owner-a", + input: { + name: "Owner A profile", + enabled: true, + executionMode: "sequential", + operationProfileSessionId: "11111111-1111-4111-8111-111111111111", + blockRefs: [], + }, + }); + + await setActiveOperationProfile({ + ownerId: "owner-a", + activeProfileId: profile.profileId, + }); + + expect(await getOperationProfileSettings({ ownerId: "owner-a" })).toMatchObject({ + activeProfileId: profile.profileId, + }); + expect(await getOperationProfileSettings({ ownerId: "owner-b" })).toMatchObject({ + activeProfileId: null, + }); + await expect( + setActiveOperationProfileWithValidation({ + ownerId: "owner-b", + activeProfileId: profile.profileId, + }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); diff --git a/server/src/services/operations/operation-profile-resolver.test.ts b/server/src/services/operations/operation-profile-resolver.test.ts index d1bdd85c..f2828948 100644 --- a/server/src/services/operations/operation-profile-resolver.test.ts +++ b/server/src/services/operations/operation-profile-resolver.test.ts @@ -12,7 +12,10 @@ import { resolveCompiledOperationProfile } from "./operation-profile-resolver"; const blockById = new Map(); vi.mock("./operation-blocks-repository", () => ({ - getOperationBlockById: vi.fn(async (id: string) => blockById.get(id) ?? null), + getOperationBlockById: vi.fn( + async (params: { ownerId: string; blockId: string }) => + blockById.get(params.blockId) ?? null + ), })); function makeBlock(params: { diff --git a/server/src/services/operations/operation-profile-resolver.ts b/server/src/services/operations/operation-profile-resolver.ts index 34b9dc1a..1535d1ce 100644 --- a/server/src/services/operations/operation-profile-resolver.ts +++ b/server/src/services/operations/operation-profile-resolver.ts @@ -4,6 +4,7 @@ import { HttpError } from "@core/middleware/error-handler"; import { validateCompiledProfileArtifactWriters } from "./operation-block-validator"; import { getOperationBlockById } from "./operation-blocks-repository"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; import type { OperationArtifactConfig, @@ -22,6 +23,19 @@ export type CompiledOperationProfile = { blockVersionFingerprint: string; }; +function assertCompiledOperationCount(profileId: string, operationCount: number): void { + if (operationCount <= OPERATION_RESOURCE_LIMITS.operationsPerProfile) return; + throw new HttpError( + 400, + `Compiled profile exceeds ${OPERATION_RESOURCE_LIMITS.operationsPerProfile} operations`, + "VALIDATION_ERROR", + { + profileId, + operationCount, + } + ); +} + function normalizeOrder(value: number): number { if (!Number.isFinite(value)) return 0; return Math.trunc(value); @@ -82,7 +96,10 @@ async function resolveBlocks( }); const out: Array<{ refOrder: number; block: OperationBlock }> = []; for (const ref of enabledRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: profile.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "Operation block not found", "VALIDATION_ERROR", { blockId: ref.blockId, @@ -100,6 +117,7 @@ export async function resolveCompiledOperationProfile( ): Promise { if (!Array.isArray(profile.blockRefs) || profile.blockRefs.length === 0) { const operations = profile.operations ?? []; + assertCompiledOperationCount(profile.profileId, operations.length); validateCompiledProfileArtifactWriters({ ...profile, operations }); return { profile, @@ -126,6 +144,8 @@ export async function resolveCompiledOperationProfile( } }); + assertCompiledOperationCount(profile.profileId, operations.length); + const blockVersionFingerprint = blockVersions .map((item) => `${item.blockId}:${item.version}`) .join("|"); diff --git a/server/src/services/operations/operation-profile-settings-repository.ts b/server/src/services/operations/operation-profile-settings-repository.ts index 70addf61..de5cd2fe 100644 --- a/server/src/services/operations/operation-profile-settings-repository.ts +++ b/server/src/services/operations/operation-profile-settings-repository.ts @@ -8,14 +8,12 @@ export type OperationProfileSettingsDto = { updatedAt: Date; }; -const SETTINGS_ROW_ID = "global"; - -async function ensureSettingsRow(): Promise { +async function ensureSettingsRow(ownerId: string): Promise { const db = await initDb(); const rows = await db .select() .from(operationProfileSettings) - .where(eq(operationProfileSettings.id, SETTINGS_ROW_ID)) + .where(eq(operationProfileSettings.id, ownerId)) .limit(1); if (rows[0]) { @@ -27,7 +25,7 @@ async function ensureSettingsRow(): Promise { const now = new Date(); await db.insert(operationProfileSettings).values({ - id: SETTINGS_ROW_ID, + id: ownerId, activeProfileId: null, updatedAt: now, }); @@ -35,15 +33,18 @@ async function ensureSettingsRow(): Promise { return { activeProfileId: null, updatedAt: now }; } -export async function getOperationProfileSettings(): Promise { - return ensureSettingsRow(); +export async function getOperationProfileSettings(params: { + ownerId: string; +}): Promise { + return ensureSettingsRow(params.ownerId); } export async function setActiveOperationProfile(params: { + ownerId: string; activeProfileId: string | null; }): Promise { const db = await initDb(); - const current = await ensureSettingsRow(); + const current = await ensureSettingsRow(params.ownerId); const now = new Date(); await db @@ -52,7 +53,7 @@ export async function setActiveOperationProfile(params: { activeProfileId: params.activeProfileId, updatedAt: now, }) - .where(eq(operationProfileSettings.id, SETTINGS_ROW_ID)); + .where(eq(operationProfileSettings.id, params.ownerId)); return { ...current, activeProfileId: params.activeProfileId, updatedAt: now }; } diff --git a/server/src/services/operations/operation-profile-validator.test.ts b/server/src/services/operations/operation-profile-validator.test.ts index 6b4d8577..e26879eb 100644 --- a/server/src/services/operations/operation-profile-validator.test.ts +++ b/server/src/services/operations/operation-profile-validator.test.ts @@ -6,6 +6,24 @@ import { } from "./operation-profile-validator"; describe("operation profile validator", () => { + test("rejects profiles with more than 16 block references", () => { + const blockRefs = Array.from({ length: 17 }, (_, index) => ({ + blockId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + enabled: true, + order: index, + })); + + expect(() => + validateOperationProfileUpsertInput({ + name: "oversized", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "2d9f1f5c-6f38-4f94-9caa-0ea4f36f2db8", + blockRefs, + }) + ).toThrow(/Validation error/); + }); + test("accepts profile with unique block refs", () => { const out = validateOperationProfileUpsertInput({ name: "profile", diff --git a/server/src/services/operations/operation-profile-validator.ts b/server/src/services/operations/operation-profile-validator.ts index 889e5ad0..06f2fd44 100644 --- a/server/src/services/operations/operation-profile-validator.ts +++ b/server/src/services/operations/operation-profile-validator.ts @@ -8,6 +8,7 @@ import { operationInProfileSchema, type ValidatedOperationBlockInput, } from "./operation-block-validator"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; import type { OperationExecutionMode, @@ -32,7 +33,7 @@ const upsertInputSchema: z.ZodType = z.object({ enabled: z.boolean(), executionMode: executionModeSchema, operationProfileSessionId: uuidSchema, - blockRefs: z.array(blockRefSchema), + blockRefs: z.array(blockRefSchema).max(OPERATION_RESOURCE_LIMITS.blocksPerProfile), meta: z.unknown().optional(), }); @@ -93,7 +94,7 @@ const bundleProfileSchema: z.ZodType = z.object({ enabled: z.boolean(), executionMode: executionModeSchema, operationProfileSessionId: uuidSchema, - blockRefs: z.array(blockRefSchema), + blockRefs: z.array(blockRefSchema).max(OPERATION_RESOURCE_LIMITS.blocksPerProfile), meta: z.unknown().optional(), }); @@ -106,9 +107,11 @@ const bundleImportSchema: z.ZodType = z.object({ name: z.string(), description: z.string().optional(), enabled: z.boolean(), - operations: z.array(operationInProfileSchema), + operations: z + .array(operationInProfileSchema) + .max(OPERATION_RESOURCE_LIMITS.operationsPerBlock), meta: z.unknown().optional(), - })), + })).max(OPERATION_RESOURCE_LIMITS.blocksPerProfile), }); const legacyImportSchema: z.ZodType = z.object({ @@ -118,7 +121,9 @@ const legacyImportSchema: z.ZodType = z.object({ enabled: z.boolean(), executionMode: executionModeSchema, operationProfileSessionId: uuidSchema, - operations: z.array(operationInProfileSchema), + operations: z + .array(operationInProfileSchema) + .max(OPERATION_RESOURCE_LIMITS.operationsPerProfile), meta: z.unknown().optional(), }); diff --git a/server/src/services/operations/operation-profiles-repository.ts b/server/src/services/operations/operation-profiles-repository.ts index c234f9e9..49a54dc0 100644 --- a/server/src/services/operations/operation-profiles-repository.ts +++ b/server/src/services/operations/operation-profiles-repository.ts @@ -45,43 +45,49 @@ function rowToDto(row: typeof operationProfiles.$inferSelect): OperationProfile }; } -export async function listOperationProfiles(params?: { - ownerId?: string; +export async function listOperationProfiles(params: { + ownerId: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; const rows = await db .select() .from(operationProfiles) - .where(eq(operationProfiles.ownerId, ownerId)) + .where(eq(operationProfiles.ownerId, params.ownerId)) .orderBy(asc(operationProfiles.name)); return rows.map(rowToDto); } export async function getOperationProfileById( - id: string + params: { ownerId: string; profileId: string } ): Promise { const db = await initDb(); const rows = await db .select() .from(operationProfiles) - .where(eq(operationProfiles.id, id)) + .where( + and( + eq(operationProfiles.id, params.profileId), + eq(operationProfiles.ownerId, params.ownerId) + ) + ) .limit(1); return rows[0] ? rowToDto(rows[0]) : null; } export async function createOperationProfile(params: { - ownerId?: string; + ownerId: string; input: OperationProfileUpsertInput; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; const ts = new Date(); const profileId = uuidv4(); const validated = validateOperationProfileUpsertInput(params.input); for (const ref of validated.blockRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "Unknown blockId in profile", "VALIDATION_ERROR", { blockId: ref.blockId, @@ -91,7 +97,7 @@ export async function createOperationProfile(params: { await db.insert(operationProfiles).values({ id: profileId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description ?? null, enabled: validated.enabled, @@ -104,11 +110,14 @@ export async function createOperationProfile(params: { updatedAt: ts, }); - const created = await getOperationProfileById(profileId); + const created = await getOperationProfileById({ + ownerId: params.ownerId, + profileId, + }); if (created) return created; return { profileId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description, enabled: validated.enabled, @@ -123,13 +132,15 @@ export async function createOperationProfile(params: { } export async function updateOperationProfile(params: { - ownerId?: string; + ownerId: string; profileId: string; patch: Partial; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - const current = await getOperationProfileById(params.profileId); + const current = await getOperationProfileById({ + ownerId: params.ownerId, + profileId: params.profileId, + }); if (!current) return null; const nextInput: OperationProfileUpsertInput = { @@ -157,7 +168,10 @@ export async function updateOperationProfile(params: { const validated = validateOperationProfileUpsertInput(nextInput); for (const ref of validated.blockRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "Unknown blockId in profile", "VALIDATION_ERROR", { blockId: ref.blockId, @@ -181,19 +195,33 @@ export async function updateOperationProfile(params: { metaJson: validated.meta === null ? null : safeJsonStringify(validated.meta), updatedAt: ts, }) - .where(and(eq(operationProfiles.id, params.profileId), eq(operationProfiles.ownerId, ownerId))); - - return getOperationProfileById(params.profileId); + .where( + and( + eq(operationProfiles.id, params.profileId), + eq(operationProfiles.ownerId, params.ownerId) + ) + ); + + return getOperationProfileById({ + ownerId: params.ownerId, + profileId: params.profileId, + }); } export async function deleteOperationProfile(params: { - ownerId?: string; + ownerId: string; profileId: string; -}): Promise { +}): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - await db + const deleted = await db .delete(operationProfiles) - .where(and(eq(operationProfiles.id, params.profileId), eq(operationProfiles.ownerId, ownerId))); + .where( + and( + eq(operationProfiles.id, params.profileId), + eq(operationProfiles.ownerId, params.ownerId) + ) + ) + .returning({ id: operationProfiles.id }); + return deleted.length > 0; } diff --git a/server/src/services/operations/operation-resource-limits.ts b/server/src/services/operations/operation-resource-limits.ts new file mode 100644 index 00000000..5fb2fbb9 --- /dev/null +++ b/server/src/services/operations/operation-resource-limits.ts @@ -0,0 +1,68 @@ +export const OPERATION_RESOURCE_LIMITS = { + concurrentTasks: 4, + operationsPerBlock: 64, + operationsPerProfile: 128, + blocksPerProfile: 16, + dependenciesPerOperation: 32, + runConditionsPerOperation: 32, + exposuresPerArtifact: 16, + artifactHistoryItems: 100, + templateCharacters: 100_000, + jsonSchemaBytes: 100_000, + llmOutputBytes: 256 * 1024, + artifactValueBytes: 256 * 1024, + artifactHistoryBytes: 1024 * 1024, +} as const; + +type ResourceLimitError = Error & { code: string }; + +function createResourceLimitError(code: string, message: string): ResourceLimitError { + const error = new Error(message) as ResourceLimitError; + error.code = code; + return error; +} + +export function serializedJsonByteLength(value: unknown): number { + const serialized = JSON.stringify(value); + return Buffer.byteLength(serialized ?? "null", "utf8"); +} + +export function assertArtifactValueWithinLimits(value: unknown): void { + let bytes: number; + try { + bytes = serializedJsonByteLength(value); + } catch { + throw createResourceLimitError( + "ARTIFACT_VALUE_NOT_SERIALIZABLE", + "Artifact value must be JSON serializable" + ); + } + if (bytes > OPERATION_RESOURCE_LIMITS.artifactValueBytes) { + throw createResourceLimitError( + "ARTIFACT_VALUE_TOO_LARGE", + `Artifact value exceeds ${OPERATION_RESOURCE_LIMITS.artifactValueBytes} bytes` + ); + } +} + +export function assertArtifactHistoryItemLimit(maxItems: number): void { + if ( + !Number.isInteger(maxItems) || + maxItems < 1 || + maxItems > OPERATION_RESOURCE_LIMITS.artifactHistoryItems + ) { + throw createResourceLimitError( + "ARTIFACT_HISTORY_LIMIT_INVALID", + `Artifact history maxItems must be between 1 and ${OPERATION_RESOURCE_LIMITS.artifactHistoryItems}` + ); + } +} + +export function assertArtifactHistoryWithinLimits(history: unknown[]): void { + if (serializedJsonByteLength(history) > OPERATION_RESOURCE_LIMITS.artifactHistoryBytes) { + throw createResourceLimitError( + "ARTIFACT_HISTORY_TOO_LARGE", + `Artifact history exceeds ${OPERATION_RESOURCE_LIMITS.artifactHistoryBytes} bytes` + ); + } +} From f6ed30e1b20f7f02cc08b5558810d71a177dc355 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 20 Jul 2026 21:46:36 +0300 Subject: [PATCH 12/14] feat: add multi-user access foundation --- USER_ACCOUNTS_IMPLEMENTATION_PLAN.md | 219 ++++++++++++++++ env.example | 14 + server/drizzle/0032_user_accounts.sql | 49 ++++ server/drizzle/meta/_journal.json | 7 + server/package.json | 1 + server/src/api/auth-public.api.test.ts | 135 ++++++++++ server/src/api/auth.api.test.ts | 159 ++++++++++++ server/src/api/auth.api.ts | 240 ++++++++++++++++++ server/src/api/files/controllers.ts | 27 +- server/src/app.ts | 33 ++- .../use-cases/continue-generation.ts | 3 +- .../create-entry-and-start-generation.ts | 3 +- .../use-cases/manual-edit-entry.ts | 3 +- .../use-cases/regenerate-assistant-variant.ts | 3 +- server/src/core/auth/access-policy.test.ts | 61 +++++ server/src/core/auth/access-policy.ts | 40 +++ server/src/core/auth/auth-config.test.ts | 63 +++++ server/src/core/auth/auth-config.ts | 78 ++++++ server/src/core/auth/auth-cookie.ts | 59 +++++ server/src/core/auth/auth-middleware.ts | 64 +++++ server/src/core/auth/security-middleware.ts | 119 +++++++++ .../src/core/auth/trusted-owner-middleware.ts | 120 +++++++++ .../request-context/owner-scope-storage.ts | 17 ++ .../request-context/request-context.test.ts | 28 +- .../core/request-context/request-context.ts | 41 ++- server/src/db/schema.ts | 1 + server/src/db/schema/app-backgrounds.ts | 23 +- server/src/db/schema/auth.ts | 63 +++++ server/src/db/schema/llm.ts | 63 +++-- .../app-backgrounds-repository.ts | 37 ++- .../app-settings/app-settings-repository.ts | 11 +- .../auth/auth-schema.integration.test.ts | 112 ++++++++ server/src/services/auth/auth-service.ts | 149 +++++++++++ .../services/auth/password-service.test.ts | 46 ++++ server/src/services/auth/password-service.ts | 50 ++++ .../auth/session-service.integration.test.ts | 95 +++++++ server/src/services/auth/session-service.ts | 168 ++++++++++++ .../auth/users-repository.integration.test.ts | 102 ++++++++ server/src/services/auth/users-repository.ts | 203 +++++++++++++++ .../services/chat-core/chats-repository.ts | 26 +- .../chat-core/entity-profiles-repository.ts | 35 ++- .../chat-core/generations-repository.ts | 15 +- .../chat-core/instructions-repository.ts | 18 +- .../chat-core/prompt-template-context.ts | 3 +- .../chat-core/user-persons-repository.ts | 47 +++- .../chat-entry-parts/entries-repository.ts | 14 +- .../chat-entry-parts/parts-repository.ts | 17 +- .../chat-entry-parts/variants-repository.ts | 3 +- .../prepare/resolve-run-context.ts | 3 +- .../knowledge-access-repository.ts | 14 +- .../knowledge-collections-repository.ts | 8 +- .../knowledge-links-repository.ts | 6 +- .../knowledge-records-repository.ts | 10 +- .../knowledge-reveal-service.ts | 4 +- .../knowledge-search-service.ts | 4 +- server/src/services/llm/llm-repository.ts | 65 ++++- server/src/services/rag.service.ts | 70 +++-- server/src/services/rag/chroma-rag.service.ts | 28 +- .../services/sidebars/sidebars-repository.ts | 7 +- .../sillytavern-importer.ts | 4 +- .../world-info/world-info-repositories.ts | 33 ++- server/yarn.lock | 38 +++ web/src/api/api-json.ts | 4 +- web/src/api/app-backgrounds.ts | 3 +- web/src/api/auth-fetch.ts | 22 ++ web/src/api/auth.ts | 106 ++++++++ web/src/api/bundles.ts | 6 +- web/src/api/chat-core.ts | 7 +- web/src/api/chat-entry-parts.ts | 6 +- web/src/api/llm.ts | 4 +- web/src/api/world-info.ts | 3 +- web/src/features/auth/account-manager.tsx | 97 +++++++ web/src/features/auth/auth-gate.tsx | 130 ++++++++++ web/src/features/common/avatar-upload.tsx | 3 +- web/src/features/sidebars/left-bar.tsx | 26 +- .../features/sidebars/user-person/index.tsx | 3 +- web/src/i18n/resources/en.ts | 2 + web/src/i18n/resources/en/auth.ts | 35 +++ web/src/i18n/resources/ru.ts | 2 + web/src/i18n/resources/ru/auth.ts | 35 +++ web/src/main.tsx | 8 +- web/src/model/_fabric_/items-model.ts | 13 +- web/src/model/_fabric_/setting-model.ts | 5 +- web/src/model/auth/index.ts | 78 ++++++ .../llm-orchestration/stream-controller.ts | 3 +- web/src/model/llm-orchestration/stream.ts | 3 +- 86 files changed, 3566 insertions(+), 219 deletions(-) create mode 100644 USER_ACCOUNTS_IMPLEMENTATION_PLAN.md create mode 100644 server/drizzle/0032_user_accounts.sql create mode 100644 server/src/api/auth-public.api.test.ts create mode 100644 server/src/api/auth.api.test.ts create mode 100644 server/src/api/auth.api.ts create mode 100644 server/src/core/auth/access-policy.test.ts create mode 100644 server/src/core/auth/access-policy.ts create mode 100644 server/src/core/auth/auth-config.test.ts create mode 100644 server/src/core/auth/auth-config.ts create mode 100644 server/src/core/auth/auth-cookie.ts create mode 100644 server/src/core/auth/auth-middleware.ts create mode 100644 server/src/core/auth/security-middleware.ts create mode 100644 server/src/core/auth/trusted-owner-middleware.ts create mode 100644 server/src/core/request-context/owner-scope-storage.ts create mode 100644 server/src/db/schema/auth.ts create mode 100644 server/src/services/auth/auth-schema.integration.test.ts create mode 100644 server/src/services/auth/auth-service.ts create mode 100644 server/src/services/auth/password-service.test.ts create mode 100644 server/src/services/auth/password-service.ts create mode 100644 server/src/services/auth/session-service.integration.test.ts create mode 100644 server/src/services/auth/session-service.ts create mode 100644 server/src/services/auth/users-repository.integration.test.ts create mode 100644 server/src/services/auth/users-repository.ts create mode 100644 web/src/api/auth-fetch.ts create mode 100644 web/src/api/auth.ts create mode 100644 web/src/features/auth/account-manager.tsx create mode 100644 web/src/features/auth/auth-gate.tsx create mode 100644 web/src/i18n/resources/en/auth.ts create mode 100644 web/src/i18n/resources/ru/auth.ts create mode 100644 web/src/model/auth/index.ts diff --git a/USER_ACCOUNTS_IMPLEMENTATION_PLAN.md b/USER_ACCOUNTS_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..ff469ac2 --- /dev/null +++ b/USER_ACCOUNTS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,219 @@ +# Система пользователей и режимы доступа TaleSpinner + +## Цель + +Добавить несколько изолированных аккаунтов без ухудшения локального UX: + +- в режиме `local` разрешить аккаунты без пароля, быстрый выбор и автоматический вход; +- в режиме `public` включать обязательную аутентификацию и полный набор защит; +- всегда определять владельца данных на сервере, не доверяя `ownerId` из HTTP-запроса; +- сохранить существующие данные с владельцем `global` при обновлении приложения. + +## Инварианты + +1. Количество аккаунтов не зависит от режима доступа. +2. Изоляция данных обязательна в `local` и `public`. +3. Режим меняет проверку личности и защиту HTTP, но не правила владения данными. +4. `user_persons` остаются игровыми персонами внутри аккаунта и не заменяют `users`. +5. Клиент не может выбрать произвольный `ownerId`. +6. Публичный режим не запускается при неполной security-конфигурации. +7. Пароли, session tokens, API-ключи и setup secrets не попадают в логи или API DTO. + +## Конфигурация + +Основной переключатель: + +```env +TALESPINNER_ACCESS_MODE=local # local | public +``` + +Планируемые параметры публичного режима: + +```env +TALESPINNER_SESSION_SECRET= +TALESPINNER_SETUP_TOKEN= +TALESPINNER_ALLOW_REGISTRATION=false +TALESPINNER_SESSION_TTL_DAYS=30 +TALESPINNER_TRUST_PROXY=false +``` + +`public` должен активировать согласованный набор защит целиком. Опциональные флаги +не должны позволять по отдельности отключать обязательные secure cookies, CSRF +или rate limiting. + +## Модель данных + +### `users` + +- `id`; +- `username` и нормализованное уникальное значение для входа; +- `display_name`; +- nullable `password_hash` только для локальных passwordless-аккаунтов; +- роль `admin | user`; +- статус `active | disabled`; +- версия credentials для отзыва сессий; +- даты создания, изменения и последнего входа. + +### `auth_sessions` + +- идентификатор сессии; +- ссылка на пользователя; +- только hash session token; +- способ входа `local | password`; +- даты создания, последней активности, истечения и отзыва. + +Первый пользователь принимает существующий scope `global`, чтобы старые чаты и +настройки не потерялись. Новые пользователи получают собственный UUID scope. +Это действие должно быть атомарным и идемпотентным. + +## Этапы реализации + +### 1. Фундамент backend — реализован + +- [x] Зафиксировать архитектурный план. +- [x] Добавить единый resolver политики `local | public`. +- [x] Закрывать запуск `public` при неполной security-конфигурации. +- [x] Добавить таблицы `users` и `auth_sessions`. +- [x] Расширить request context типизированным authenticated actor. +- [x] Добавить репозитории пользователей и сессий без выдачи секретных полей. +- [x] Добавить интеграционные тесты миграции и ограничений уникальности. + +### 2. Credentials и сессии + +- [x] Выбрать и подключить поддерживаемую реализацию Argon2id. +- [x] Хэшировать пароль только на сервере. +- [x] Генерировать криптографически случайные session tokens. +- [x] Хранить в БД только hash токена. +- [ ] Реализовать создание, продление, отзыв и очистку истёкших сессий. Создание, + проверка и отзыв готовы; фоновой очистки пока нет. +- [ ] Отзывать сессии при смене пароля или отключении пользователя. + +### 3. Setup и локальный вход + +- [x] Добавить endpoint состояния первоначальной настройки без утечки данных. +- [x] Создавать первого администратора и принимать legacy scope `global`. +- [x] В `local` разрешить пустой пароль. +- [x] Автоматически входить в единственный passwordless-аккаунт. +- [ ] Для нескольких аккаунтов поддержать выбор и последний использованный аккаунт. + Выбор готов, запоминание последнего аккаунта пока нет. +- [x] Не позволять local auto-login выбирать аккаунт через неподписанный `ownerId`. + +### 4. Публичный режим + +- [x] Требовать пароль и валидную session-конфигурацию. +- [x] Защитить первоначальный setup токеном из окружения. +- [x] Использовать `HttpOnly`, `Secure`, `SameSite` cookies. +- [x] Добавить CSRF-защиту для изменяющих запросов. +- [ ] Добавить rate limiting и безопасные ошибки входа. Базовый limiter готов, + но перед релизом нужно считать только неуспешные попытки и унифицировать ошибки. +- [x] Настроить security headers, proxy trust и проверку HTTPS. +- [ ] Добавить безопасное восстановление доступа администратора. + +### 5. Перевод API на trusted owner scope + +- [x] Auth middleware устанавливает actor и owner scope до маршрутизации. +- [x] Перезаписывать `ownerId` в body/query доверенным идентификатором сессии. +- [ ] Все get/update/delete выполняются по составному условию `id + ownerId`. +- [ ] Проверять одинакового владельца у связанных сущностей. +- [ ] Сохранить отдельный явно привилегированный admin API там, где он нужен. + +### 6. Аудит хранилищ + +- [ ] Чаты, ветки, сообщения и варианты — основной scope готов, остаются bulk-операции. +- [x] Персоны и entity profiles. +- [ ] World Info, instructions и operation profiles — базовые репозитории переведены, + нужен итоговый аудит связей. +- [ ] RAG, Chroma collections и knowledge store — namespace добавлен, нужны тесты. +- [ ] LLM presets, provider credentials и runtime state — credentials/runtime разделены, + нужны тесты и полный аудит presets. +- [ ] UI settings, темы, фоны, bundles и импорты — settings/backgrounds/sidebars + переведены, темы и bundles требуют финального аудита. +- [ ] Загруженные файлы, аватары и защита путей — namespace и media guard добавлены, + нужны regression-тесты. + +### 7. Frontend + +- [x] Мастер первого запуска. +- [x] Вход, выход и восстановление сессии. +- [x] Локальный account picker и auto-login. +- [x] Базовое создание аккаунтов администратором. +- [ ] Переключение аккаунта с полным сбросом Effector state/cache. +- [x] RU/EN локализация добавленных экранов. + +### 8. Миграция и совместимость + +- [x] Автоматически связать legacy `global` data с первым аккаунтом. +- [x] Не создавать публичного администратора без setup token. +- [ ] Проверить обновление существующей БД и чистую установку. +- [ ] Добавить резервную копию и диагностику перед необратимой миграцией. + +### 9. Проверки готовности + +- [x] Unit-тесты политики доступа, credentials и session lifecycle. +- [ ] Интеграционные тесты cross-owner read/write/delete — есть проверка personas, + нужен полный набор для chats, LLM, RAG, files и bulk mutations. +- [x] API-тесты local/public setup и login/logout. +- [ ] Тесты CSRF, cookies, rate limiting и session revocation. +- [ ] E2E для первого запуска и нескольких аккаунтов. +- [ ] Аудит отсутствия секретов в логах и ответах. +- [ ] Обновить RU/EN документацию и `env.example`. + +## Критерий завершения + +Фича готова, когда два одновременно созданных аккаунта не могут получить данные +друг друга ни через UI, ни через прямые HTTP-запросы, локальный пользователь может +работать без пароля, а публичный сервер отказывается запускаться или обслуживать +запросы без полностью настроенной защиты. + +## Снимок состояния для следующей сессии + +Последнее обновление: 2026-07-20. Рабочая ветка: `agent/user-access-modes`. + +### Уже работает + +- схема `users`/`auth_sessions`, Argon2id и hash-only session storage; +- setup/login/logout/status и admin list/create API; +- локальный passwordless setup, auto-login единственного аккаунта и account picker; +- public fail-closed конфигурация, HTTPS/proxy checks, secure cookies, CSRF и headers; +- frontend auth gate, setup/login/account manager и RU/EN ресурсы; +- серверный authenticated owner scope и первая большая волна изоляции репозиториев; +- owner namespaces для LLM credentials/runtime, RAG и Chroma, settings, backgrounds, + upload/media путей; +- legacy-пользователь получает идентификатор `global`. + +### Не завершено и не готово к релизу + +1. Закончить owner-аудит всех update/delete/bulk путей. В частности, + `softDeleteEntries` пока выбирает записи только по ID и должен делать join с + принадлежащим текущему пользователю чатом. +2. Добавить cross-owner тесты для chats/messages, LLM tokens/config, RAG/Chroma, + backgrounds/uploads и bulk mutations. +3. Исправить login rate limiter: успешные входы сейчас тоже расходуют лимит. +4. Унифицировать публичные ошибки login, чтобы status/наличие аккаунта нельзя было + определить по ответу. +5. Реализовать disable user, смену пароля, отзыв всех сессий и admin recovery. +6. Либо реализовать `TALESPINNER_ALLOW_REGISTRATION`, либо удалить пока не + используемый флаг. +7. Сделать полный сброс frontend state/cache при logout/account switch. +8. Проверить CSRF-поведение в нескольких вкладках: текущая ротация может инвалидировать + токен соседней вкладки. +9. Проверить миграцию `0032` на чистой и существующей БД, добавить backup/diagnostics. +10. Обновить RU/EN пользовательскую документацию и API docs. +11. Прогнать formatter/lint, весь server/web test suite и production builds; исправить + старые API-тесты, которые теперь должны аутентифицироваться. + +Этот коммит является промежуточной контрольной точкой. Галочки выше означают +реализованный код, но весь критерий завершения пока не выполнен. + +### Проверки этой контрольной точки + +- `yarn typecheck:server` — passed; +- `yarn typecheck:web` — passed; +- `yarn lint:server` — passed; +- `yarn lint:web` — passed; +- targeted Vitest для access policy, auth config, password/session services, + user repository и local/public auth API — 6 files / 18 tests passed; +- `git diff --check` — passed (выводит только ожидаемые Windows LF/CRLF warnings). + +Полные server/web suites и production builds на этой промежуточной точке не +запускались; они явно оставлены в списке незавершённых работ. diff --git a/env.example b/env.example index 07e0074a..1fb5e7d4 100644 --- a/env.example +++ b/env.example @@ -4,6 +4,20 @@ # Backend listen port (1-65535). PORT=5000 +# Account access policy: local | public. +# local allows passwordless accounts and automatic login. +# public requires the hardened authentication settings below. +TALESPINNER_ACCESS_MODE=local + +# Required in public mode. Use independent random values. +TALESPINNER_SESSION_SECRET= +TALESPINNER_SETUP_TOKEN= + +# Public deployments are expected to terminate HTTPS at a trusted reverse proxy. +TALESPINNER_TRUST_PROXY=false +TALESPINNER_ALLOW_REGISTRATION=false +TALESPINNER_SESSION_TTL_DAYS=30 + # Base directory for backend runtime data. # Absolute path is supported. # Relative path is resolved from monorepo root. diff --git a/server/drizzle/0032_user_accounts.sql b/server/drizzle/0032_user_accounts.sql new file mode 100644 index 00000000..a3c432cb --- /dev/null +++ b/server/drizzle/0032_user_accounts.sql @@ -0,0 +1,49 @@ +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `normalized_username` text NOT NULL, + `display_name` text NOT NULL, + `password_hash` text, + `role` text DEFAULT 'user' NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `credential_version` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `last_login_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_normalized_username_uq` ON `users` (`normalized_username`); +--> statement-breakpoint +CREATE INDEX `users_status_updated_at_idx` ON `users` (`status`,`updated_at`); +--> statement-breakpoint +CREATE TABLE `auth_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `token_hash` text NOT NULL, + `csrf_token_hash` text NOT NULL, + `auth_method` text NOT NULL, + `credential_version` integer NOT NULL, + `created_at` integer NOT NULL, + `last_seen_at` integer NOT NULL, + `expires_at` integer NOT NULL, + `revoked_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `auth_sessions_token_hash_uq` ON `auth_sessions` (`token_hash`); +--> statement-breakpoint +CREATE INDEX `auth_sessions_user_expires_at_idx` ON `auth_sessions` (`user_id`,`expires_at`); +--> statement-breakpoint +CREATE INDEX `auth_sessions_expires_at_idx` ON `auth_sessions` (`expires_at`); +--> statement-breakpoint +ALTER TABLE `llm_provider_configs` ADD `owner_id` text DEFAULT 'global' NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX `llm_provider_configs_owner_provider_uq` ON `llm_provider_configs` (`owner_id`,`provider_id`); +--> statement-breakpoint +ALTER TABLE `llm_tokens` ADD `owner_id` text DEFAULT 'global' NOT NULL; +--> statement-breakpoint +CREATE INDEX `llm_tokens_owner_provider_idx` ON `llm_tokens` (`owner_id`,`provider_id`); +--> statement-breakpoint +ALTER TABLE `ui_app_backgrounds` ADD `owner_id` text DEFAULT 'global' NOT NULL; +--> statement-breakpoint +CREATE INDEX `ui_app_backgrounds_owner_id_idx` ON `ui_app_backgrounds` (`owner_id`); diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 3cde38e9..536c1b94 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1774060000000, "tag": "0031_chat_knowledge_store", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1784560000000, + "tag": "0032_user_accounts", + "breakpoints": true } ] } diff --git a/server/package.json b/server/package.json index a20c1494..1553375a 100644 --- a/server/package.json +++ b/server/package.json @@ -8,6 +8,7 @@ "dependencies": { "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", + "argon2": "0.44.0", "axios": "^1.13.2", "better-sqlite3": "^12.6.0", "chromadb": "^3.3.1", diff --git a/server/src/api/auth-public.api.test.ts b/server/src/api/auth-public.api.test.ts new file mode 100644 index 00000000..a01ff2c5 --- /dev/null +++ b/server/src/api/auth-public.api.test.ts @@ -0,0 +1,135 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { bootstrapApp, createApp } from "../app"; +import { resetDbForTests } from "../db/client"; + +const PUBLIC_ENV = { + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_SESSION_SECRET: "public-session-secret-value-123456789", + TALESPINNER_SETUP_TOKEN: "public-setup-token", + TALESPINNER_TRUST_PROXY: "true", +} as const; + +describe("auth API in public mode", () => { + let tempDir = ""; + let server: ReturnType["listen"]>; + let baseUrl = ""; + const previous = new Map(); + + beforeEach(async () => { + for (const [key, value] of Object.entries(PUBLIC_ENV)) { + previous.set(key, process.env[key]); + process.env[key] = value; + } + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-public-auth-")); + await bootstrapApp({ dbPath: path.join(tempDir, "db.sqlite") }); + const app = createApp(); + server = await new Promise((resolve) => { + const started = app.listen(0, "127.0.0.1", () => resolve(started)); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + baseUrl = `http://127.0.0.1:${address.port}/api`; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + for (const key of Object.keys(PUBLIC_ENV)) { + const value = previous.get(key); + if (typeof value === "string") process.env[key] = value; + else delete process.env[key]; + } + }); + + const secureHeaders = { + "content-type": "application/json", + "x-forwarded-proto": "https", + }; + + test("requires HTTPS and a setup token, then issues hardened cookies", async () => { + const insecure = await fetch(`${baseUrl}/auth/status`); + expect(insecure.status).toBe(426); + expect(insecure.headers.get("x-frame-options")).toBe("DENY"); + + const rejected = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(rejected.status).toBe(403); + + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(setup.status).toBe(201); + expect(setup.headers.get("set-cookie")).toMatch(/HttpOnly.*Secure.*SameSite=Strict/); + await expect(setup.json()).resolves.toMatchObject({ + data: { + user: { role: "admin", hasPassword: true }, + csrfToken: expect.any(String), + }, + }); + }); + + test("requires CSRF for authenticated mutations", async () => { + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + const body = (await setup.json()) as { + data: { csrfToken: string }; + }; + const cookie = setup.headers.get("set-cookie")?.split(";")[0] ?? ""; + + const rejected = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: { ...secureHeaders, cookie }, + body: JSON.stringify({ + username: "bob", + password: "another-strong-password", + }), + }); + expect(rejected.status).toBe(403); + + const accepted = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: { + ...secureHeaders, + cookie, + "x-csrf-token": body.data.csrfToken, + }, + body: JSON.stringify({ + username: "bob", + password: "another-strong-password", + }), + }); + expect(accepted.status).toBe(201); + }); +}); diff --git a/server/src/api/auth.api.test.ts b/server/src/api/auth.api.test.ts new file mode 100644 index 00000000..5968aa43 --- /dev/null +++ b/server/src/api/auth.api.test.ts @@ -0,0 +1,159 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { bootstrapApp, createApp } from "../app"; +import { resetDbForTests } from "../db/client"; + +describe("auth API in local mode", () => { + let tempDir = ""; + let server: ReturnType["listen"]>; + let baseUrl = ""; + let previousMode: string | undefined; + + beforeEach(async () => { + previousMode = process.env.TALESPINNER_ACCESS_MODE; + process.env.TALESPINNER_ACCESS_MODE = "local"; + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-auth-api-")); + await bootstrapApp({ dbPath: path.join(tempDir, "db.sqlite") }); + const app = createApp(); + server = await new Promise((resolve) => { + const started = app.listen(0, "127.0.0.1", () => resolve(started)); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + baseUrl = `http://127.0.0.1:${address.port}/api`; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + if (typeof previousMode === "string") { + process.env.TALESPINNER_ACCESS_MODE = previousMode; + } else { + delete process.env.TALESPINNER_ACCESS_MODE; + } + }); + + function cookieFrom(response: Response): string { + const header = response.headers.get("set-cookie"); + if (!header) throw new Error("Missing session cookie"); + return header.split(";")[0]; + } + + test("requires setup, creates the first admin, and protects app APIs", async () => { + const initialStatus = await fetch(`${baseUrl}/auth/status`); + await expect(initialStatus.json()).resolves.toMatchObject({ + data: { + mode: "local", + setupRequired: true, + authenticated: false, + }, + }); + + const denied = await fetch(`${baseUrl}/user-persons`); + expect(denied.status).toBe(401); + + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "alice", password: "" }), + }); + expect(setup.status).toBe(201); + const cookie = cookieFrom(setup); + await expect(setup.json()).resolves.toMatchObject({ + data: { + user: { + id: "global", + username: "alice", + role: "admin", + hasPassword: false, + }, + }, + }); + + const allowed = await fetch(`${baseUrl}/user-persons`, { + headers: { cookie }, + }); + expect(allowed.status).toBe(200); + }); + + test("supports multiple passwordless local accounts", async () => { + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "" }), + }); + const adminCookie = cookieFrom(setup); + const adminPersonaResponse = await fetch(`${baseUrl}/user-persons`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: adminCookie, + }, + body: JSON.stringify({ name: "Admin persona" }), + }); + const adminPersona = (await adminPersonaResponse.json()) as { + data: { id: string }; + }; + + const created = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: adminCookie, + }, + body: JSON.stringify({ username: "bob", password: "", role: "user" }), + }); + expect(created.status).toBe(201); + const createdBody = (await created.json()) as { + data: { id: string }; + }; + + await fetch(`${baseUrl}/auth/logout`, { + method: "POST", + headers: { cookie: adminCookie }, + }); + const status = await fetch(`${baseUrl}/auth/status`); + await expect(status.json()).resolves.toMatchObject({ + data: { + authenticated: false, + accounts: [ + { username: "admin" }, + { username: "bob" }, + ], + }, + }); + + const login = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + userId: createdBody.data.id, + password: "", + }), + }); + expect(login.status).toBe(200); + const userCookie = cookieFrom(login); + await expect(login.json()).resolves.toMatchObject({ + data: { user: { username: "bob", role: "user" } }, + }); + + const isolatedList = await fetch(`${baseUrl}/user-persons`, { + headers: { cookie: userCookie }, + }); + await expect(isolatedList.json()).resolves.toMatchObject({ data: [] }); + + const directRead = await fetch( + `${baseUrl}/user-persons/${adminPersona.data.id}`, + { headers: { cookie: userCookie } } + ); + expect(directRead.status).toBe(404); + }); +}); diff --git a/server/src/api/auth.api.ts b/server/src/api/auth.api.ts new file mode 100644 index 00000000..7873bb8a --- /dev/null +++ b/server/src/api/auth.api.ts @@ -0,0 +1,240 @@ +import { timingSafeEqual } from "node:crypto"; + +import express, { type Request, type Response } from "express"; +import { z } from "zod"; + +import { clearSessionCookie, setSessionCookie } from "../core/auth/auth-cookie"; +import { requireAuthenticatedApi } from "../core/auth/auth-middleware"; +import { createLoginRateLimitMiddleware } from "../core/auth/security-middleware"; +import { asyncHandler } from "../core/middleware/async-handler"; +import { HttpError } from "../core/middleware/error-handler"; +import { validate } from "../core/middleware/validate"; +import { + createAdditionalUser, + loginUser, + setupInitialUser, + AuthServiceError, + type AuthResult, +} from "../services/auth/auth-service"; +import { + revokeAuthSession, + rotateSessionCsrfToken, +} from "../services/auth/session-service"; +import { + countUsers, + listActiveUsers, + listUsers, +} from "../services/auth/users-repository"; + +import type { AuthConfig } from "../core/auth/auth-config"; + +const identitySchema = z.string().trim().min(1).max(128); +const passwordSchema = z.string().max(1024); + +const setupBodySchema = z.object({ + username: identitySchema.max(64), + displayName: identitySchema.optional(), + password: passwordSchema.default(""), +}); + +const loginBodySchema = z + .object({ + username: z.string().trim().max(64).optional(), + userId: z.string().trim().min(1).optional(), + password: passwordSchema.default(""), + }) + .refine((value) => Boolean(value.username || value.userId), { + message: "username or userId is required", + }); + +const createUserBodySchema = setupBodySchema.extend({ + role: z.enum(["admin", "user"]).default("user"), +}); + +function safeTokenEquals(actual: string | undefined, expected: string): boolean { + if (!actual) return false; + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return ( + actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer) + ); +} + +function requirePublicSetupToken(request: Request, config: AuthConfig): void { + if (config.policy.mode !== "public") return; + if ( + !config.setupToken || + !safeTokenEquals(request.header("x-setup-token"), config.setupToken) + ) { + throw new HttpError(403, "Invalid setup token.", "INVALID_SETUP_TOKEN"); + } +} + +function mapAuthError(error: unknown): never { + if (!(error instanceof AuthServiceError)) throw error; + const status = error.code === "SETUP_COMPLETED" ? 409 : 401; + throw new HttpError(status, error.message, error.code); +} + +function applyAuthResult( + response: Response, + result: AuthResult, + config: AuthConfig +) { + setSessionCookie({ + response, + token: result.session.token, + expiresAt: result.session.expiresAt, + config, + }); + return { + user: result.user, + csrfToken: result.session.csrfToken, + expiresAt: result.session.expiresAt, + }; +} + +export function createAuthRouter(config: AuthConfig) { + const router = express.Router(); + const authRateLimit = createLoginRateLimitMiddleware(config); + + router.get( + "/status", + asyncHandler(async (request: Request, response: Response) => { + const userCount = await countUsers(); + if (userCount === 0) { + return { + data: { + mode: config.policy.mode, + setupRequired: true, + authenticated: false, + user: null, + accounts: [], + }, + }; + } + + if (request.auth) { + const csrfToken = await rotateSessionCsrfToken( + request.auth.sessionId, + config + ); + return { + data: { + mode: config.policy.mode, + setupRequired: false, + authenticated: true, + user: request.auth.user, + accounts: [], + csrfToken, + }, + }; + } + + const accounts = + config.policy.mode === "local" ? await listActiveUsers() : []; + if ( + config.policy.automaticLoginAllowed && + accounts.length === 1 && + !accounts[0]?.hasPassword + ) { + const result = await loginUser({ + userId: accounts[0].id, + password: "", + config, + }); + return { + data: { + mode: config.policy.mode, + setupRequired: false, + authenticated: true, + ...applyAuthResult(response, result, config), + accounts: [], + }, + }; + } + + return { + data: { + mode: config.policy.mode, + setupRequired: false, + authenticated: false, + user: null, + accounts, + }, + }; + }) + ); + + router.post( + "/setup", + authRateLimit, + validate({ body: setupBodySchema }), + asyncHandler(async (request: Request, response: Response) => { + requirePublicSetupToken(request, config); + const body = setupBodySchema.parse(request.body); + try { + const result = await setupInitialUser({ ...body, config }); + return { + status: 201, + data: applyAuthResult(response, result, config), + }; + } catch (error) { + mapAuthError(error); + } + }) + ); + + router.post( + "/login", + authRateLimit, + validate({ body: loginBodySchema }), + asyncHandler(async (request: Request, response: Response) => { + const body = loginBodySchema.parse(request.body); + try { + const result = await loginUser({ ...body, config }); + return { data: applyAuthResult(response, result, config) }; + } catch (error) { + mapAuthError(error); + } + }) + ); + + router.post( + "/logout", + requireAuthenticatedApi, + asyncHandler(async (request: Request, response: Response) => { + if (request.auth) await revokeAuthSession(request.auth.sessionId); + clearSessionCookie(response, config); + return { data: { ok: true } }; + }) + ); + + router.get( + "/users", + requireAuthenticatedApi, + asyncHandler(async (request: Request) => { + if (request.auth?.user.role !== "admin") { + throw new HttpError(403, "Administrator access is required.", "FORBIDDEN"); + } + return { data: await listUsers() }; + }) + ); + + router.post( + "/users", + requireAuthenticatedApi, + validate({ body: createUserBodySchema }), + asyncHandler(async (request: Request) => { + if (request.auth?.user.role !== "admin") { + throw new HttpError(403, "Administrator access is required.", "FORBIDDEN"); + } + const body = createUserBodySchema.parse(request.body); + const user = await createAdditionalUser({ ...body, config }); + return { status: 201, data: user }; + }) + ); + + return router; +} diff --git a/server/src/api/files/controllers.ts b/server/src/api/files/controllers.ts index b1c3d4e4..48763f0c 100644 --- a/server/src/api/files/controllers.ts +++ b/server/src/api/files/controllers.ts @@ -7,6 +7,7 @@ import sharp from "sharp"; import { resolveSafePath } from "@core/files/safe-path"; import { type AsyncRequestHandler } from "@core/middleware/async-handler"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import fileService from "@services/file-service"; import { createDataPath } from "../../utils"; @@ -23,6 +24,16 @@ function readFilenameOrThrow(raw: unknown): string { if (typeof filename !== "string" || filename.trim().length === 0) { throw new HttpError(400, "filename обязателен", "VALIDATION_ERROR"); } + const ownerId = resolveTrustedOwnerId(); + const scopedSeparator = filename.indexOf("__"); + if ( + (ownerId !== "global" && !filename.startsWith(`${ownerId}__`)) || + (ownerId === "global" && + scopedSeparator >= 0 && + !filename.startsWith("global__")) + ) { + throw new HttpError(404, "Файл не найден", "NOT_FOUND"); + } return filename; } @@ -58,7 +69,7 @@ export const uploadFiles: AsyncRequestHandler = async (req) => { const uploadedFiles = await Promise.all( req.files.map(async (file) => { const fileExtension = path.extname(file.originalname); - const filename = `${uuidv4()}${fileExtension}`; + const filename = `${resolveTrustedOwnerId()}__${uuidv4()}${fileExtension}`; await fileService.saveFile(file.buffer, filename); return { originalName: file.originalname, @@ -138,14 +149,15 @@ export const uploadCards: AsyncRequestHandler = async (req) => { const cardImagesPath = path.join( createDataPath("media", "images"), - "agent-cards" + "agent-cards", + resolveTrustedOwnerId() ); await fs.mkdir(cardImagesPath, { recursive: true }); for (const file of req.files) { try { const fileExtension = path.extname(file.originalname).toLowerCase(); - const filename = `${uuidv4()}${fileExtension}`; + const filename = `${resolveTrustedOwnerId()}__${uuidv4()}${fileExtension}`; const filePath = resolveSafePath(cardImagesPath, filename); if (fileExtension === ".png") { @@ -167,7 +179,7 @@ export const uploadCards: AsyncRequestHandler = async (req) => { processedFiles.push({ originalName: file.originalname, filename, - path: `/media/images/agent-cards/${filename}`, + path: `/media/images/agent-cards/${resolveTrustedOwnerId()}/${filename}`, characterData: [JSON.parse(characterData)], metadata: { ...metadata, @@ -183,7 +195,7 @@ export const uploadCards: AsyncRequestHandler = async (req) => { processedFiles.push({ originalName: file.originalname, filename, - path: `/media/images/agent-cards/${filename}`, + path: `/media/images/agent-cards/${resolveTrustedOwnerId()}/${filename}`, metadata: { width: 0, height: 0, @@ -222,7 +234,8 @@ export const uploadImage: AsyncRequestHandler = async (req) => { const imageFolder = path.join( createDataPath("media", "images"), - sanitizedFolderName + sanitizedFolderName, + resolveTrustedOwnerId() ); // Создаем папку, если она не существует @@ -251,7 +264,7 @@ export const uploadImage: AsyncRequestHandler = async (req) => { return { data: { file: uploadedFile, - path: `/media/images/${sanitizedFolderName}/${filename}`, + path: `/media/images/${sanitizedFolderName}/${resolveTrustedOwnerId()}/${filename}`, message: "Изображение успешно загружено", }, }; diff --git a/server/src/app.ts b/server/src/app.ts index c4420082..5c74eb91 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,7 +5,23 @@ import express, { type Express } from "express"; import morgan from "morgan"; import { routes } from "./api/_routes_"; +import { createAuthRouter } from "./api/auth.api"; import staticRouter from "./api/static.api"; +import { resolveAccessPolicy } from "./core/auth/access-policy"; +import { resolveAuthConfig } from "./core/auth/auth-config"; +import { + createAuthContextMiddleware, + requireAuthenticatedApi, +} from "./core/auth/auth-middleware"; +import { + createCsrfProtectionMiddleware, + createHttpsEnforcementMiddleware, + securityHeadersMiddleware, +} from "./core/auth/security-middleware"; +import { + mediaOwnerMiddleware, + trustedOwnerMiddleware, +} from "./core/auth/trusted-owner-middleware"; import { runBackendBootstrap } from "./core/bootstrap/bootstrap-coordinator"; import { structuredLogger } from "./core/logging/structured-logger"; import { errorHandler } from "./core/middleware/error-handler"; @@ -30,8 +46,16 @@ function shouldUseRequestLogging(): boolean { export function createApp(): Express { const app = express(); + const authConfig = resolveAuthConfig(); + const accessPolicy = resolveAccessPolicy(); const networkPolicy = resolveServerNetworkPolicy(); + app.locals.accessPolicy = accessPolicy; + app.locals.authConfig = authConfig; + if (authConfig.trustProxy) app.set("trust proxy", 1); + + app.use(securityHeadersMiddleware); + app.use(createHttpsEnforcementMiddleware(authConfig)); if (shouldUseRequestLogging()) { app.use(morgan("dev")); } @@ -39,10 +63,16 @@ export function createApp(): Express { app.use( cors({ origin: (origin, callback) => callback(null, networkPolicy.isOriginAllowed(origin)), + credentials: true, }) ); app.use(express.json({ limit: "10mb" })); app.use(requestContextMiddleware); + app.use("/api", createAuthContextMiddleware(authConfig)); + app.use("/media", createAuthContextMiddleware(authConfig)); + app.use("/media", requireAuthenticatedApi, mediaOwnerMiddleware); + app.use("/api", trustedOwnerMiddleware); + app.use("/api", createCsrfProtectionMiddleware(authConfig)); if (shouldUseRequestLogging()) { app.use(requestLifecycleLogger); } @@ -50,7 +80,8 @@ export function createApp(): Express { app.use(express.static("public")); app.use(staticRouter); - app.use("/api", routes); + app.use("/api/auth", createAuthRouter(authConfig)); + app.use("/api", requireAuthenticatedApi, routes); app.use(errorHandler(structuredLogger)); diff --git a/server/src/application/chat-runtime/use-cases/continue-generation.ts b/server/src/application/chat-runtime/use-cases/continue-generation.ts index 21f38d74..22d6cd28 100644 --- a/server/src/application/chat-runtime/use-cases/continue-generation.ts +++ b/server/src/application/chat-runtime/use-cases/continue-generation.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -35,7 +36,7 @@ export async function continueGeneration( const chat = await getChatById(params.chatId); if (!chat) throw new HttpError(404, "Chat не найден", "NOT_FOUND"); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const branchId = params.body.branchId || chat.activeBranchId; if (!branchId) { throw new HttpError(400, "branchId обязателен (нет activeBranchId)", "VALIDATION_ERROR"); diff --git a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts index 24dd06b1..ead20c16 100644 --- a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts +++ b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -38,7 +39,7 @@ export async function createEntryAndStartGeneration( const chat = await getChatById(params.chatId); if (!chat) throw new HttpError(404, "Chat не найден", "NOT_FOUND"); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const branchId = params.body.branchId || chat.activeBranchId; if (!branchId) { throw new HttpError(400, "branchId обязателен (нет activeBranchId)", "VALIDATION_ERROR"); diff --git a/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts b/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts index 837d22b4..cf3f4096 100644 --- a/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts +++ b/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -53,7 +54,7 @@ export async function manualEditEntry( requestedPartId: params.body.partId, }); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const chat = await getChatById(entry.chatId); const templateContext = await buildInstructionRenderContext({ ownerId, diff --git a/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts b/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts index 8f02e86c..de61502f 100644 --- a/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts +++ b/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -41,7 +42,7 @@ export async function regenerateAssistantVariant( const chat = await getChatById(entry.chatId); if (!chat) throw new HttpError(404, "Chat не найден", "NOT_FOUND"); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const currentTurn = await getBranchCurrentTurn({ branchId: entry.branchId }); const userTurnTarget = await resolveRegenerateUserTurnTarget({ chatId: entry.chatId, diff --git a/server/src/core/auth/access-policy.test.ts b/server/src/core/auth/access-policy.test.ts new file mode 100644 index 00000000..084cd6c2 --- /dev/null +++ b/server/src/core/auth/access-policy.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; + +import { createApp } from "../../app"; + +import { resolveAccessPolicy } from "./access-policy"; + +describe("access policy", () => { + test("defaults to a frictionless local policy", () => { + expect(resolveAccessPolicy({})).toEqual({ + mode: "local", + passwordRequired: false, + passwordlessLoginAllowed: true, + automaticLoginAllowed: true, + secureCookiesRequired: false, + loginRateLimitRequired: false, + csrfProtectionRequired: false, + }); + }); + + test("enables the complete public security policy", () => { + expect( + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: "public" }) + ).toEqual({ + mode: "public", + passwordRequired: true, + passwordlessLoginAllowed: false, + automaticLoginAllowed: false, + secureCookiesRequired: true, + loginRateLimitRequired: true, + csrfProtectionRequired: true, + }); + }); + + test("normalizes configured mode and rejects unknown values", () => { + expect( + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: " LOCAL " }).mode + ).toBe("local"); + + expect(() => + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: "shared" }) + ).toThrow(/TALESPINNER_ACCESS_MODE/); + }); + + test("exposes the active local policy to server middleware", () => { + const previousMode = process.env.TALESPINNER_ACCESS_MODE; + process.env.TALESPINNER_ACCESS_MODE = "local"; + + try { + expect(createApp().locals.accessPolicy).toMatchObject({ + mode: "local", + passwordRequired: false, + }); + } finally { + if (typeof previousMode === "string") { + process.env.TALESPINNER_ACCESS_MODE = previousMode; + } else { + delete process.env.TALESPINNER_ACCESS_MODE; + } + } + }); +}); diff --git a/server/src/core/auth/access-policy.ts b/server/src/core/auth/access-policy.ts new file mode 100644 index 00000000..d2fa758a --- /dev/null +++ b/server/src/core/auth/access-policy.ts @@ -0,0 +1,40 @@ +export type AccessMode = "local" | "public"; + +export type AccessPolicy = { + mode: AccessMode; + passwordRequired: boolean; + passwordlessLoginAllowed: boolean; + automaticLoginAllowed: boolean; + secureCookiesRequired: boolean; + loginRateLimitRequired: boolean; + csrfProtectionRequired: boolean; +}; + +type AccessEnvironment = Record; + +function resolveAccessMode(environment: AccessEnvironment): AccessMode { + const configured = environment.TALESPINNER_ACCESS_MODE?.trim().toLowerCase(); + if (!configured || configured === "local") return "local"; + if (configured === "public") return "public"; + + throw new Error( + `Invalid TALESPINNER_ACCESS_MODE "${configured}". Expected "local" or "public".` + ); +} + +export function resolveAccessPolicy( + environment: AccessEnvironment = process.env +): AccessPolicy { + const mode = resolveAccessMode(environment); + const isPublic = mode === "public"; + + return { + mode, + passwordRequired: isPublic, + passwordlessLoginAllowed: !isPublic, + automaticLoginAllowed: !isPublic, + secureCookiesRequired: isPublic, + loginRateLimitRequired: isPublic, + csrfProtectionRequired: isPublic, + }; +} diff --git a/server/src/core/auth/auth-config.test.ts b/server/src/core/auth/auth-config.test.ts new file mode 100644 index 00000000..ada3a49a --- /dev/null +++ b/server/src/core/auth/auth-config.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; + +import { resolveAuthConfig } from "./auth-config"; + +describe("auth config", () => { + test("provides local defaults without secrets", () => { + expect(resolveAuthConfig({})).toMatchObject({ + policy: { mode: "local" }, + sessionCookieName: "talespinner_session", + sessionTtlMs: 30 * 24 * 60 * 60 * 1000, + sessionSecret: null, + setupToken: null, + allowRegistration: false, + trustProxy: false, + }); + }); + + test("requires secrets in public mode", () => { + expect(() => + resolveAuthConfig({ + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_TRUST_PROXY: "true", + }) + ).toThrow(/SESSION_SECRET/); + + expect(() => + resolveAuthConfig({ + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_TRUST_PROXY: "true", + TALESPINNER_SESSION_SECRET: "s".repeat(32), + }) + ).toThrow(/SETUP_TOKEN/); + }); + + test("parses a complete public configuration", () => { + expect( + resolveAuthConfig({ + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_SESSION_SECRET: "s".repeat(32), + TALESPINNER_SETUP_TOKEN: "setup-token-value", + TALESPINNER_ALLOW_REGISTRATION: "true", + TALESPINNER_SESSION_TTL_DAYS: "7", + TALESPINNER_TRUST_PROXY: "1", + }) + ).toMatchObject({ + policy: { mode: "public" }, + sessionTtlMs: 7 * 24 * 60 * 60 * 1000, + sessionSecret: "s".repeat(32), + setupToken: "setup-token-value", + allowRegistration: true, + trustProxy: true, + }); + }); + + test("rejects invalid session TTL values", () => { + expect(() => + resolveAuthConfig({ TALESPINNER_SESSION_TTL_DAYS: "0" }) + ).toThrow(/SESSION_TTL_DAYS/); + expect(() => + resolveAuthConfig({ TALESPINNER_SESSION_TTL_DAYS: "abc" }) + ).toThrow(/SESSION_TTL_DAYS/); + }); +}); diff --git a/server/src/core/auth/auth-config.ts b/server/src/core/auth/auth-config.ts new file mode 100644 index 00000000..6231e9ec --- /dev/null +++ b/server/src/core/auth/auth-config.ts @@ -0,0 +1,78 @@ +import { resolveAccessPolicy, type AccessPolicy } from "./access-policy"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_SESSION_TTL_DAYS = 30; +const MIN_PUBLIC_SECRET_LENGTH = 32; +const MIN_SETUP_TOKEN_LENGTH = 16; + +type AuthEnvironment = Record; + +export type AuthConfig = { + policy: AccessPolicy; + sessionCookieName: string; + sessionTtlMs: number; + sessionSecret: string | null; + setupToken: string | null; + allowRegistration: boolean; + trustProxy: boolean; +}; + +function parseBoolean(value: string | undefined): boolean { + return value === "1" || value?.trim().toLowerCase() === "true"; +} + +function parseSessionTtl(value: string | undefined): number { + if (!value?.trim()) return DEFAULT_SESSION_TTL_DAYS * DAY_MS; + const days = Number(value); + if (!Number.isInteger(days) || days < 1 || days > 365) { + throw new Error( + "TALESPINNER_SESSION_TTL_DAYS must be an integer between 1 and 365." + ); + } + return days * DAY_MS; +} + +function optionalSecret(value: string | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +function validatePublicSecrets(config: AuthConfig): void { + if (config.policy.mode !== "public") return; + if (!config.trustProxy) { + throw new Error( + "TALESPINNER_TRUST_PROXY=true is required in public mode." + ); + } + if ( + !config.sessionSecret || + config.sessionSecret.length < MIN_PUBLIC_SECRET_LENGTH + ) { + throw new Error( + `TALESPINNER_SESSION_SECRET must contain at least ${MIN_PUBLIC_SECRET_LENGTH} characters in public mode.` + ); + } + if (!config.setupToken || config.setupToken.length < MIN_SETUP_TOKEN_LENGTH) { + throw new Error( + `TALESPINNER_SETUP_TOKEN must contain at least ${MIN_SETUP_TOKEN_LENGTH} characters in public mode.` + ); + } +} + +export function resolveAuthConfig( + environment: AuthEnvironment = process.env +): AuthConfig { + const config: AuthConfig = { + policy: resolveAccessPolicy(environment), + sessionCookieName: "talespinner_session", + sessionTtlMs: parseSessionTtl(environment.TALESPINNER_SESSION_TTL_DAYS), + sessionSecret: optionalSecret(environment.TALESPINNER_SESSION_SECRET), + setupToken: optionalSecret(environment.TALESPINNER_SETUP_TOKEN), + allowRegistration: parseBoolean( + environment.TALESPINNER_ALLOW_REGISTRATION + ), + trustProxy: parseBoolean(environment.TALESPINNER_TRUST_PROXY), + }; + validatePublicSecrets(config); + return config; +} diff --git a/server/src/core/auth/auth-cookie.ts b/server/src/core/auth/auth-cookie.ts new file mode 100644 index 00000000..46896737 --- /dev/null +++ b/server/src/core/auth/auth-cookie.ts @@ -0,0 +1,59 @@ +import type { AuthConfig } from "./auth-config"; +import type { Response } from "express"; + + +export function readCookie( + cookieHeader: string | undefined, + name: string +): string | null { + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const separator = part.indexOf("="); + if (separator < 0) continue; + const key = part.slice(0, separator).trim(); + if (key !== name) continue; + try { + return decodeURIComponent(part.slice(separator + 1)); + } catch { + return null; + } + } + return null; +} + +function cookieSecurity(config: AuthConfig): string { + return config.policy.secureCookiesRequired + ? "; Secure; SameSite=Strict" + : "; SameSite=Lax"; +} + +export function setSessionCookie(params: { + response: Response; + token: string; + expiresAt: Date; + config: AuthConfig; +}): void { + const maxAgeSeconds = Math.max( + 0, + Math.floor((params.expiresAt.getTime() - Date.now()) / 1000) + ); + params.response.append( + "Set-Cookie", + `${params.config.sessionCookieName}=${encodeURIComponent(params.token)}` + + `; Path=/; HttpOnly; Max-Age=${maxAgeSeconds}` + + `; Expires=${params.expiresAt.toUTCString()}` + + cookieSecurity(params.config) + ); +} + +export function clearSessionCookie( + response: Response, + config: AuthConfig +): void { + response.append( + "Set-Cookie", + `${config.sessionCookieName}=; Path=/; HttpOnly; Max-Age=0` + + "; Expires=Thu, 01 Jan 1970 00:00:00 GMT" + + cookieSecurity(config) + ); +} diff --git a/server/src/core/auth/auth-middleware.ts b/server/src/core/auth/auth-middleware.ts new file mode 100644 index 00000000..f0c2a32d --- /dev/null +++ b/server/src/core/auth/auth-middleware.ts @@ -0,0 +1,64 @@ +import { + resolveAuthSession, + type SessionPrincipal, +} from "../../services/auth/session-service"; +import { asyncHandler } from "../middleware/async-handler"; +import { setAuthenticatedUserContext } from "../request-context/request-context"; + +import { clearSessionCookie, readCookie } from "./auth-cookie"; + +import type { AuthConfig } from "./auth-config"; +import type { RequestHandler } from "express"; + + +declare module "express-serve-static-core" { + interface Request { + auth?: SessionPrincipal; + } +} + +export function createAuthContextMiddleware( + config: AuthConfig +): RequestHandler { + return asyncHandler(async (request, response, next) => { + const token = readCookie( + request.header("cookie"), + config.sessionCookieName + ); + if (!token) { + next(); + return; + } + + const principal = await resolveAuthSession({ token, config }); + if (!principal) { + clearSessionCookie(response, config); + next(); + return; + } + + request.auth = principal; + setAuthenticatedUserContext(request, { + userId: principal.user.id, + role: principal.user.role, + }); + next(); + }); +} + +export const requireAuthenticatedApi: RequestHandler = ( + request, + response, + next +) => { + if (request.auth) { + next(); + return; + } + response.status(401).json({ + error: { + code: "AUTH_REQUIRED", + message: "Authentication is required.", + }, + }); +}; diff --git a/server/src/core/auth/security-middleware.ts b/server/src/core/auth/security-middleware.ts new file mode 100644 index 00000000..56f77f8c --- /dev/null +++ b/server/src/core/auth/security-middleware.ts @@ -0,0 +1,119 @@ +import { verifySessionCsrfToken } from "../../services/auth/session-service"; + +import type { AuthConfig } from "./auth-config"; +import type { RequestHandler } from "express"; + + + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const CSRF_EXEMPT_PATHS = new Set(["/auth/login", "/auth/setup"]); + +export const securityHeadersMiddleware: RequestHandler = ( + _request, + response, + next +) => { + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + next(); +}; + +export function createHttpsEnforcementMiddleware( + config: AuthConfig +): RequestHandler { + return (request, response, next) => { + if (config.policy.mode !== "public" || request.secure) { + next(); + return; + } + response.status(426).json({ + error: { + code: "HTTPS_REQUIRED", + message: "HTTPS is required in public access mode.", + }, + }); + }; +} + +export function createCsrfProtectionMiddleware( + config: AuthConfig +): RequestHandler { + return (request, response, next) => { + if ( + !config.policy.csrfProtectionRequired || + SAFE_METHODS.has(request.method) || + CSRF_EXEMPT_PATHS.has(request.path) + ) { + next(); + return; + } + const csrfToken = request.header("x-csrf-token"); + if ( + request.auth && + csrfToken && + verifySessionCsrfToken(request.auth, csrfToken, config) + ) { + next(); + return; + } + response.status(403).json({ + error: { + code: "CSRF_TOKEN_INVALID", + message: "A valid CSRF token is required.", + }, + }); + }; +} + +type RateLimitEntry = { + count: number; + resetAt: number; +}; + +export function createLoginRateLimitMiddleware( + config: AuthConfig, + options: { maxAttempts?: number; windowMs?: number } = {} +): RequestHandler { + const entries = new Map(); + const maxAttempts = options.maxAttempts ?? 5; + const windowMs = options.windowMs ?? 15 * 60 * 1000; + + return (request, response, next) => { + if (!config.policy.loginRateLimitRequired) { + next(); + return; + } + const now = Date.now(); + const key = request.ip ?? request.socket.remoteAddress ?? "unknown"; + const current = entries.get(key); + const entry = + !current || current.resetAt <= now + ? { count: 0, resetAt: now + windowMs } + : current; + entry.count += 1; + entries.set(key, entry); + + if (entries.size > 10_000) { + for (const [entryKey, value] of entries) { + if (value.resetAt <= now) entries.delete(entryKey); + } + } + if (entry.count <= maxAttempts) { + next(); + return; + } + response.setHeader( + "Retry-After", + String(Math.max(1, Math.ceil((entry.resetAt - now) / 1000))) + ); + response.status(429).json({ + error: { + code: "AUTH_RATE_LIMITED", + message: "Too many authentication attempts. Try again later.", + }, + }); + }; +} diff --git a/server/src/core/auth/trusted-owner-middleware.ts b/server/src/core/auth/trusted-owner-middleware.ts new file mode 100644 index 00000000..d0fc304d --- /dev/null +++ b/server/src/core/auth/trusted-owner-middleware.ts @@ -0,0 +1,120 @@ + + +import { getChatById } from "../../services/chat-core/chats-repository"; +import { getEntityProfileById } from "../../services/chat-core/entity-profiles-repository"; +import { getGenerationById } from "../../services/chat-core/generations-repository"; +import { getInstructionById } from "../../services/chat-core/instructions-repository"; +import { getUserPersonById } from "../../services/chat-core/user-persons-repository"; +import { getEntryById } from "../../services/chat-entry-parts/entries-repository"; +import { getPartWithVariantContextById } from "../../services/chat-entry-parts/parts-repository"; +import { getWorldInfoBookById } from "../../services/world-info/world-info-repositories"; +import { asyncHandler } from "../middleware/async-handler"; +import { runWithOwnerScope } from "../request-context/owner-scope-storage"; + +import type { Request, RequestHandler } from "express"; + +function injectTrustedOwner(request: Request, ownerId: string): void { + if ( + request.body && + typeof request.body === "object" && + !Array.isArray(request.body) + ) { + (request.body as Record).ownerId = ownerId; + } + if (request.query && typeof request.query === "object") { + (request.query as Record).ownerId = ownerId; + } +} + +async function resolveResourceOwner( + path: string, + authenticatedOwnerId: string +): Promise { + const segments = path.split("/").filter(Boolean); + const [resource, id, nested] = segments; + if (!id) return undefined; + + if (resource === "chats") return (await getChatById(id))?.ownerId ?? null; + if (resource === "entity-profiles") { + if (id === "import") return undefined; + return (await getEntityProfileById(id))?.ownerId ?? null; + } + if (resource === "entries") { + if (id === "soft-delete-bulk") return undefined; + return (await getEntryById({ entryId: id })) ? authenticatedOwnerId : null; + } + if (resource === "parts") { + return (await getPartWithVariantContextById({ partId: id }))?.ownerId ?? null; + } + if (resource === "instructions") { + if (id === "default-st-preset" || id === "prerender") return undefined; + return (await getInstructionById(id))?.ownerId ?? null; + } + if (resource === "user-persons") { + return (await getUserPersonById(id))?.ownerId ?? null; + } + if (resource === "generations") { + return (await getGenerationById(id)) ? authenticatedOwnerId : null; + } + if (resource === "world-info" && id === "books" && nested) { + if (nested === "import") return undefined; + return (await getWorldInfoBookById(nested))?.ownerId ?? null; + } + return undefined; +} + +export const trustedOwnerMiddleware: RequestHandler = asyncHandler( + async (request, response, next) => { + const userId = request.auth?.user.id; + if (!userId) { + next(); + return; + } + await runWithOwnerScope(userId, async () => { + injectTrustedOwner(request, userId); + const resourceOwner = await resolveResourceOwner(request.path, userId); + if (resourceOwner === undefined || resourceOwner === userId) { + next(); + return; + } + response.status(404).json({ + error: { + code: "NOT_FOUND", + message: "Resource not found.", + }, + }); + }); + } +); + +export const mediaOwnerMiddleware: RequestHandler = ( + request, + response, + next +) => { + const userId = request.auth?.user.id; + if (!userId) { + response.status(401).end(); + return; + } + const segments = request.path.split("/").filter(Boolean); + if (userId === "global") { + const isNamespacedUpload = + segments[0] === "images" && + segments.length >= 4 && + ["app-backgrounds", "user-persons", "entity-profiles", "agent-cards"].includes( + segments[1] ?? "" + ); + if (!isNamespacedUpload || segments[2] === "global") { + next(); + return; + } + response.status(404).end(); + return; + } + if (segments.includes(userId)) { + next(); + return; + } + response.status(404).end(); +}; diff --git a/server/src/core/request-context/owner-scope-storage.ts b/server/src/core/request-context/owner-scope-storage.ts new file mode 100644 index 00000000..bf0e2559 --- /dev/null +++ b/server/src/core/request-context/owner-scope-storage.ts @@ -0,0 +1,17 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { GLOBAL_OWNER_ID } from "./request-context"; + +const ownerStorage = new AsyncLocalStorage(); + +export function runWithOwnerScope(ownerId: string, work: () => T): T { + return ownerStorage.run(ownerId, work); +} + +export function getActiveOwnerId(): string | null { + return ownerStorage.getStore() ?? null; +} + +export function resolveTrustedOwnerId(requestedOwnerId?: string | null): string { + return getActiveOwnerId() ?? requestedOwnerId ?? GLOBAL_OWNER_ID; +} diff --git a/server/src/core/request-context/request-context.test.ts b/server/src/core/request-context/request-context.test.ts index f648880a..980c4e31 100644 --- a/server/src/core/request-context/request-context.test.ts +++ b/server/src/core/request-context/request-context.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { getRequestOwnerId } from "./request-context"; +import { + getRequestContext, + getRequestOwnerId, + setAuthenticatedUserContext, +} from "./request-context"; import type { Request } from "express"; @@ -17,4 +21,26 @@ describe("request owner context", () => { expect(getRequestOwnerId(request, "attacker-owner")).toBe("trusted-owner"); }); + + test("sets the authenticated user as the trusted owner", () => { + const request = {} as Request; + + setAuthenticatedUserContext(request, { + userId: "user-1", + role: "admin", + }); + + expect(getRequestContext(request)).toMatchObject({ + ownerScope: { + ownerId: "user-1", + source: "authenticated-user", + }, + actor: { + type: "user", + id: "user-1", + role: "admin", + }, + }); + expect(getRequestOwnerId(request, "attacker-owner")).toBe("user-1"); + }); }); diff --git a/server/src/core/request-context/request-context.ts b/server/src/core/request-context/request-context.ts index cbb46324..80f43831 100644 --- a/server/src/core/request-context/request-context.ts +++ b/server/src/core/request-context/request-context.ts @@ -4,16 +4,26 @@ import type { Request, RequestHandler } from "express"; export const GLOBAL_OWNER_ID = "global"; +export type UserRole = "admin" | "user"; + +export type RequestActor = + | { + type: "system"; + id: null; + } + | { + type: "user"; + id: string; + role: UserRole; + }; + export type RequestContext = { requestId: string; ownerScope: { ownerId: string; - source: "context-default" | "explicit"; - }; - actor: { - type: "system"; - id: null; + source: "context-default" | "explicit" | "authenticated-user"; }; + actor: RequestActor; tenant: { id: null; }; @@ -72,3 +82,24 @@ export function getRequestOwnerId(req: Request, requestedOwnerId?: string | null void requestedOwnerId; return getRequestContext(req).ownerScope.ownerId; } + +export function setAuthenticatedUserContext( + req: Request, + user: { userId: string; role: UserRole } +): RequestContext { + const context = getRequestContext(req); + const authenticatedContext: RequestContext = { + ...context, + ownerScope: { + ownerId: user.userId, + source: "authenticated-user", + }, + actor: { + type: "user", + id: user.userId, + role: user.role, + }, + }; + req.context = authenticatedContext; + return authenticatedContext; +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 06e4836f..2a5eaae2 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -11,4 +11,5 @@ export * from "./schema/ui-theme"; export * from "./schema/user-persons"; export * from "./schema/world-info"; export * from "./schema/chat-knowledge"; +export * from "./schema/auth"; diff --git a/server/src/db/schema/app-backgrounds.ts b/server/src/db/schema/app-backgrounds.ts index b60b1b46..ffb6d612 100644 --- a/server/src/db/schema/app-backgrounds.ts +++ b/server/src/db/schema/app-backgrounds.ts @@ -1,9 +1,16 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -export const uiAppBackgrounds = sqliteTable("ui_app_backgrounds", { - id: text("id").primaryKey(), - name: text("name").notNull(), - fileName: text("file_name").notNull(), - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), -}); +export const uiAppBackgrounds = sqliteTable( + "ui_app_backgrounds", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + name: text("name").notNull(), + fileName: text("file_name").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => ({ + ownerIdIndex: index("ui_app_backgrounds_owner_id_idx").on(table.ownerId), + }) +); diff --git a/server/src/db/schema/auth.ts b/server/src/db/schema/auth.ts new file mode 100644 index 00000000..4cdccdf1 --- /dev/null +++ b/server/src/db/schema/auth.ts @@ -0,0 +1,63 @@ +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable( + "users", + { + id: text("id").primaryKey(), + username: text("username").notNull(), + normalizedUsername: text("normalized_username").notNull(), + displayName: text("display_name").notNull(), + passwordHash: text("password_hash"), + role: text("role", { enum: ["admin", "user"] }).notNull().default("user"), + status: text("status", { enum: ["active", "disabled"] }) + .notNull() + .default("active"), + credentialVersion: integer("credential_version").notNull().default(0), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + lastLoginAt: integer("last_login_at", { mode: "timestamp_ms" }), + }, + (table) => ({ + normalizedUsernameUnique: uniqueIndex("users_normalized_username_uq").on( + table.normalizedUsername + ), + statusUpdatedAtIndex: index("users_status_updated_at_idx").on( + table.status, + table.updatedAt + ), + }) +); + +export const authSessions = sqliteTable( + "auth_sessions", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + csrfTokenHash: text("csrf_token_hash").notNull(), + authMethod: text("auth_method", { enum: ["local", "password"] }).notNull(), + credentialVersion: integer("credential_version").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }).notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + revokedAt: integer("revoked_at", { mode: "timestamp_ms" }), + }, + (table) => ({ + tokenHashUnique: uniqueIndex("auth_sessions_token_hash_uq").on( + table.tokenHash + ), + userExpiresAtIndex: index("auth_sessions_user_expires_at_idx").on( + table.userId, + table.expiresAt + ), + expiresAtIndex: index("auth_sessions_expires_at_idx").on(table.expiresAt), + }) +); diff --git a/server/src/db/schema/llm.ts b/server/src/db/schema/llm.ts index f8f3fcc9..afea37a5 100644 --- a/server/src/db/schema/llm.ts +++ b/server/src/db/schema/llm.ts @@ -62,29 +62,48 @@ export const llmProviders = sqliteTable("llm_providers", { updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }); -export const llmProviderConfigs = sqliteTable("llm_provider_configs", { - id: text("id").primaryKey(), - providerId: text("provider_id") - .notNull() - .references(() => llmProviders.id, { onDelete: "cascade" }), - // Provider-specific fields are stored in JSON to avoid schema churn. - configJson: text("config_json").notNull(), - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), -}); +export const llmProviderConfigs = sqliteTable( + "llm_provider_configs", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + providerId: text("provider_id") + .notNull() + .references(() => llmProviders.id, { onDelete: "cascade" }), + configJson: text("config_json").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => ({ + ownerProviderIndex: uniqueIndex("llm_provider_configs_owner_provider_uq").on( + table.ownerId, + table.providerId + ), + }) +); -export const llmTokens = sqliteTable("llm_tokens", { - id: text("id").primaryKey(), - providerId: text("provider_id") - .notNull() - .references(() => llmProviders.id, { onDelete: "cascade" }), - name: text("name").notNull(), - ciphertext: text("ciphertext").notNull(), - tokenHint: text("token_hint").notNull(), - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), - lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }), -}); +export const llmTokens = sqliteTable( + "llm_tokens", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + providerId: text("provider_id") + .notNull() + .references(() => llmProviders.id, { onDelete: "cascade" }), + name: text("name").notNull(), + ciphertext: text("ciphertext").notNull(), + tokenHint: text("token_hint").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }), + }, + (table) => ({ + ownerProviderIndex: index("llm_tokens_owner_provider_idx").on( + table.ownerId, + table.providerId + ), + }) +); export const llmRuntimeSettings = sqliteTable( "llm_runtime_settings", diff --git a/server/src/services/app-backgrounds/app-backgrounds-repository.ts b/server/src/services/app-backgrounds/app-backgrounds-repository.ts index fec2db52..8005cb62 100644 --- a/server/src/services/app-backgrounds/app-backgrounds-repository.ts +++ b/server/src/services/app-backgrounds/app-backgrounds-repository.ts @@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { resolveSafePath } from "@core/files/safe-path"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { uiAppBackgrounds, uiAppSettings } from "../../db/schema"; @@ -20,7 +21,6 @@ import type { AppBackgroundCatalog, } from "@shared/types/app-background"; -const SETTINGS_ROW_ID = "global"; const APP_BACKGROUNDS_FOLDER = createDataPath("media", "images", "app-backgrounds"); type AppBackgroundRow = typeof uiAppBackgrounds.$inferSelect; @@ -46,7 +46,10 @@ async function ensureSettingsRow(): Promise { async function listUploadedBackgrounds(): Promise { const db = await initDb(); - const rows = await db.select().from(uiAppBackgrounds); + const rows = await db + .select() + .from(uiAppBackgrounds) + .where(eq(uiAppBackgrounds.ownerId, resolveTrustedOwnerId())); return rows.map(rowToAsset); } @@ -56,7 +59,7 @@ async function readStoredActiveBackgroundId(): Promise { const rows = await db .select({ activeAppBackgroundId: uiAppSettings.activeAppBackgroundId }) .from(uiAppSettings) - .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)) + .where(eq(uiAppSettings.id, resolveTrustedOwnerId())) .limit(1); return rows[0]?.activeAppBackgroundId ?? null; } @@ -70,7 +73,7 @@ async function persistActiveBackgroundId(activeBackgroundId: string | null): Pro activeAppBackgroundId: activeBackgroundId, updatedAt: new Date(), }) - .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)); + .where(eq(uiAppSettings.id, resolveTrustedOwnerId())); } export function mergeAppBackgroundAssets( @@ -140,10 +143,12 @@ export async function importAppBackground(params: { fileBuffer: Buffer; originalName: string; }): Promise { - await fs.mkdir(APP_BACKGROUNDS_FOLDER, { recursive: true }); + const ownerId = resolveTrustedOwnerId(); + const ownerFolder = resolveSafePath(APP_BACKGROUNDS_FOLDER, ownerId); + await fs.mkdir(ownerFolder, { recursive: true }); const extension = path.extname(params.originalName).toLowerCase(); - const filename = `${randomUUID()}${extension}`; + const filename = `${ownerId}/${randomUUID()}${extension}`; const filePath = resolveSafePath(APP_BACKGROUNDS_FOLDER, filename); await fs.writeFile(filePath, params.fileBuffer); @@ -152,6 +157,7 @@ export async function importAppBackground(params: { const db = await initDb(); await db.insert(uiAppBackgrounds).values({ id, + ownerId, name: resolveUploadedBackgroundName(params.originalName), fileName: filename, createdAt: now, @@ -160,6 +166,7 @@ export async function importAppBackground(params: { return rowToAsset({ id, + ownerId, name: resolveUploadedBackgroundName(params.originalName), fileName: filename, createdAt: now, @@ -178,14 +185,26 @@ export async function deleteAppBackground(params: { const rows = await db .select() .from(uiAppBackgrounds) - .where(eq(uiAppBackgrounds.id, params.id)) + .where( + and( + eq(uiAppBackgrounds.id, params.id), + eq(uiAppBackgrounds.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const row = rows[0]; if (!row) { throw new HttpError(404, "App background not found", "NOT_FOUND"); } - await db.delete(uiAppBackgrounds).where(eq(uiAppBackgrounds.id, params.id)); + await db + .delete(uiAppBackgrounds) + .where( + and( + eq(uiAppBackgrounds.id, params.id), + eq(uiAppBackgrounds.ownerId, resolveTrustedOwnerId()) + ) + ); await fs.rm(resolveSafePath(APP_BACKGROUNDS_FOLDER, row.fileName), { force: true, }); diff --git a/server/src/services/app-settings/app-settings-repository.ts b/server/src/services/app-settings/app-settings-repository.ts index 08276c81..90e7f182 100644 --- a/server/src/services/app-settings/app-settings-repository.ts +++ b/server/src/services/app-settings/app-settings-repository.ts @@ -3,12 +3,11 @@ import fs from "node:fs/promises"; import { type AppSettings } from "@shared/types/app-settings"; import { eq } from "drizzle-orm"; - +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { uiAppSettings } from "../../db/schema"; import { createDataPath } from "../../utils"; -const SETTINGS_ROW_ID = "global"; const MAX_LEGACY_DATA_DEPTH = 32; const DEFAULT_APP_SETTINGS: AppSettings = { @@ -109,7 +108,7 @@ async function insertInitialSettings(settings: AppSettings): Promise { await db .insert(uiAppSettings) .values({ - id: SETTINGS_ROW_ID, + id: resolveTrustedOwnerId(), language: settings.language, openLastChat: settings.openLastChat, autoSelectCurrentPersona: settings.autoSelectCurrentPersona, @@ -136,13 +135,13 @@ export async function getAppSettings(): Promise { const rows = await db .select() .from(uiAppSettings) - .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)) + .where(eq(uiAppSettings.id, resolveTrustedOwnerId())) .limit(1); const existing = rows[0]; if (existing) return rowToDto(existing); - const legacy = await tryReadLegacyFile(); + const legacy = resolveTrustedOwnerId() === "global" ? await tryReadLegacyFile() : null; const normalized = normalizeLegacyAppSettings(legacy); await insertInitialSettings(normalized); return normalized; @@ -159,7 +158,7 @@ export async function updateAppSettings( await db .insert(uiAppSettings) .values({ - id: SETTINGS_ROW_ID, + id: resolveTrustedOwnerId(), language: next.language, openLastChat: next.openLastChat, autoSelectCurrentPersona: next.autoSelectCurrentPersona, diff --git a/server/src/services/auth/auth-schema.integration.test.ts b/server/src/services/auth/auth-schema.integration.test.ts new file mode 100644 index 00000000..f4cb37f0 --- /dev/null +++ b/server/src/services/auth/auth-schema.integration.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { authSessions, users } from "../../db/schema"; + +describe("auth schema", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-auth-schema-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("stores users and hashed session tokens", async () => { + const db = await initDb(); + const now = new Date(); + + await db.insert(users).values({ + id: "user-1", + username: "Alice", + normalizedUsername: "alice", + displayName: "Alice", + passwordHash: null, + role: "admin", + status: "active", + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }); + await db.insert(authSessions).values({ + id: "session-1", + userId: "user-1", + tokenHash: "hashed-token", + csrfTokenHash: "hashed-csrf-token", + authMethod: "local", + credentialVersion: 0, + createdAt: now, + lastSeenAt: now, + expiresAt: new Date(now.getTime() + 60_000), + }); + + await expect(db.select().from(users)).resolves.toHaveLength(1); + await expect(db.select().from(authSessions)).resolves.toMatchObject([ + { + userId: "user-1", + tokenHash: "hashed-token", + authMethod: "local", + }, + ]); + }); + + test("enforces normalized username and token hash uniqueness", async () => { + const db = await initDb(); + const now = new Date(); + const userValues = { + username: "Alice", + normalizedUsername: "alice", + displayName: "Alice", + role: "user" as const, + status: "active" as const, + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }; + + await db.insert(users).values({ id: "user-1", ...userValues }); + await expect( + db.insert(users).values({ + id: "user-2", + ...userValues, + username: "ALICE", + }) + ).rejects.toThrow(); + + await db.insert(authSessions).values({ + id: "session-1", + userId: "user-1", + tokenHash: "same-hash", + csrfTokenHash: "csrf-1", + authMethod: "local", + credentialVersion: 0, + createdAt: now, + lastSeenAt: now, + expiresAt: new Date(now.getTime() + 60_000), + }); + await expect( + db.insert(authSessions).values({ + id: "session-2", + userId: "user-1", + tokenHash: "same-hash", + csrfTokenHash: "csrf-2", + authMethod: "local", + credentialVersion: 0, + createdAt: now, + lastSeenAt: now, + expiresAt: new Date(now.getTime() + 60_000), + }) + ).rejects.toThrow(); + }); +}); diff --git a/server/src/services/auth/auth-service.ts b/server/src/services/auth/auth-service.ts new file mode 100644 index 00000000..faed5d29 --- /dev/null +++ b/server/src/services/auth/auth-service.ts @@ -0,0 +1,149 @@ + +import { createPasswordHash, verifyPassword } from "./password-service"; +import { + createAuthSession, + type AuthMethod, + type CreatedAuthSession, +} from "./session-service"; +import { + createInitialUser, + createUser, + getUserById, + getUserCredentialsById, + getUserCredentialsByUsername, + recordUserLogin, + type UserDto, + type UserRole, +} from "./users-repository"; + +import type { AuthConfig } from "../../core/auth/auth-config"; + +export type AuthResult = { + user: UserDto; + session: CreatedAuthSession; +}; + +export class AuthServiceError extends Error { + constructor( + public readonly code: + | "SETUP_COMPLETED" + | "INVALID_CREDENTIALS" + | "ACCOUNT_DISABLED" + | "PASSWORD_REQUIRED", + message: string + ) { + super(message); + } +} + +async function issueSession(params: { + userId: string; + authMethod: AuthMethod; + config: AuthConfig; +}): Promise { + const user = await getUserCredentialsById(params.userId); + if (!user) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Invalid username or password." + ); + } + const session = await createAuthSession({ + user, + authMethod: params.authMethod, + config: params.config, + }); + await recordUserLogin(user.id); + const safeUser = await getUserById(user.id); + if (!safeUser) { + throw new Error("Authenticated user could not be reloaded."); + } + return { user: safeUser, session }; +} + +export async function setupInitialUser(params: { + username: string; + displayName?: string; + password: string; + config: AuthConfig; +}): Promise { + const passwordHash = await createPasswordHash( + params.password, + params.config.policy + ); + const created = await createInitialUser({ + username: params.username, + displayName: params.displayName ?? params.username, + passwordHash, + }); + if (!created) { + throw new AuthServiceError( + "SETUP_COMPLETED", + "Initial setup has already been completed." + ); + } + return issueSession({ + userId: created.id, + authMethod: passwordHash ? "password" : "local", + config: params.config, + }); +} + +export async function loginUser(params: { + username?: string; + userId?: string; + password: string; + config: AuthConfig; +}): Promise { + const user = params.userId + ? await getUserCredentialsById(params.userId) + : await getUserCredentialsByUsername(params.username ?? ""); + if (!user) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Invalid username or password." + ); + } + if (user.status !== "active") { + throw new AuthServiceError("ACCOUNT_DISABLED", "Account is disabled."); + } + if ( + !user.passwordHash && + !params.config.policy.passwordlessLoginAllowed + ) { + throw new AuthServiceError( + "PASSWORD_REQUIRED", + "Passwordless login is disabled." + ); + } + if (!(await verifyPassword(user.passwordHash, params.password))) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Invalid username or password." + ); + } + return issueSession({ + userId: user.id, + authMethod: user.passwordHash ? "password" : "local", + config: params.config, + }); +} + +export async function createAdditionalUser(params: { + username: string; + displayName?: string; + password: string; + role?: UserRole; + config: AuthConfig; +}): Promise { + const passwordHash = await createPasswordHash( + params.password, + params.config.policy + ); + return createUser({ + username: params.username, + displayName: params.displayName ?? params.username, + passwordHash, + role: params.role ?? "user", + }); +} diff --git a/server/src/services/auth/password-service.test.ts b/server/src/services/auth/password-service.test.ts new file mode 100644 index 00000000..07d1cdbf --- /dev/null +++ b/server/src/services/auth/password-service.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import { resolveAccessPolicy } from "../../core/auth/access-policy"; + +import { + createPasswordHash, + verifyPassword, +} from "./password-service"; + +describe("password service", () => { + test("allows an empty password only in local mode", async () => { + await expect( + createPasswordHash("", resolveAccessPolicy({})) + ).resolves.toBeNull(); + + await expect( + createPasswordHash( + "", + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: "public" }) + ) + ).rejects.toThrow(/required/i); + }); + + test("hashes non-empty passwords with Argon2id", async () => { + const hash = await createPasswordHash( + "correct horse battery staple", + resolveAccessPolicy({}) + ); + + expect(hash).toMatch(/^\$argon2id\$/); + await expect( + verifyPassword(hash, "correct horse battery staple") + ).resolves.toBe(true); + await expect(verifyPassword(hash, "wrong password")).resolves.toBe(false); + }); + + test("requires a stronger password in public mode", async () => { + const policy = resolveAccessPolicy({ + TALESPINNER_ACCESS_MODE: "public", + }); + + await expect(createPasswordHash("short", policy)).rejects.toThrow( + /at least 10/ + ); + }); +}); diff --git a/server/src/services/auth/password-service.ts b/server/src/services/auth/password-service.ts new file mode 100644 index 00000000..76b9d94d --- /dev/null +++ b/server/src/services/auth/password-service.ts @@ -0,0 +1,50 @@ +import argon2 from "argon2"; + +import type { AccessPolicy } from "../../core/auth/access-policy"; + +const PUBLIC_PASSWORD_MIN_LENGTH = 10; +const PASSWORD_MAX_LENGTH = 1024; + +function validatePassword(password: string, policy: AccessPolicy): void { + if (password.length > PASSWORD_MAX_LENGTH) { + throw new Error(`Password must not exceed ${PASSWORD_MAX_LENGTH} characters.`); + } + if (policy.passwordRequired && password.length === 0) { + throw new Error("Password is required in public access mode."); + } + if ( + policy.passwordRequired && + password.length < PUBLIC_PASSWORD_MIN_LENGTH + ) { + throw new Error( + `Password must contain at least ${PUBLIC_PASSWORD_MIN_LENGTH} characters.` + ); + } +} + +export async function createPasswordHash( + password: string, + policy: AccessPolicy +): Promise { + validatePassword(password, policy); + if (password.length === 0) return null; + + return argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 19_456, + timeCost: 2, + parallelism: 1, + }); +} + +export async function verifyPassword( + passwordHash: string | null, + password: string +): Promise { + if (!passwordHash) return password.length === 0; + try { + return await argon2.verify(passwordHash, password); + } catch { + return false; + } +} diff --git a/server/src/services/auth/session-service.integration.test.ts b/server/src/services/auth/session-service.integration.test.ts new file mode 100644 index 00000000..328786cd --- /dev/null +++ b/server/src/services/auth/session-service.integration.test.ts @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { resolveAuthConfig } from "../../core/auth/auth-config"; +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { authSessions } from "../../db/schema"; + +import { + createAuthSession, + resolveAuthSession, + revokeAuthSession, + verifySessionCsrfToken, +} from "./session-service"; +import { + createUser, + getUserCredentialsById, +} from "./users-repository"; + +describe("session service", () => { + let tempDir = ""; + const config = resolveAuthConfig({}); + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-session-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function createFixture() { + await createUser({ + id: "user-1", + username: "alice", + displayName: "Alice", + passwordHash: null, + role: "admin", + }); + const user = await getUserCredentialsById("user-1"); + if (!user) throw new Error("Missing fixture user"); + return createAuthSession({ user, authMethod: "local", config }); + } + + test("stores only token hashes and resolves an active principal", async () => { + const created = await createFixture(); + const db = await initDb(); + const stored = await db + .select() + .from(authSessions) + .where(eq(authSessions.id, created.sessionId)); + + expect(stored[0]?.tokenHash).not.toBe(created.token); + expect(stored[0]?.csrfTokenHash).not.toBe(created.csrfToken); + await expect( + resolveAuthSession({ token: created.token, config }) + ).resolves.toMatchObject({ + sessionId: created.sessionId, + user: { id: "user-1", role: "admin" }, + }); + }); + + test("verifies CSRF tokens without storing their plaintext", async () => { + const created = await createFixture(); + const principal = await resolveAuthSession({ + token: created.token, + config, + }); + if (!principal) throw new Error("Missing session principal"); + + expect( + verifySessionCsrfToken(principal, created.csrfToken, config) + ).toBe(true); + expect(verifySessionCsrfToken(principal, "wrong-token", config)).toBe( + false + ); + }); + + test("does not resolve revoked sessions", async () => { + const created = await createFixture(); + await revokeAuthSession(created.sessionId); + + await expect( + resolveAuthSession({ token: created.token, config }) + ).resolves.toBeNull(); + }); +}); diff --git a/server/src/services/auth/session-service.ts b/server/src/services/auth/session-service.ts new file mode 100644 index 00000000..dc17441e --- /dev/null +++ b/server/src/services/auth/session-service.ts @@ -0,0 +1,168 @@ +import { + createHash, + createHmac, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; + +import { and, eq, isNull } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { authSessions } from "../../db/schema"; + +import { + getUserCredentialsById, + type UserCredentials, + type UserDto, +} from "./users-repository"; + +import type { AuthConfig } from "../../core/auth/auth-config"; + +export type AuthMethod = "local" | "password"; + +export type SessionPrincipal = { + sessionId: string; + user: UserDto; + csrfTokenHash: string; + expiresAt: Date; +}; + +export type CreatedAuthSession = { + sessionId: string; + token: string; + csrfToken: string; + expiresAt: Date; +}; + +function generateToken(): string { + return randomBytes(32).toString("base64url"); +} + +function hashToken(token: string, secret: string | null): string { + return secret + ? createHmac("sha256", secret).update(token).digest("hex") + : createHash("sha256").update(token).digest("hex"); +} + +function safeUser(credentials: UserCredentials): UserDto { + const { + normalizedUsername: _normalizedUsername, + passwordHash: _passwordHash, + credentialVersion: _credentialVersion, + ...user + } = credentials; + return user; +} + +export async function createAuthSession(params: { + user: UserCredentials; + authMethod: AuthMethod; + config: AuthConfig; +}): Promise { + const db = await initDb(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + params.config.sessionTtlMs); + const token = generateToken(); + const csrfToken = generateToken(); + const sessionId = randomUUID(); + + await db.insert(authSessions).values({ + id: sessionId, + userId: params.user.id, + tokenHash: hashToken(token, params.config.sessionSecret), + csrfTokenHash: hashToken(csrfToken, params.config.sessionSecret), + authMethod: params.authMethod, + credentialVersion: params.user.credentialVersion, + createdAt: now, + lastSeenAt: now, + expiresAt, + }); + + return { sessionId, token, csrfToken, expiresAt }; +} + +export async function resolveAuthSession(params: { + token: string; + config: AuthConfig; +}): Promise { + const db = await initDb(); + const tokenHash = hashToken(params.token, params.config.sessionSecret); + const rows = await db + .select() + .from(authSessions) + .where( + and( + eq(authSessions.tokenHash, tokenHash), + isNull(authSessions.revokedAt) + ) + ) + .limit(1); + const session = rows[0]; + if (!session || session.expiresAt.getTime() <= Date.now()) return null; + + const user = await getUserCredentialsById(session.userId); + if ( + !user || + user.status !== "active" || + user.credentialVersion !== session.credentialVersion + ) { + return null; + } + + return { + sessionId: session.id, + user: safeUser(user), + csrfTokenHash: session.csrfTokenHash, + expiresAt: session.expiresAt, + }; +} + +export function verifySessionCsrfToken( + principal: SessionPrincipal, + csrfToken: string, + config: AuthConfig +): boolean { + const actual = Buffer.from( + hashToken(csrfToken, config.sessionSecret), + "hex" + ); + const expected = Buffer.from(principal.csrfTokenHash, "hex"); + return ( + actual.length === expected.length && timingSafeEqual(actual, expected) + ); +} + +export async function revokeAuthSession(sessionId: string): Promise { + const db = await initDb(); + await db + .update(authSessions) + .set({ revokedAt: new Date() }) + .where(eq(authSessions.id, sessionId)); +} + +export async function revokeUserSessions(userId: string): Promise { + const db = await initDb(); + await db + .update(authSessions) + .set({ revokedAt: new Date() }) + .where( + and(eq(authSessions.userId, userId), isNull(authSessions.revokedAt)) + ); +} + +export async function rotateSessionCsrfToken( + sessionId: string, + config: AuthConfig +): Promise { + const db = await initDb(); + const csrfToken = generateToken(); + await db + .update(authSessions) + .set({ + csrfTokenHash: hashToken(csrfToken, config.sessionSecret), + lastSeenAt: new Date(), + }) + .where(eq(authSessions.id, sessionId)); + return csrfToken; +} diff --git a/server/src/services/auth/users-repository.integration.test.ts b/server/src/services/auth/users-repository.integration.test.ts new file mode 100644 index 00000000..c5b5c410 --- /dev/null +++ b/server/src/services/auth/users-repository.integration.test.ts @@ -0,0 +1,102 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; + +import { + countUsers, + createUser, + getUserCredentialsByUsername, + listActiveUsers, + normalizeUsername, +} from "./users-repository"; + +describe("users repository", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-users-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("creates safe user DTOs and keeps credentials internal", async () => { + const user = await createUser({ + id: "global", + username: " Alice ", + displayName: "Alice", + passwordHash: "secret-hash", + role: "admin", + }); + + expect(user).toMatchObject({ + id: "global", + username: "Alice", + displayName: "Alice", + role: "admin", + status: "active", + hasPassword: true, + }); + expect(user).not.toHaveProperty("passwordHash"); + await expect(countUsers()).resolves.toBe(1); + + await expect( + getUserCredentialsByUsername("ALICE") + ).resolves.toMatchObject({ + id: "global", + normalizedUsername: "alice", + passwordHash: "secret-hash", + }); + }); + + test("normalizes Unicode usernames and enforces uniqueness", async () => { + expect(normalizeUsername(" Alice ")).toBe("alice"); + + await createUser({ + username: "Alice", + displayName: "Alice", + passwordHash: null, + role: "user", + }); + await expect( + createUser({ + username: "ALICE", + displayName: "Other Alice", + passwordHash: null, + role: "user", + }) + ).rejects.toThrow(); + }); + + test("lists only active users for local account selection", async () => { + await createUser({ + id: "active-user", + username: "active", + displayName: "Active", + passwordHash: null, + role: "user", + }); + await createUser({ + id: "disabled-user", + username: "disabled", + displayName: "Disabled", + passwordHash: null, + role: "user", + status: "disabled", + }); + + await expect(listActiveUsers()).resolves.toMatchObject([ + { id: "active-user" }, + ]); + }); +}); diff --git a/server/src/services/auth/users-repository.ts b/server/src/services/auth/users-repository.ts new file mode 100644 index 00000000..eb1a79b6 --- /dev/null +++ b/server/src/services/auth/users-repository.ts @@ -0,0 +1,203 @@ +import { randomUUID } from "node:crypto"; + +import { asc, eq } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { users } from "../../db/schema"; + +export type UserRole = "admin" | "user"; +export type UserStatus = "active" | "disabled"; + +export type UserDto = { + id: string; + username: string; + displayName: string; + role: UserRole; + status: UserStatus; + hasPassword: boolean; + createdAt: Date; + updatedAt: Date; + lastLoginAt: Date | null; +}; + +export type UserCredentials = UserDto & { + normalizedUsername: string; + passwordHash: string | null; + credentialVersion: number; +}; + +function normalizeIdentity(value: string): string { + return value.normalize("NFKC").trim(); +} + +export function normalizeUsername(value: string): string { + return normalizeIdentity(value).toLocaleLowerCase("en-US"); +} + +function validateIdentity(username: string, displayName: string): void { + if (username.length < 1 || username.length > 64) { + throw new Error("Username must contain between 1 and 64 characters."); + } + if (displayName.length < 1 || displayName.length > 128) { + throw new Error("Display name must contain between 1 and 128 characters."); + } +} + +function rowToDto(row: typeof users.$inferSelect): UserDto { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + role: row.role, + status: row.status, + hasPassword: Boolean(row.passwordHash), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + lastLoginAt: row.lastLoginAt, + }; +} + +function rowToCredentials( + row: typeof users.$inferSelect +): UserCredentials { + return { + ...rowToDto(row), + normalizedUsername: row.normalizedUsername, + passwordHash: row.passwordHash, + credentialVersion: row.credentialVersion, + }; +} + +export async function countUsers(): Promise { + const db = await initDb(); + const rows = await db.select({ id: users.id }).from(users); + return rows.length; +} + +export async function createUser(params: { + id?: string; + username: string; + displayName: string; + passwordHash: string | null; + role: UserRole; + status?: UserStatus; +}): Promise { + const db = await initDb(); + const username = normalizeIdentity(params.username); + const displayName = normalizeIdentity(params.displayName); + validateIdentity(username, displayName); + const now = new Date(); + const id = params.id ?? randomUUID(); + + await db.insert(users).values({ + id, + username, + normalizedUsername: normalizeUsername(username), + displayName, + passwordHash: params.passwordHash, + role: params.role, + status: params.status ?? "active", + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }); + + const created = await getUserById(id); + if (!created) throw new Error("Created user could not be loaded."); + return created; +} + +export async function createInitialUser(params: { + username: string; + displayName: string; + passwordHash: string | null; +}): Promise { + const db = await initDb(); + const username = normalizeIdentity(params.username); + const displayName = normalizeIdentity(params.displayName); + validateIdentity(username, displayName); + const now = new Date(); + + return db.transaction((tx) => { + const existing = tx.select({ id: users.id }).from(users).limit(1).all(); + if (existing.length > 0) return null; + + tx.insert(users) + .values({ + id: "global", + username, + normalizedUsername: normalizeUsername(username), + displayName, + passwordHash: params.passwordHash, + role: "admin", + status: "active", + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }) + .run(); + + return { + id: "global", + username, + displayName, + role: "admin", + status: "active", + hasPassword: Boolean(params.passwordHash), + createdAt: now, + updatedAt: now, + lastLoginAt: null, + }; + }); +} + +export async function getUserById(id: string): Promise { + const db = await initDb(); + const rows = await db.select().from(users).where(eq(users.id, id)).limit(1); + return rows[0] ? rowToDto(rows[0]) : null; +} + +export async function getUserCredentialsById( + id: string +): Promise { + const db = await initDb(); + const rows = await db.select().from(users).where(eq(users.id, id)).limit(1); + return rows[0] ? rowToCredentials(rows[0]) : null; +} + +export async function getUserCredentialsByUsername( + username: string +): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(users) + .where(eq(users.normalizedUsername, normalizeUsername(username))) + .limit(1); + return rows[0] ? rowToCredentials(rows[0]) : null; +} + +export async function listActiveUsers(): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(users) + .where(eq(users.status, "active")) + .orderBy(asc(users.username)); + return rows.map(rowToDto); +} + +export async function listUsers(): Promise { + const db = await initDb(); + const rows = await db.select().from(users).orderBy(asc(users.username)); + return rows.map(rowToDto); +} + +export async function recordUserLogin(id: string): Promise { + const db = await initDb(); + const now = new Date(); + await db + .update(users) + .set({ lastLoginAt: now, updatedAt: now }) + .where(eq(users.id, id)); +} diff --git a/server/src/services/chat-core/chats-repository.ts b/server/src/services/chat-core/chats-repository.ts index 5a2b1276..67e44778 100644 --- a/server/src/services/chat-core/chats-repository.ts +++ b/server/src/services/chat-core/chats-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, desc, eq, lt, ne } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { chatBranches, @@ -137,7 +138,7 @@ export async function listChatsByEntityProfile(params: { .from(chats) .where( and( - eq(chats.ownerId, params.ownerId ?? "global"), + eq(chats.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(chats.entityProfileId, params.entityProfileId), // Show non-deleted by default in list ne(chats.status, "deleted") @@ -149,7 +150,12 @@ export async function listChatsByEntityProfile(params: { export async function getChatById(id: string): Promise { const db = await initDb(); - const rows = await db.select().from(chats).where(eq(chats.id, id)); + const rows = await db + .select() + .from(chats) + .where( + and(eq(chats.id, id), eq(chats.ownerId, resolveTrustedOwnerId())) + ); return rows[0] ? chatRowToDto(rows[0]) : null; } @@ -160,7 +166,7 @@ export async function createChat(params: { meta?: unknown; }): Promise<{ chat: ChatDto; mainBranch: ChatBranchDto }> { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const ts = new Date(); const chatId = uuidv4(); const mainBranchId = uuidv4(); @@ -234,7 +240,7 @@ export async function setChatInstruction(params: { instructionId: string | null; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const ts = new Date(); await db .update(chats) @@ -291,7 +297,7 @@ export async function createChatBranch(params: { await db.insert(chatBranches).values({ id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, title: params.title ?? null, createdAt: ts, @@ -438,7 +444,7 @@ export async function createChatMessage(params: { await db.insert(chatMessages).values({ id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, role: params.role, @@ -490,7 +496,7 @@ export async function createAssistantMessageWithVariant(params: { await db.transaction((tx) => { tx.insert(chatMessages).values({ id: assistantMessageId, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, role: "assistant", @@ -504,7 +510,7 @@ export async function createAssistantMessageWithVariant(params: { tx.insert(messageVariants).values({ id: variantId, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), messageId: assistantMessageId, createdAt: ts, kind: "generation", @@ -539,7 +545,7 @@ export async function createImportedAssistantMessage(params: { await db.transaction((tx) => { tx.insert(chatMessages).values({ id: assistantMessageId, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, role: "assistant", @@ -556,7 +562,7 @@ export async function createImportedAssistantMessage(params: { tx.insert(messageVariants).values({ id: variantId, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), messageId: assistantMessageId, createdAt: ts, kind: "import", diff --git a/server/src/services/chat-core/entity-profiles-repository.ts b/server/src/services/chat-core/entity-profiles-repository.ts index 74d74dc7..47addb89 100644 --- a/server/src/services/chat-core/entity-profiles-repository.ts +++ b/server/src/services/chat-core/entity-profiles-repository.ts @@ -1,8 +1,9 @@ import { randomUUID as uuidv4 } from "node:crypto"; -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { entityProfiles } from "../../db/schema"; @@ -41,7 +42,7 @@ export async function listEntityProfiles(params?: { const rows = await db .select() .from(entityProfiles) - .where(eq(entityProfiles.ownerId, params?.ownerId ?? "global")) + .where(eq(entityProfiles.ownerId, resolveTrustedOwnerId(params?.ownerId))) .orderBy(asc(entityProfiles.name)); return rows.map(rowToDto); } @@ -51,7 +52,12 @@ export async function getEntityProfileById(id: string): Promise { const db = await initDb(); - await db.delete(entityProfiles).where(eq(entityProfiles.id, id)); + await db + .delete(entityProfiles) + .where( + and( + eq(entityProfiles.id, id), + eq(entityProfiles.ownerId, resolveTrustedOwnerId()) + ) + ); } diff --git a/server/src/services/chat-core/generations-repository.ts b/server/src/services/chat-core/generations-repository.ts index ff642d78..4a8f88a2 100644 --- a/server/src/services/chat-core/generations-repository.ts +++ b/server/src/services/chat-core/generations-repository.ts @@ -7,6 +7,7 @@ import { safeJsonStringify, safeJsonStringifyForLog, } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; import { llmGenerations } from "../../db/schema"; @@ -33,7 +34,7 @@ export async function createGeneration(params: CreateGenerationParams): Promise< await db.insert(llmGenerations).values({ id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, messageId: params.messageId, @@ -103,7 +104,16 @@ function rowToWithDebugDto( export async function getGenerationById(id: string): Promise { const db = await initDb(); - const rows = await db.select().from(llmGenerations).where(eq(llmGenerations.id, id)).limit(1); + const rows = await db + .select() + .from(llmGenerations) + .where( + and( + eq(llmGenerations.id, id), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()) + ) + ) + .limit(1); return rows[0] ? rowToDto(rows[0]) : null; } @@ -119,6 +129,7 @@ export async function getActiveGenerationForChatBranch(params: { and( eq(llmGenerations.chatId, params.chatId), eq(llmGenerations.branchId, params.branchId), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()), eq(llmGenerations.status, "streaming") ) ) diff --git a/server/src/services/chat-core/instructions-repository.ts b/server/src/services/chat-core/instructions-repository.ts index df13275a..1302e4a5 100644 --- a/server/src/services/chat-core/instructions-repository.ts +++ b/server/src/services/chat-core/instructions-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, desc, eq } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { chats, instructions } from "../../db/schema"; @@ -81,7 +82,7 @@ export async function listInstructions(params: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(instructions) @@ -97,7 +98,12 @@ export async function getInstructionById( const rows = await db .select() .from(instructions) - .where(eq(instructions.id, id)); + .where( + and( + eq(instructions.id, id), + eq(instructions.ownerId, resolveTrustedOwnerId()) + ) + ); return rows[0] ? rowToDto(rows[0]) : null; } @@ -124,7 +130,7 @@ export async function createInstruction(params: { await db.insert(instructions).values({ id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), name: params.name, kind: params.kind, engine: params.engine ?? "liquidjs", @@ -144,7 +150,7 @@ export async function createInstruction(params: { if (params.kind === "basic") { return { id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), name: params.name, kind: "basic", engine: params.engine ?? "liquidjs", @@ -157,7 +163,7 @@ export async function createInstruction(params: { return { id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), name: params.name, kind: "st_base", engine: params.engine ?? "liquidjs", @@ -221,7 +227,7 @@ export async function pickInstructionForChat(params: { chatId: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const chatRows = await db .select({ instructionId: chats.instructionId }) diff --git a/server/src/services/chat-core/prompt-template-context.ts b/server/src/services/chat-core/prompt-template-context.ts index 44026801..6b074170 100644 --- a/server/src/services/chat-core/prompt-template-context.ts +++ b/server/src/services/chat-core/prompt-template-context.ts @@ -1,3 +1,4 @@ +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { listProjectedPromptMessages } from "../chat-entry-parts/prompt-history"; import { resolveWorldInfoRuntimeForChat } from "../world-info/world-info-runtime"; @@ -492,7 +493,7 @@ export async function buildInstructionRenderContext(params: { excludeEntryIds?: string[]; worldInfo?: InstructionWorldInfoInput; }): Promise { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); // Default “empty” context: should never break Liquid rendering. const base: InstructionRenderContext = { diff --git a/server/src/services/chat-core/user-persons-repository.ts b/server/src/services/chat-core/user-persons-repository.ts index 4f8d4eaf..757cb421 100644 --- a/server/src/services/chat-core/user-persons-repository.ts +++ b/server/src/services/chat-core/user-persons-repository.ts @@ -1,8 +1,9 @@ import { randomUUID as uuidv4 } from "node:crypto"; -import { asc, desc, eq } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { userPersons, userPersonsSettings } from "../../db/schema"; @@ -68,19 +69,25 @@ export async function listUserPersons(params?: { ownerId?: string; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(userPersons) - .where(eq(userPersons.ownerId, params?.ownerId ?? "global")) + .where(eq(userPersons.ownerId, ownerId)) .orderBy(asc(userPersons.name), desc(userPersons.updatedAt)); return rows.map(rowToDto); } export async function getUserPersonById( - id: string + id: string, + params?: { ownerId?: string } ): Promise { const db = await initDb(); - const rows = await db.select().from(userPersons).where(eq(userPersons.id, id)); + const ownerId = resolveTrustedOwnerId(params?.ownerId); + const rows = await db + .select() + .from(userPersons) + .where(and(eq(userPersons.id, id), eq(userPersons.ownerId, ownerId))); return rows[0] ? rowToDto(rows[0]) : null; } @@ -97,6 +104,7 @@ export async function createUserPerson(params: { updatedAt?: Date; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const now = new Date(); const id = typeof params.id === "string" && params.id.length > 0 ? params.id : uuidv4(); @@ -105,7 +113,7 @@ export async function createUserPerson(params: { await db.insert(userPersons).values({ id, - ownerId: params.ownerId ?? "global", + ownerId, name: params.name, prefix: typeof params.prefix === "string" ? params.prefix : null, avatarUrl: typeof params.avatarUrl === "string" ? params.avatarUrl : null, @@ -122,11 +130,11 @@ export async function createUserPerson(params: { updatedAt, }); - const created = await getUserPersonById(id); + const created = await getUserPersonById(id, { ownerId }); if (!created) { return { id, - ownerId: params.ownerId ?? "global", + ownerId, name: params.name, prefix: params.prefix, avatarUrl: params.avatarUrl, @@ -142,6 +150,7 @@ export async function createUserPerson(params: { export async function updateUserPerson(params: { id: string; + ownerId?: string; name?: string; prefix?: string; avatarUrl?: string; @@ -151,7 +160,8 @@ export async function updateUserPerson(params: { updatedAt?: Date; }): Promise { const db = await initDb(); - const current = await getUserPersonById(params.id); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const current = await getUserPersonById(params.id, { ownerId }); if (!current) return null; const set: Partial = { @@ -172,20 +182,29 @@ export async function updateUserPerson(params: { if (typeof params.contentTypeExtended !== "undefined") set.contentTypeExtendedJson = safeJsonStringify(params.contentTypeExtended, "[]"); - await db.update(userPersons).set(set).where(eq(userPersons.id, params.id)); - return getUserPersonById(params.id); + await db + .update(userPersons) + .set(set) + .where(and(eq(userPersons.id, params.id), eq(userPersons.ownerId, ownerId))); + return getUserPersonById(params.id, { ownerId }); } -export async function deleteUserPerson(id: string): Promise { +export async function deleteUserPerson( + id: string, + params?: { ownerId?: string } +): Promise { const db = await initDb(); - await db.delete(userPersons).where(eq(userPersons.id, id)); + const ownerId = resolveTrustedOwnerId(params?.ownerId); + await db + .delete(userPersons) + .where(and(eq(userPersons.id, id), eq(userPersons.ownerId, ownerId))); } export async function getUserPersonsSettings(params?: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(userPersonsSettings) @@ -231,7 +250,7 @@ export async function updateUserPersonsSettings(params: { sortType?: string | null; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getUserPersonsSettings({ ownerId }); const nextSelectedId = diff --git a/server/src/services/chat-entry-parts/entries-repository.ts b/server/src/services/chat-entry-parts/entries-repository.ts index 8e6c66f5..2828acda 100644 --- a/server/src/services/chat-entry-parts/entries-repository.ts +++ b/server/src/services/chat-entry-parts/entries-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; import { chatEntries, entryVariants } from "../../db/schema"; @@ -74,7 +75,7 @@ export function createEntryWithVariant( params: CreateEntryWithVariantParams ): Promise<{ entry: Entry; variant: Variant }> | { entry: Entry; variant: Variant } { const run = (db: DbExecutor): { entry: Entry; variant: Variant } => { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const entryId = uuidv4(); const variantId = uuidv4(); @@ -209,7 +210,16 @@ export async function listEntries(params: { export async function getEntryById(params: { entryId: string }): Promise { const db = await initDb(); - const rows = await db.select().from(chatEntries).where(eq(chatEntries.entryId, params.entryId)).limit(1); + const rows = await db + .select() + .from(chatEntries) + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, resolveTrustedOwnerId()) + ) + ) + .limit(1); const row = rows[0]; return row ? entryRowToDomain(row) : null; } diff --git a/server/src/services/chat-entry-parts/parts-repository.ts b/server/src/services/chat-entry-parts/parts-repository.ts index a925c167..64ec2bed 100644 --- a/server/src/services/chat-entry-parts/parts-repository.ts +++ b/server/src/services/chat-entry-parts/parts-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, inArray } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; import { entryVariants, variantParts } from "../../db/schema"; @@ -108,7 +109,7 @@ export function createPart(params: CreatePartParams & { executor: DbExecutor }): export function createPart(params: CreatePartParams): Promise; export function createPart(params: CreatePartParams): Promise | Part { const run = (db: DbExecutor): Part => { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const partId = uuidv4(); const payloadJson = safeJsonStringify({ @@ -217,7 +218,12 @@ export async function getPartPayloadTextById(params: { const rows = await db .select({ payloadJson: variantParts.payloadJson }) .from(variantParts) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const existing = safeJsonParse(rows[0]?.payloadJson, null); @@ -233,7 +239,12 @@ export async function getPartById(params: { const rows = await db .select() .from(variantParts) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const row = rows[0]; return row ? partRowToDomain(row) : null; diff --git a/server/src/services/chat-entry-parts/variants-repository.ts b/server/src/services/chat-entry-parts/variants-repository.ts index 8c614dad..2e1289c9 100644 --- a/server/src/services/chat-entry-parts/variants-repository.ts +++ b/server/src/services/chat-entry-parts/variants-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, inArray } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; import { chatEntries, entryVariants } from "../../db/schema"; @@ -33,7 +34,7 @@ export function createVariant(params: CreateVariantParams & { executor: DbExecut export function createVariant(params: CreateVariantParams): Promise; export function createVariant(params: CreateVariantParams): Promise | Variant { const run = (db: DbExecutor): Variant => { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const variantId = uuidv4(); const createdAtMs = Date.now(); diff --git a/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts b/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts index 947e1253..35be6bd2 100644 --- a/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts +++ b/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts @@ -1,3 +1,4 @@ +import { resolveTrustedOwnerId } from "../../../core/request-context/owner-scope-storage"; import { createGeneration } from "../../chat-core/generations-repository"; import { resolveGatewayModel, @@ -50,7 +51,7 @@ export async function resolveRunContext(params: { context: RunContext; profile: OperationProfile | null; }> { - const ownerId = params.request.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.request.ownerId); const runtime = await getRuntime("global", ownerId); const providerConfig = await getProviderConfig(runtime.activeProviderId); const model = resolveGatewayModel({ diff --git a/server/src/services/chat-knowledge/knowledge-access-repository.ts b/server/src/services/chat-knowledge/knowledge-access-repository.ts index d1bba54c..b2caf1d2 100644 --- a/server/src/services/chat-knowledge/knowledge-access-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-access-repository.ts @@ -2,6 +2,8 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, isNull, or } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; import { knowledgeRecordAccessState } from "../../db/schema"; @@ -30,7 +32,10 @@ export async function listKnowledgeRecordAccessState(params: { .from(knowledgeRecordAccessState) .where( and( - eq(knowledgeRecordAccessState.ownerId, params.ownerId ?? "global"), + eq( + knowledgeRecordAccessState.ownerId, + resolveTrustedOwnerId(params.ownerId) + ), eq(knowledgeRecordAccessState.chatId, params.chatId), or( params.branchId === null @@ -55,7 +60,10 @@ export async function getKnowledgeRecordAccessState(params: { .from(knowledgeRecordAccessState) .where( and( - eq(knowledgeRecordAccessState.ownerId, params.ownerId ?? "global"), + eq( + knowledgeRecordAccessState.ownerId, + resolveTrustedOwnerId(params.ownerId) + ), eq(knowledgeRecordAccessState.chatId, params.chatId), params.branchId === null ? isNull(knowledgeRecordAccessState.branchId) @@ -87,7 +95,7 @@ export async function upsertKnowledgeAccessState(params: { .insert(knowledgeRecordAccessState) .values({ id: uuidv4(), - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, recordId: params.recordId, diff --git a/server/src/services/chat-knowledge/knowledge-collections-repository.ts b/server/src/services/chat-knowledge/knowledge-collections-repository.ts index 99073d78..22f314b6 100644 --- a/server/src/services/chat-knowledge/knowledge-collections-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-collections-repository.ts @@ -2,6 +2,8 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, isNull, or } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; import { knowledgeCollections } from "../../db/schema"; @@ -50,7 +52,7 @@ export async function createKnowledgeCollection(params: { const now = new Date(); await db.insert(knowledgeCollections).values({ id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, scope: params.scope, @@ -92,7 +94,7 @@ export async function listKnowledgeCollections(params: { .from(knowledgeCollections) .where( and( - eq(knowledgeCollections.ownerId, params.ownerId ?? "global"), + eq(knowledgeCollections.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeCollections.chatId, params.chatId), params.branchId === null ? isNull(knowledgeCollections.branchId) @@ -213,7 +215,7 @@ export async function importKnowledgeCollection(params: { } const importedLinks = await createKnowledgeRecordLinksBulk({ - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, items: params.payload.links diff --git a/server/src/services/chat-knowledge/knowledge-links-repository.ts b/server/src/services/chat-knowledge/knowledge-links-repository.ts index 1b59fd5d..2f53395c 100644 --- a/server/src/services/chat-knowledge/knowledge-links-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-links-repository.ts @@ -2,6 +2,8 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, isNull, or } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; import { knowledgeRecordLinks } from "../../db/schema"; @@ -28,7 +30,7 @@ export async function createKnowledgeRecordLinksBulk(params: { .values( params.items.map((item) => ({ id: uuidv4(), - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, fromRecordId: item.fromRecordId, @@ -58,7 +60,7 @@ export async function listKnowledgeRecordLinks(params: { .from(knowledgeRecordLinks) .where( and( - eq(knowledgeRecordLinks.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecordLinks.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecordLinks.chatId, params.chatId), or( params.branchId === null diff --git a/server/src/services/chat-knowledge/knowledge-records-repository.ts b/server/src/services/chat-knowledge/knowledge-records-repository.ts index cba5669f..d88f32ba 100644 --- a/server/src/services/chat-knowledge/knowledge-records-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-records-repository.ts @@ -2,6 +2,8 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; import { knowledgeRecords } from "../../db/schema"; @@ -60,7 +62,7 @@ export async function getScopedKnowledgeRecordsByIds(params: { .from(knowledgeRecords) .where( and( - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildOverlayBranchScope(params.branchId), inArray(knowledgeRecords.id, params.ids) @@ -82,7 +84,7 @@ export async function findKnowledgeRecordsByKeys(params: { .from(knowledgeRecords) .where( and( - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildOverlayBranchScope(params.branchId), inArray(knowledgeRecords.key, params.keys) @@ -100,7 +102,7 @@ export async function listKnowledgeRecords(params: { }): Promise { const db = await initDb(); const where = [ - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildOverlayBranchScope(params.branchId), ]; @@ -183,7 +185,7 @@ export async function upsertKnowledgeRecord(params: { } else { await db.insert(knowledgeRecords).values({ id: uuidv4(), - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, collectionId: params.collectionId, diff --git a/server/src/services/chat-knowledge/knowledge-reveal-service.ts b/server/src/services/chat-knowledge/knowledge-reveal-service.ts index 26ad5126..a1b18960 100644 --- a/server/src/services/chat-knowledge/knowledge-reveal-service.ts +++ b/server/src/services/chat-knowledge/knowledge-reveal-service.ts @@ -1,3 +1,5 @@ +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { getKnowledgeRecordAccessState, upsertKnowledgeAccessState } from "./knowledge-access-repository"; import { evaluateKnowledgeGate } from "./knowledge-gate-policy"; import { @@ -62,7 +64,7 @@ export async function revealKnowledgeRecords(params: { branchId: string | null; request: KnowledgeRevealRequest; }): Promise { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const targetRecords = await resolveTargetRecords(params); const targetIds = new Set(targetRecords.map((item) => item.id)); const missingIds = (params.request.recordIds ?? []).filter((item) => !targetIds.has(item)); diff --git a/server/src/services/chat-knowledge/knowledge-search-service.ts b/server/src/services/chat-knowledge/knowledge-search-service.ts index 9ea133c4..d680f2eb 100644 --- a/server/src/services/chat-knowledge/knowledge-search-service.ts +++ b/server/src/services/chat-knowledge/knowledge-search-service.ts @@ -1,5 +1,7 @@ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; import { knowledgeRecords } from "../../db/schema"; @@ -133,7 +135,7 @@ export async function searchKnowledgeRecords(params: { }): Promise { const db = await initDb(); const where = [ - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildBranchScope(params.branchId), eq(knowledgeRecords.status, "active"), diff --git a/server/src/services/llm/llm-repository.ts b/server/src/services/llm/llm-repository.ts index e1b05141..7f83052c 100644 --- a/server/src/services/llm/llm-repository.ts +++ b/server/src/services/llm/llm-repository.ts @@ -7,6 +7,7 @@ import { encryptSecret, maskToken, } from "@core/crypto/secret-box"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { initDb, type Db } from "../../db/client"; import { @@ -65,6 +66,15 @@ function nowDate(): Date { return new Date(); } +function storageScopeId(scopeId: string): string { + const ownerId = resolveTrustedOwnerId(); + return ownerId === "global" ? scopeId : `${ownerId}:${scopeId}`; +} + +function providerConfigId(ownerId: string, providerId: LlmProviderId): string { + return ownerId === "global" ? providerId : `${ownerId}:${providerId}`; +} + function parseConfigJson(raw: string): unknown { try { return JSON.parse(raw) as unknown; @@ -142,13 +152,14 @@ export async function getRuntime( scopeId: string, ): Promise { const database = await db(); + const persistedScopeId = storageScopeId(scopeId); const rows = await database .select() .from(llmRuntimeSettings) .where( and( eq(llmRuntimeSettings.scope, scope), - eq(llmRuntimeSettings.scopeId, scopeId), + eq(llmRuntimeSettings.scopeId, persistedScopeId), ), ); @@ -173,7 +184,7 @@ export async function getRuntime( await database.insert(llmRuntimeSettings).values({ scope, - scopeId, + scopeId: persistedScopeId, activeProviderId: fallback.activeProviderId, activeTokenId: null, activeModel: null, @@ -188,12 +199,13 @@ export async function upsertRuntime( ): Promise { const database = await db(); const ts = nowDate(); + const persistedScopeId = storageScopeId(runtime.scopeId); await database .insert(llmRuntimeSettings) .values({ scope: runtime.scope, - scopeId: runtime.scopeId, + scopeId: persistedScopeId, activeProviderId: runtime.activeProviderId, activeTokenId: runtime.activeTokenId, activeModel: runtime.activeModel, @@ -218,13 +230,14 @@ export async function getRuntimeProviderState(params: { providerId: LlmProviderId; }): Promise { const database = await db(); + const persistedScopeId = storageScopeId(params.scopeId); const rows = await database .select() .from(llmRuntimeProviderState) .where( and( eq(llmRuntimeProviderState.scope, params.scope), - eq(llmRuntimeProviderState.scopeId, params.scopeId), + eq(llmRuntimeProviderState.scopeId, persistedScopeId), eq(llmRuntimeProviderState.providerId, params.providerId), ), ); @@ -241,7 +254,7 @@ export async function getRuntimeProviderState(params: { } return { scope: row.scope, - scopeId: row.scopeId, + scopeId: params.scopeId, providerId: row.providerId as LlmProviderId, lastTokenId: row.lastTokenId ?? null, lastModel: row.lastModel ?? null, @@ -253,11 +266,12 @@ export async function upsertRuntimeProviderState( ): Promise { const database = await db(); const ts = nowDate(); + const persistedScopeId = storageScopeId(params.scopeId); await database .insert(llmRuntimeProviderState) .values({ scope: params.scope, - scopeId: params.scopeId, + scopeId: persistedScopeId, providerId: params.providerId, lastTokenId: params.lastTokenId, lastModel: params.lastModel, @@ -281,10 +295,16 @@ export async function getProviderConfig( providerId: LlmProviderId, ): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const rows = await database .select() .from(llmProviderConfigs) - .where(eq(llmProviderConfigs.id, providerId)); + .where( + and( + eq(llmProviderConfigs.ownerId, ownerId), + eq(llmProviderConfigs.providerId, providerId) + ) + ); if (!rows[0]) { return { providerId, config: {} }; @@ -301,11 +321,13 @@ export async function upsertProviderConfig( const database = await db(); const ts = nowDate(); const configJson = JSON.stringify(config ?? {}); + const ownerId = resolveTrustedOwnerId(); await database .insert(llmProviderConfigs) .values({ - id: providerId, + id: providerConfigId(ownerId, providerId), + ownerId, providerId, configJson, createdAt: ts, @@ -323,10 +345,16 @@ export async function listTokens( providerId: LlmProviderId, ): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const rows = await database .select() .from(llmTokens) - .where(eq(llmTokens.providerId, providerId)); + .where( + and( + eq(llmTokens.ownerId, ownerId), + eq(llmTokens.providerId, providerId) + ) + ); return rows.map((r) => ({ id: r.id, providerId: r.providerId as LlmProviderId, @@ -344,6 +372,7 @@ export async function createToken(params: { token: string; }): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const id = uuidv4(); const ts = nowDate(); const ciphertext = encryptSecret(params.token); @@ -351,6 +380,7 @@ export async function createToken(params: { await database.insert(llmTokens).values({ id, + ownerId, providerId: params.providerId, name: params.name, ciphertext, @@ -377,6 +407,7 @@ export async function updateToken(params: { token?: string; }): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const ts = nowDate(); const set: Partial = { updatedAt: ts }; @@ -388,11 +419,15 @@ export async function updateToken(params: { set.tokenHint = maskToken(params.token.trim()); } - await database.update(llmTokens).set(set).where(eq(llmTokens.id, params.id)); + await database + .update(llmTokens) + .set(set) + .where(and(eq(llmTokens.id, params.id), eq(llmTokens.ownerId, ownerId))); } export async function deleteToken(id: string): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); await database.transaction(async (tx) => { await tx .update(llmRuntimeSettings) @@ -402,7 +437,9 @@ export async function deleteToken(id: string): Promise { .update(llmRuntimeProviderState) .set({ lastTokenId: null, updatedAt: nowDate() }) .where(eq(llmRuntimeProviderState.lastTokenId, id)); - await tx.delete(llmTokens).where(eq(llmTokens.id, id)); + await tx + .delete(llmTokens) + .where(and(eq(llmTokens.id, id), eq(llmTokens.ownerId, ownerId))); }); } @@ -428,10 +465,11 @@ export async function getTokenPlaintextResult( id: string, ): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const rows = await database .select() .from(llmTokens) - .where(eq(llmTokens.id, id)); + .where(and(eq(llmTokens.id, id), eq(llmTokens.ownerId, ownerId))); const row = rows[0]; if (!row) return { status: "missing" }; try { @@ -459,8 +497,9 @@ export async function getTokenPlaintext(id: string): Promise { export async function touchTokenLastUsed(id: string): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); await database .update(llmTokens) .set({ lastUsedAt: nowDate(), updatedAt: nowDate() }) - .where(eq(llmTokens.id, id)); + .where(and(eq(llmTokens.id, id), eq(llmTokens.ownerId, ownerId))); } diff --git a/server/src/services/rag.service.ts b/server/src/services/rag.service.ts index 11f9ba29..66b85746 100644 --- a/server/src/services/rag.service.ts +++ b/server/src/services/rag.service.ts @@ -5,6 +5,7 @@ import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { getTokenPlaintext, listTokens } from "@services/llm/llm-repository"; import { probeRagProviderConnection } from "@services/rag/rag-connection-check"; @@ -31,8 +32,18 @@ import type { const MODELS_REQUEST_TIMEOUT_MS = 7000; const DEFAULT_RAG_PRESET_NAME = "Default RAG preset"; -const DEFAULT_OWNER_ID = "global"; -const RUNTIME_ROW_ID = "global"; +function currentOwnerId(): string { + return resolveTrustedOwnerId(); +} + +function runtimeRowId(): string { + return currentOwnerId(); +} + +function storedRagProviderId(providerId: RagProviderId): string { + const ownerId = currentOwnerId(); + return ownerId === "global" ? providerId : `${ownerId}:${providerId}`; +} const DEFAULT_PROVIDER_CONFIGS: Record = { openrouter: { defaultModel: "text-embedding-3-small", encodingFormat: "float" }, @@ -209,7 +220,7 @@ async function getRagPresetRowById( const rows = await db .select() .from(ragPresets) - .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, DEFAULT_OWNER_ID))) + .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, currentOwnerId()))) .limit(1); return rows[0] ?? null; } @@ -225,7 +236,7 @@ async function upsertRagPreset(input: RagPreset): Promise { .insert(ragPresets) .values({ id: parsed.id, - ownerId: DEFAULT_OWNER_ID, + ownerId: currentOwnerId(), name: parsed.name, payloadJson: safeJsonStringify(parsed.payload, "{}"), createdAt, @@ -234,7 +245,7 @@ async function upsertRagPreset(input: RagPreset): Promise { .onConflictDoUpdate({ target: ragPresets.id, set: { - ownerId: DEFAULT_OWNER_ID, + ownerId: currentOwnerId(), name: parsed.name, payloadJson: safeJsonStringify(parsed.payload, "{}"), createdAt, @@ -252,14 +263,14 @@ async function ensureRuntimeRow(): Promise { await db .insert(ragProviderConfigs) .values({ - providerId, + providerId: storedRagProviderId(providerId), configJson: safeJsonStringify(config, "{}"), createdAt: now, updatedAt: now, @@ -397,7 +408,7 @@ export const ragService = { const rows = await db .select() .from(ragPresets) - .where(eq(ragPresets.ownerId, DEFAULT_OWNER_ID)) + .where(eq(ragPresets.ownerId, currentOwnerId())) .orderBy(desc(ragPresets.createdAt)); return rows.map(rowToRagPreset); @@ -418,7 +429,7 @@ export const ragService = { await db .delete(ragPresets) - .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, DEFAULT_OWNER_ID))); + .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, currentOwnerId()))); return id; }, @@ -440,7 +451,7 @@ export const ragService = { await db .insert(ragPresetSettings) .values({ - ownerId: DEFAULT_OWNER_ID, + ownerId: currentOwnerId(), selectedId: next.selectedId, updatedAt: now, }) @@ -470,7 +481,7 @@ export const ragService = { await db .insert(ragRuntimeSettings) .values({ - id: RUNTIME_ROW_ID, + id: runtimeRowId(), activeProviderId: parsed.activeProviderId, activeTokenId: parsed.activeTokenId, activeModel: parsed.activeModel, @@ -498,10 +509,10 @@ export const ragService = { await ensureDefaultProviderConfigs(); const db = await initDb(); const rows = await db.select().from(ragProviderConfigs); - const map = new Map(rows.map((row) => [row.providerId as RagProviderId, row])); + const map = new Map(rows.map((row) => [row.providerId, row])); - const openrouterRaw = map.get("openrouter")?.configJson; - const ollamaRaw = map.get("ollama")?.configJson; + const openrouterRaw = map.get(storedRagProviderId("openrouter"))?.configJson; + const ollamaRaw = map.get(storedRagProviderId("ollama"))?.configJson; return { openrouter: normalizeRagConfig( @@ -532,7 +543,7 @@ export const ragService = { await db .insert(ragProviderConfigs) .values({ - providerId, + providerId: storedRagProviderId(providerId), configJson: safeJsonStringify(next[providerId], "{}"), createdAt: now, updatedAt: now, @@ -551,10 +562,10 @@ export const ragService = { }, }; -let ensureRagPresetStateInFlight: Promise<{ +const ensureRagPresetStateInFlight = new Map | null = null; +}>>(); export function normalizeRagPresetSettings(input: unknown): RagPresetSettings { const parsed = ragPresetSettingsSchema.safeParse(input); @@ -633,15 +644,20 @@ export async function ensureRagPresetState(): Promise<{ presets: RagPreset[]; settings: RagPresetSettings; }> { - if (ensureRagPresetStateInFlight) { - return ensureRagPresetStateInFlight; + const ownerId = currentOwnerId(); + const existing = ensureRagPresetStateInFlight.get(ownerId); + if (existing) { + return existing; } - ensureRagPresetStateInFlight = ensureRagPresetStateUnsafe(); + const pending = ensureRagPresetStateUnsafe(); + ensureRagPresetStateInFlight.set(ownerId, pending); try { - return await ensureRagPresetStateInFlight; + return await pending; } finally { - ensureRagPresetStateInFlight = null; + if (ensureRagPresetStateInFlight.get(ownerId) === pending) { + ensureRagPresetStateInFlight.delete(ownerId); + } } } diff --git a/server/src/services/rag/chroma-rag.service.ts b/server/src/services/rag/chroma-rag.service.ts index 22bee5d6..d4033f5a 100644 --- a/server/src/services/rag/chroma-rag.service.ts +++ b/server/src/services/rag/chroma-rag.service.ts @@ -1,5 +1,6 @@ import { getChromaConfig } from "../../config/chroma-config"; import { HttpError } from "../../core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { generateRagEmbedding } from "../rag.service"; import { normalizeWorldInfoBookEntries } from "../world-info/world-info-normalizer"; import { @@ -70,7 +71,22 @@ function toNonEmptyString(value: unknown): string | null { function resolveCollectionName(collectionName?: string): string { const fallback = getChromaConfig().worldInfoCollection; const normalized = toNonEmptyString(collectionName); - return normalized ?? fallback; + const logicalName = normalized ?? fallback; + const ownerId = resolveTrustedOwnerId(); + if (ownerId === "global") { + return looksOwnerNamespaced(logicalName) ? `global__${logicalName}` : logicalName; + } + return `${ownerId}__${logicalName}`; +} + +function looksOwnerNamespaced(name: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f-]{27}__/i.test(name); +} + +function isVisibleCollectionName(name: string): boolean { + const ownerId = resolveTrustedOwnerId(); + if (ownerId === "global") return !looksOwnerNamespaced(name); + return name.startsWith(`${ownerId}__`); } function normalizePeekResult(raw: unknown): ChromaPeekItem[] { @@ -242,7 +258,8 @@ export function createChromaRagService( }, async listCollections(): Promise { - return deps.chroma.listCollections(); + const collections = await deps.chroma.listCollections(); + return collections.filter((collection) => isVisibleCollectionName(collection.name)); }, async createCollection(params: { @@ -253,8 +270,9 @@ export function createChromaRagService( if (!name) { throw new HttpError(400, "Collection name is required", "VALIDATION_ERROR"); } + const storedName = resolveCollectionName(name); await deps.chroma.getOrCreateCollection({ - name, + name: storedName, metadata: toMetadataRecord(params.metadata), }); return { name }; @@ -265,7 +283,7 @@ export function createChromaRagService( if (!normalized) { throw new HttpError(400, "Collection name is required", "VALIDATION_ERROR"); } - await deps.chroma.deleteCollection(normalized); + await deps.chroma.deleteCollection(resolveCollectionName(normalized)); return { name: normalized }; }, @@ -397,7 +415,7 @@ export function createChromaRagService( durationMs: number; }> { const startedAt = Date.now(); - const ownerId = toNonEmptyString(params.ownerId) ?? "global"; + const ownerId = resolveTrustedOwnerId(toNonEmptyString(params.ownerId) ?? undefined); const collectionName = resolveCollectionName(params.collectionName); const books = await deps.listBooksForIndexing({ ownerId }); const docs: ChromaDocInput[] = []; diff --git a/server/src/services/sidebars/sidebars-repository.ts b/server/src/services/sidebars/sidebars-repository.ts index 008d7903..dbbd340c 100644 --- a/server/src/services/sidebars/sidebars-repository.ts +++ b/server/src/services/sidebars/sidebars-repository.ts @@ -1,11 +1,10 @@ import { eq } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { uiSidebarsState } from "../../db/schema"; import { type SidebarState } from "../../types"; -const SIDEBARS_STATE_ID = "global"; - function safeParseState(json: string): SidebarState { try { const parsed = JSON.parse(json) as unknown; @@ -21,7 +20,7 @@ export async function getSidebarsState(): Promise { const rows = await db .select() .from(uiSidebarsState) - .where(eq(uiSidebarsState.id, SIDEBARS_STATE_ID)); + .where(eq(uiSidebarsState.id, resolveTrustedOwnerId())); const row = rows[0]; if (!row) return {}; @@ -35,7 +34,7 @@ export async function saveSidebarsState(state: SidebarState): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const limit = Math.max(1, Math.min(200, params.limit ?? 50)); const query = params.query?.trim().toLowerCase() ?? ""; const where = [eq(worldInfoBooks.ownerId, ownerId), isNull(worldInfoBooks.deletedAt)]; @@ -210,7 +211,13 @@ export async function getWorldInfoBookById(id: string): Promise { if (params.ids.length === 0) return []; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(worldInfoBooks) @@ -240,7 +247,7 @@ export async function listWorldInfoBooksForIndexing(params?: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(worldInfoBooks) @@ -259,7 +266,7 @@ export async function createWorldInfoBook(params: { source?: WorldInfoBookSource; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const ts = new Date(); const id = uuidv4(); const normalized = normalizeWorldInfoBookPayload(params.data ?? {}); @@ -304,7 +311,7 @@ export async function updateWorldInfoBook(params: { version?: number; }): Promise<{ item: WorldInfoBookDto | null; conflict: boolean }> { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getWorldInfoBookById(params.id); if (!current || current.ownerId !== ownerId) return { item: null, conflict: false }; @@ -349,7 +356,7 @@ export async function softDeleteWorldInfoBook(params: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getWorldInfoBookById(params.id); if (!current || current.ownerId !== ownerId) return false; const ts = new Date(); @@ -383,7 +390,7 @@ export async function getWorldInfoSettings(params?: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(worldInfoSettings) @@ -427,7 +434,7 @@ export async function patchWorldInfoSettings(params: { patch: Partial>; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getWorldInfoSettings({ ownerId }); const ts = new Date(); @@ -468,7 +475,7 @@ export async function listWorldInfoBindings(params: { scopeId?: string | null; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const where = [eq(worldInfoBindings.ownerId, ownerId)]; if (params.scope) where.push(eq(worldInfoBindings.scope, params.scope)); if (typeof params.scopeId === "string") where.push(eq(worldInfoBindings.scopeId, params.scopeId)); @@ -495,7 +502,7 @@ export async function replaceWorldInfoBindings(params: { }>; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const scopeId = params.scope === "global" ? null : (params.scopeId ?? null); const ts = new Date(); await db.transaction((tx) => { @@ -583,7 +590,7 @@ export async function listWorldInfoTimedEffects(params: { branchId: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(worldInfoTimedEffects) @@ -616,7 +623,7 @@ export async function upsertWorldInfoTimedEffect(params: { protected?: boolean; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const ts = new Date(); const id = uuidv4(); await db diff --git a/server/yarn.lock b/server/yarn.lock index 02787958..43434975 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -36,6 +36,11 @@ dependencies: tslib "^2.4.0" +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + "@esbuild-kit/core-utils@^3.3.2": version "3.3.2" resolved "https://registry.yarnpkg.com/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz#186b6598a5066f0413471d7c4d45828e399ba96c" @@ -715,6 +720,11 @@ dependencies: semver "7.7.4" +"@phc/format@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4" + integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== + "@rollup/rollup-android-arm-eabi@4.57.1": version "4.57.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" @@ -1335,6 +1345,16 @@ arg@^4.1.0: resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== +argon2@0.44.0: + version "0.44.0" + resolved "https://registry.yarnpkg.com/argon2/-/argon2-0.44.0.tgz#65a5ba662bba66af41407aa0457decf4af101742" + integrity sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig== + dependencies: + "@phc/format" "^1.0.0" + cross-env "^10.0.0" + node-addon-api "^8.5.0" + node-gyp-build "^4.8.4" + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -1752,6 +1772,14 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cross-env@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== + dependencies: + "@epic-web/invariant" "^1.0.0" + cross-spawn "^7.0.6" + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -3252,6 +3280,16 @@ node-abi@^3.3.0: dependencies: semver "^7.3.5" +node-addon-api@^8.5.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.9.0.tgz#d2467090e6195c428ccd510dfd604f01c027f0a0" + integrity sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q== + +node-gyp-build@^4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + nodemon@^3.1.11: version "3.1.11" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.11.tgz#04a54d1e794fbec9d8f6ffd8bf1ba9ea93a756ed" diff --git a/web/src/api/api-json.ts b/web/src/api/api-json.ts index 430ea23d..740986dc 100644 --- a/web/src/api/api-json.ts +++ b/web/src/api/api-json.ts @@ -1,9 +1,11 @@ import { BASE_URL } from "../const"; +import { authFetch } from "./auth-fetch"; + type ApiEnvelope = { data: T; error?: unknown }; export async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { "Content-Type": "application/json", diff --git a/web/src/api/app-backgrounds.ts b/web/src/api/app-backgrounds.ts index c202ad2b..20b9b612 100644 --- a/web/src/api/app-backgrounds.ts +++ b/web/src/api/app-backgrounds.ts @@ -1,6 +1,7 @@ import { BASE_URL } from "../const"; import { apiJson } from "./api-json"; +import { authFetch } from "./auth-fetch"; import type { AppBackgroundActiveSelection, @@ -20,7 +21,7 @@ export async function importAppBackground(file: File): Promise { + const headers = new Headers(init.headers); + if (csrfToken && isMutation(init.method)) { + headers.set('X-CSRF-Token', csrfToken); + } + return fetch(input, { + ...init, + headers, + credentials: 'include', + }); +} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 00000000..3a15e6ab --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,106 @@ +import { BASE_URL } from '../const'; + +import { authFetch, setAuthCsrfToken } from './auth-fetch'; + +export type AccessMode = 'local' | 'public'; + +export type AuthUser = { + id: string; + username: string; + displayName: string; + role: 'admin' | 'user'; + status: 'active' | 'disabled'; + hasPassword: boolean; +}; + +export type AuthStatus = { + mode: AccessMode; + setupRequired: boolean; + authenticated: boolean; + user: AuthUser | null; + accounts: AuthUser[]; + csrfToken?: string; +}; + +type AuthResult = { + user: AuthUser; + csrfToken: string; + expiresAt: string; +}; + +async function authJson(path: string, init?: RequestInit): Promise { + const response = await authFetch(`${BASE_URL}/auth${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + }); + const body = (await response.json().catch(() => ({}))) as { + data?: T; + error?: { message?: string }; + }; + if (!response.ok) { + throw new Error(body.error?.message ?? `HTTP error ${response.status}`); + } + return body.data as T; +} + +export async function getAuthStatus(): Promise { + const status = await authJson('/status'); + setAuthCsrfToken(status.csrfToken ?? null); + return status; +} + +export async function setupAccount(params: { + username: string; + displayName?: string; + password: string; + setupToken?: string; +}): Promise { + const result = await authJson('/setup', { + method: 'POST', + headers: params.setupToken ? { 'X-Setup-Token': params.setupToken } : undefined, + body: JSON.stringify({ + username: params.username, + displayName: params.displayName, + password: params.password, + }), + }); + setAuthCsrfToken(result.csrfToken); + return result; +} + +export async function loginAccount(params: { + username?: string; + userId?: string; + password: string; +}): Promise { + const result = await authJson('/login', { + method: 'POST', + body: JSON.stringify(params), + }); + setAuthCsrfToken(result.csrfToken); + return result; +} + +export async function logoutAccount(): Promise { + await authJson<{ ok: true }>('/logout', { method: 'POST' }); + setAuthCsrfToken(null); +} + +export async function listAuthUsers(): Promise { + return authJson('/users'); +} + +export async function createAuthUser(params: { + username: string; + displayName?: string; + password: string; + role: 'admin' | 'user'; +}): Promise { + return authJson('/users', { + method: 'POST', + body: JSON.stringify(params), + }); +} diff --git a/web/src/api/bundles.ts b/web/src/api/bundles.ts index 4d201f2d..1909ad7d 100644 --- a/web/src/api/bundles.ts +++ b/web/src/api/bundles.ts @@ -1,5 +1,7 @@ import { BASE_URL } from "../const"; +import { authFetch } from "./auth-fetch"; + import type { TaleSpinnerBundleResourceKind } from "@shared/types/bundles"; type ApiEnvelope = { data: T; error?: unknown }; @@ -46,7 +48,7 @@ export async function exportBundle(input: { selections: BundleSelectionHandle[]; format?: "json" | "archive" | "auto"; }): Promise<{ blob: Blob; filename: string; contentType: string }> { - const res = await fetch(`${BASE_URL}/bundles/export`, { + const res = await authFetch(`${BASE_URL}/bundles/export`, { method: "POST", headers: { "Content-Type": "application/json", @@ -81,7 +83,7 @@ export async function importBundle(file: File): Promise { const form = new FormData(); form.append("file", file); - const res = await fetch(`${BASE_URL}/bundles/import`, { + const res = await authFetch(`${BASE_URL}/bundles/import`, { method: "POST", body: form, }); diff --git a/web/src/api/chat-core.ts b/web/src/api/chat-core.ts index d2fe3ca4..2af698ac 100644 --- a/web/src/api/chat-core.ts +++ b/web/src/api/chat-core.ts @@ -1,6 +1,7 @@ import { BASE_URL } from '../const'; import { getApiErrorMessage } from './api-error'; +import { authFetch } from './auth-fetch'; import type { OperationBlock, @@ -17,7 +18,7 @@ type ApiEnvelope = { data: T; error?: unknown }; export const BACKEND_ORIGIN = BASE_URL.replace(/\/api\/?$/, ''); async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', @@ -38,7 +39,7 @@ async function apiJson(path: string, init?: RequestInit): Promise { } async function apiForm(path: string, form: FormData, init?: Omit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, method: init?.method ?? 'POST', body: form, @@ -169,7 +170,7 @@ export async function exportEntityProfileFile(params: { format: 'json' | 'png'; preferredName?: string; }): Promise<{ blob: Blob; filename: string; contentType: string }> { - const res = await fetch( + const res = await authFetch( `${BASE_URL}/entity-profiles/${encodeURIComponent(params.id)}/export?format=${encodeURIComponent(params.format)}`, { method: 'GET', diff --git a/web/src/api/chat-entry-parts.ts b/web/src/api/chat-entry-parts.ts index 897f919e..9cf51aff 100644 --- a/web/src/api/chat-entry-parts.ts +++ b/web/src/api/chat-entry-parts.ts @@ -1,5 +1,7 @@ import { BASE_URL } from '../const'; +import { authFetch } from './auth-fetch'; + import type { SseEnvelope } from './chat-core'; import type { Variant, Entry } from '@shared/types/chat-entry-parts'; import type { ChatOperationRuntimeStateDto } from '@shared/types/chat-runtime-state'; @@ -11,7 +13,7 @@ const CHAT_GENERATION_DEBUG_STORAGE_KEY = 'chat_generation_debug'; const CHAT_GENERATION_DEBUG_SETTINGS_KEY = '__chatGenerationDebug'; async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', @@ -168,7 +170,7 @@ async function* streamSseRequest(params: { body: Record; signal?: AbortSignal; }): AsyncGenerator { - const res = await fetch(`${BASE_URL}${params.path}`, { + const res = await authFetch(`${BASE_URL}${params.path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/web/src/api/llm.ts b/web/src/api/llm.ts index 27db3373..e58f3d5a 100644 --- a/web/src/api/llm.ts +++ b/web/src/api/llm.ts @@ -1,5 +1,7 @@ import { BASE_URL } from '../const'; +import { authFetch } from './auth-fetch'; + import type { LlmModel, LlmOpenRouterEndpoint, @@ -19,7 +21,7 @@ import type { type ApiEnvelope = { data: T; error?: unknown }; async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', diff --git a/web/src/api/world-info.ts b/web/src/api/world-info.ts index 97d9887b..367f4c49 100644 --- a/web/src/api/world-info.ts +++ b/web/src/api/world-info.ts @@ -1,6 +1,7 @@ import { BASE_URL } from '../const'; import { apiJson } from './api-json'; +import { authFetch } from './auth-fetch'; export type WorldInfoScope = 'global' | 'chat' | 'entity_profile' | 'persona'; export type WorldInfoBindingRole = 'primary' | 'additional'; @@ -90,7 +91,7 @@ export type WorldInfoBookListResponse = { type ApiEnvelope = { data: T; error?: unknown }; async function apiForm(path: string, form: FormData, init?: Omit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, method: init?.method ?? 'POST', body: form, diff --git a/web/src/features/auth/account-manager.tsx b/web/src/features/auth/account-manager.tsx new file mode 100644 index 00000000..4f035147 --- /dev/null +++ b/web/src/features/auth/account-manager.tsx @@ -0,0 +1,97 @@ +import { Button, Group, PasswordInput, Select, Stack, Text, TextInput } from '@mantine/core'; +import { useUnit } from 'effector-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + $authStatus, + $authUsers, + accountManagerOpened, + createAuthUserFx, + createUserSubmitted, + logoutRequested, +} from '@model/auth'; +import { Dialog } from '@ui/dialog'; + +export function AccountManager({ opened, onClose }: { opened: boolean; onClose: () => void }) { + const { t } = useTranslation(); + const [status, users, load, create, logout, creating] = useUnit([ + $authStatus, + $authUsers, + accountManagerOpened, + createUserSubmitted, + logoutRequested, + createAuthUserFx.pending, + ]); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState<'admin' | 'user'>('user'); + + useEffect(() => { + if (opened && status.user?.role === 'admin') load(); + }, [load, opened, status.user?.role]); + + return ( + { + if (!next) onClose(); + }} + title={t('auth.accounts.title')} + size="md" + footer={ + + } + > + + {t('auth.accounts.signedInAs', { name: status.user?.displayName })} + {status.user?.role === 'admin' && ( + <> + {t('auth.accounts.users')} + {users.map((user) => ( + + {user.displayName} · {user.role} + + ))} + setUsername(e.currentTarget.value)} /> + setDisplayName(e.currentTarget.value)} /> + setPassword(e.currentTarget.value)} + /> + { + if (role === 'admin' || role === 'user') onUpdate({ id: user.id, role }); + }} + data={[ + { value: 'user', label: t('auth.accounts.roles.user') }, + { value: 'admin', label: t('auth.accounts.roles.admin') }, + ]} + /> + setRole(value === 'admin' ? 'admin' : 'user')} - data={[ - { value: 'user', label: t('auth.accounts.roles.user') }, - { value: 'admin', label: t('auth.accounts.roles.admin') }, - ]} + + - - - - + )} - + ); } diff --git a/web/src/features/auth/account-overview-section.tsx b/web/src/features/auth/account-overview-section.tsx new file mode 100644 index 00000000..68cf9da4 --- /dev/null +++ b/web/src/features/auth/account-overview-section.tsx @@ -0,0 +1,119 @@ +import { + Avatar, + Badge, + Box, + Button, + Divider, + Group, + Stack, + Text, +} from '@mantine/core'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuLogOut, LuRefreshCw } from 'react-icons/lu'; + +import { AccountChooser } from './account-chooser'; +import { AuthCredentialsForm } from './auth-credentials-form'; + +import type { AuthStatus } from '../../api/auth'; + +type AccountOverviewSectionProps = { + status: AuthStatus; + pending: boolean; + onSwitch: (params: { username?: string; userId?: string; password: string }) => void; + onLogout: () => void; +}; + +export function AccountOverviewSection({ + status, + pending, + onSwitch, + onLogout, +}: AccountOverviewSectionProps) { + const { t } = useTranslation(); + const [manualSwitch, setManualSwitch] = useState(false); + const currentUser = status.user; + if (!currentUser) return null; + + const otherLocalAccounts = status.accounts.filter((account) => account.id !== currentUser.id); + + return ( + + + + {currentUser.displayName.slice(0, 2).toLocaleUpperCase()} + + + + {currentUser.displayName} + + + @{currentUser.username} + + + {t(`auth.accounts.roles.${currentUser.role}`)} + + + + + + {t('auth.accounts.switchTitle')} + + {t(`auth.accounts.switchDescription.${status.mode}`)} + + + {status.mode === 'local' && otherLocalAccounts.length > 0 && ( + + )} + {status.mode === 'local' && otherLocalAccounts.length === 0 && ( + + {t('auth.accounts.noOtherAccounts')} + + )} + {status.mode === 'public' && !manualSwitch && ( + + )} + {status.mode === 'public' && manualSwitch && ( + setManualSwitch(false)} + onLogin={onSwitch} + /> + )} + + + + + + + {t('auth.accounts.endSession')} + + {t('auth.accounts.endSessionDescription')} + + + + + + ); +} diff --git a/web/src/features/auth/account-security-section.tsx b/web/src/features/auth/account-security-section.tsx new file mode 100644 index 00000000..d4a8205c --- /dev/null +++ b/web/src/features/auth/account-security-section.tsx @@ -0,0 +1,70 @@ +import { Box, Button, Group, PasswordInput, Stack, Text } from '@mantine/core'; +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuKeyRound } from 'react-icons/lu'; + +import type { AuthStatus } from '../../api/auth'; + +type AccountSecuritySectionProps = { + status: AuthStatus; + pending: boolean; + onChangePassword: (params: { currentPassword: string; newPassword: string }) => void; +}; + +export function AccountSecuritySection({ + status, + pending, + onChangePassword, +}: AccountSecuritySectionProps) { + const { t } = useTranslation(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const hasPassword = status.user?.hasPassword ?? false; + + const submit = (event: FormEvent) => { + event.preventDefault(); + onChangePassword({ currentPassword, newPassword }); + }; + + return ( + + + + {t('auth.accounts.changeOwnPassword')} + + {t(`auth.accounts.passwordDescription.${status.mode}`)} + + + {hasPassword && ( + } + value={currentPassword} + onChange={(event) => setCurrentPassword(event.currentTarget.value)} + autoComplete="current-password" + /> + )} + } + value={newPassword} + onChange={(event) => setNewPassword(event.currentTarget.value)} + autoComplete="new-password" + /> + + + + + + ); +} diff --git a/web/src/features/auth/account-user-row.tsx b/web/src/features/auth/account-user-row.tsx index a99a69b6..29b0e799 100644 --- a/web/src/features/auth/account-user-row.tsx +++ b/web/src/features/auth/account-user-row.tsx @@ -1,6 +1,20 @@ -import { Button, Group, Paper, PasswordInput, Select, Stack, Text } from '@mantine/core'; +import { + ActionIcon, + Avatar, + Badge, + Box, + Button, + Collapse, + Group, + PasswordInput, + Select, + SimpleGrid, + Stack, + Text, +} from '@mantine/core'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { LuChevronDown, LuKeyRound } from 'react-icons/lu'; import type { AuthUser } from '../../api/auth'; @@ -8,11 +22,7 @@ type AccountUserRowProps = { user: AuthUser; allowEmptyPassword: boolean; pending: boolean; - onUpdate: (params: { - id: string; - role?: AuthUser['role']; - status?: AuthUser['status']; - }) => void; + onUpdate: (params: { id: string; role?: AuthUser['role']; status?: AuthUser['status'] }) => void; onResetPassword: (params: { id: string; newPassword: string }) => void; }; @@ -24,63 +34,99 @@ export function AccountUserRow({ onResetPassword, }: AccountUserRowProps) { const { t } = useTranslation(); + const [opened, setOpened] = useState(false); const [newPassword, setNewPassword] = useState(''); return ( - - - {user.displayName} - - @{user.username} - - - { - if (status === 'active' || status === 'disabled') onUpdate({ id: user.id, status }); - }} - data={[ - { value: 'active', label: t('auth.accounts.statuses.active') }, - { value: 'disabled', label: t('auth.accounts.statuses.disabled') }, - ]} - /> + + + + {user.displayName.slice(0, 2).toLocaleUpperCase()} + + + + {user.displayName} + + + @{user.username} + + + + {t(`auth.accounts.roles.${user.role}`)} + + {t(`auth.accounts.statuses.${user.status}`)} + - - setNewPassword(event.currentTarget.value)} - style={{ flex: 1 }} - /> - - - - + /> + + + + + + + { + if (status === 'active' || status === 'disabled') onUpdate({ id: user.id, status }); + }} + data={[ + { value: 'active', label: t('auth.accounts.statuses.active') }, + { value: 'disabled', label: t('auth.accounts.statuses.disabled') }, + ]} + /> + + + } + value={newPassword} + disabled={pending} + onChange={(event) => setNewPassword(event.currentTarget.value)} + style={{ flex: '1 1 240px' }} + /> + + + + + ); } diff --git a/web/src/features/auth/account-users-section.tsx b/web/src/features/auth/account-users-section.tsx new file mode 100644 index 00000000..9afee159 --- /dev/null +++ b/web/src/features/auth/account-users-section.tsx @@ -0,0 +1,167 @@ +import { + Box, + Button, + Collapse, + Group, + Loader, + PasswordInput, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuPlus, LuUsers } from 'react-icons/lu'; + +import { AccountUserRow } from './account-user-row'; + +import type { AccessMode, AuthUser } from '../../api/auth'; + +type AccountUsersSectionProps = { + mode: AccessMode; + users: AuthUser[]; + loading: boolean; + mutating: boolean; + onCreate: (params: { + username: string; + displayName?: string; + password: string; + role: AuthUser['role']; + }) => void; + onUpdate: (params: { id: string; role?: AuthUser['role']; status?: AuthUser['status'] }) => void; + onResetPassword: (params: { id: string; newPassword: string }) => void; +}; + +export function AccountUsersSection({ + mode, + users, + loading, + mutating, + onCreate, + onUpdate, + onResetPassword, +}: AccountUsersSectionProps) { + const { t } = useTranslation(); + const [creating, setCreating] = useState(false); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('user'); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!username.trim()) return; + onCreate({ + username, + displayName: displayName || undefined, + password, + role, + }); + }; + + return ( + + + + + + {t('auth.accounts.users')} + + + {t('auth.accounts.usersDescription', { count: users.length })} + + + + + + + + + {t('auth.accounts.createTitle')} + + setUsername(event.currentTarget.value)} + autoComplete="off" + /> + setDisplayName(event.currentTarget.value)} + /> + setPassword(event.currentTarget.value)} + autoComplete="new-password" + /> +