From 919f523a57ce44c6741f2095da98f626ae8bc5ab Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Tue, 14 Jul 2026 17:24:10 +0300 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 6/9] 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 7/9] 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" + /> +