From e9edb3a50a1d02d83a84748d05e9e9a7ce13bb36 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 07:43:26 +0000 Subject: [PATCH 1/5] feat: enable OpenRouter prompt caching by separating project state Move the dynamic `## Project state` block out of the system prompt and into a dedicated second message. This keeps the system prompt static so OpenRouter can cache it across requests: - Auto-caching models (Moonshot AI, OpenAI-compatible): the unchanged system prompt prefix is cached automatically with no extra config. - Anthropic-compatible models: a `cacheControl: { type: 'ephemeral' }` marker on the project-state user message tells OpenRouter to cache everything before that point (i.e. the system prompt). The project state is now prepended as a user+assistant exchange before the real conversation, keeping full context available to the model while ensuring the large, expensive system prompt is only processed once per cache window instead of on every request. https://claude.ai/code/session_01BUGedtPGoaoXbYcQ9Getdy --- src/lib/ai/system-prompt.ts | 11 ++++++++--- src/routes/(app)/chat/+server.ts | 33 ++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/lib/ai/system-prompt.ts b/src/lib/ai/system-prompt.ts index b74ea55..40e9712 100644 --- a/src/lib/ai/system-prompt.ts +++ b/src/lib/ai/system-prompt.ts @@ -125,10 +125,15 @@ ${JSON.stringify(exampleProject)} 3. Always set meaningful props (content, colors, sizes) — do not rely on defaults for visible content. 4. Always position layers intentionally. 5. Always animate every layer. -6. Create layers one at a time; do not batch unrelated layers in a single call. +6. Create layers one at a time; do not batch unrelated layers in a single call.`; +} -## Project state -${buildCanvasState(project)}`; +/** + * Build the dynamic project state section (injected as a separate message + * so the static system prompt above can be cached by OpenRouter). + */ +export function buildProjectStateSection(project: Project): string { + return `## Project state\n${buildCanvasState(project)}`; } /** diff --git a/src/routes/(app)/chat/+server.ts b/src/routes/(app)/chat/+server.ts index cceea64..1cdbe2e 100644 --- a/src/routes/(app)/chat/+server.ts +++ b/src/routes/(app)/chat/+server.ts @@ -9,7 +9,7 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { z } from 'zod'; import { ProjectSchema } from '$lib/schemas/animation'; import { animationTools } from '$lib/ai/schemas'; -import { buildSystemPrompt } from '$lib/ai/system-prompt'; +import { buildSystemPrompt, buildProjectStateSection } from '$lib/ai/system-prompt'; import { getModel } from '$lib/ai/models'; import { writeFileSync, mkdirSync, existsSync } from 'fs'; import { join } from 'path'; @@ -151,7 +151,12 @@ export const POST: RequestHandler = async ({ request, locals }) => { const { project, modelId, messages } = GenerateRequestSchema.parse(body); const openrouter = getOpenRouterClient(); + // Static system prompt — no project state here so OpenRouter can cache it + // across requests (automatic caching for Moonshot AI, OpenAI-compatible models). const systemPrompt = buildSystemPrompt(project); + // Dynamic project state injected as a separate second message so it doesn't + // invalidate the cached system prompt on every request. + const projectState = buildProjectStateSection(project); const model = getModel(modelId); console.log(`[AI] Using model: ${model.name} (${model.id})`); @@ -192,8 +197,32 @@ export const POST: RequestHandler = async ({ request, locals }) => { } }); + // Prepend project state as the first exchange so the static system prompt + // above stays cacheable. On Anthropic-compatible models the cacheControl + // marker instructs OpenRouter to cache everything before this point (i.e. + // the system prompt). On auto-caching models (Moonshot AI, OpenAI-compatible) + // the unchanged system prompt prefix is cached automatically. + const projectStateMessages = [ + { + role: 'user' as const, + content: [ + { + type: 'text' as const, + text: projectState, + providerOptions: { + openrouter: { cacheControl: { type: 'ephemeral' } } + } + } + ] + }, + { + role: 'assistant' as const, + content: [{ type: 'text' as const, text: 'Understood.' }] + } + ]; + const result = await agent.stream({ - messages: await convertToModelMessages(messages) + messages: [...projectStateMessages, ...(await convertToModelMessages(messages))] }); return result.toUIMessageStreamResponse(); From 26e9d95bc2cea98e2870f9f3ecce004257bc395a Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 18 Feb 2026 18:23:42 +0100 Subject: [PATCH 2/5] cleanup ai --- package.json | 6 +- pnpm-lock.yaml | 63 ++++++++++------- src/lib/ai/ai-operations.svelte.ts | 27 +------- src/lib/ai/models.ts | 67 ++++++++++-------- src/lib/ai/mutations.ts | 63 +++-------------- src/lib/ai/schemas.ts | 9 ++- src/lib/ai/system-prompt.ts | 60 +--------------- src/lib/components/ai/ai-chat.svelte | 17 +++-- src/lib/components/ai/tool-part.svelte | 96 ++++++++++++-------------- src/routes/(app)/chat/+server.ts | 70 ++++++++++--------- src/routes/mcp/+server.ts | 5 +- 11 files changed, 187 insertions(+), 296 deletions(-) diff --git a/package.json b/package.json index 40cebad..1e562eb 100644 --- a/package.json +++ b/package.json @@ -61,11 +61,11 @@ "vitest-browser-svelte": "^1.1.0" }, "dependencies": { - "@ai-sdk/openai": "^3.0.18", + "@ai-sdk/openai": "^3.0.29", "@ai-sdk/svelte": "^4.0.64", "@aws-sdk/client-s3": "^3.984.0", "@aws-sdk/s3-request-presigner": "^3.984.0", - "@openrouter/ai-sdk-provider": "^2.0.2", + "@openrouter/ai-sdk-provider": "^2.2.3", "@resvg/resvg-js": "^2.6.2", "@sveltejs/adapter-node": "^5.5.2", "@vercel/mcp-adapter": "^1.0.0", @@ -78,7 +78,7 @@ "fluent-ffmpeg": "^2.1.3", "mediabunny": "^1.30.1", "nanoid": "^5.1.6", - "openai": "^6.18.0", + "openai": "^6.22.0", "playwright": "^1.58.0", "postgres": "^3.4.7", "runed": "^0.37.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79b9895..5798399 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@ai-sdk/openai': - specifier: ^3.0.18 - version: 3.0.18(zod@4.3.6) + specifier: ^3.0.29 + version: 3.0.29(zod@4.3.6) '@ai-sdk/svelte': specifier: ^4.0.64 version: 4.0.64(svelte@5.48.2)(zod@4.3.6) @@ -21,8 +21,8 @@ importers: specifier: ^3.984.0 version: 3.984.0 '@openrouter/ai-sdk-provider': - specifier: ^2.0.2 - version: 2.0.2(ai@6.0.49(zod@4.3.6))(zod@4.3.6) + specifier: ^2.2.3 + version: 2.2.3(ai@6.0.49(zod@4.3.6))(zod@4.3.6) '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 @@ -60,8 +60,8 @@ importers: specifier: ^5.1.6 version: 5.1.6 openai: - specifier: ^6.18.0 - version: 6.18.0(ws@8.18.3)(zod@4.3.6) + specifier: ^6.22.0 + version: 6.22.0(ws@8.18.3)(zod@4.3.6) playwright: specifier: ^1.58.0 version: 1.58.0 @@ -213,8 +213,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.18': - resolution: {integrity: sha512-uYscTyoaWij9FoPpKRNK8YgtDEuPpQlqREYylJCA8o5YQVQXghV0Dwgk1ehPVpg6USIO4L0C8GqQJ4AMm/Xb1g==} + '@ai-sdk/openai@3.0.29': + resolution: {integrity: sha512-ugVTIVpuSLKTjzSPe1F1DWiblJT/lwrrHx0OZEKjpMk/EYP6j6VD/F7SJqM1dsqOJryeBCJWFbUzLNqc99PrMA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -225,6 +225,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.15': + resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.9': resolution: {integrity: sha512-bB4r6nfhBOpmoS9mePxjRoCy+LnzP3AfhyMGCkGL4Mn9clVNlqEeKj26zEKEtB6yoSVcT1IQ0Zh9fytwMCDnow==} engines: {node: '>=18'} @@ -239,6 +245,10 @@ packages: resolution: {integrity: sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@ai-sdk/svelte@4.0.64': resolution: {integrity: sha512-tiMSRI+VN2OdgW6A4nvicmhsrS4iQtnSdTkJCQM6G2kIT+UVfW8TQFxZ7Ut+G0Q4qYY8iB1O4ak7C9Pe0e34nw==} peerDependencies: @@ -897,16 +907,13 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - '@openrouter/ai-sdk-provider@2.0.2': - resolution: {integrity: sha512-G+8Z7Q4R61anj/nk/hmb1JAAjYpP/LEFA4mjQnitMGDLscoeThPpOl6xFKalzfkd2WSweeKRYAID5HxzEMCbPQ==} + '@openrouter/ai-sdk-provider@2.2.3': + resolution: {integrity: sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g==} engines: {node: '>=18'} peerDependencies: ai: ^6.0.0 zod: ^3.25.0 || ^4.0.0 - '@openrouter/sdk@0.1.27': - resolution: {integrity: sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==} - '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -2876,8 +2883,8 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openai@6.18.0: - resolution: {integrity: sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw==} + openai@6.22.0: + resolution: {integrity: sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -3738,10 +3745,10 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.3.6 - '@ai-sdk/openai@3.0.18(zod@4.3.6)': + '@ai-sdk/openai@3.0.29(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.9(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) zod: 4.3.6 '@ai-sdk/provider-utils@4.0.11(zod@4.3.6)': @@ -3751,6 +3758,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.15(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.9(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.5 @@ -3766,6 +3780,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/svelte@4.0.64(svelte@5.48.2)(zod@4.3.6)': dependencies: '@ai-sdk/provider-utils': 4.0.11(zod@4.3.6) @@ -4646,16 +4664,11 @@ snapshots: '@noble/hashes@2.0.1': {} - '@openrouter/ai-sdk-provider@2.0.2(ai@6.0.49(zod@4.3.6))(zod@4.3.6)': + '@openrouter/ai-sdk-provider@2.2.3(ai@6.0.49(zod@4.3.6))(zod@4.3.6)': dependencies: - '@openrouter/sdk': 0.1.27 ai: 6.0.49(zod@4.3.6) zod: 4.3.6 - '@openrouter/sdk@0.1.27': - dependencies: - zod: 4.3.6 - '@opentelemetry/api@1.9.0': {} '@peculiar/asn1-android@2.5.0': @@ -6649,7 +6662,7 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.18.0(ws@8.18.3)(zod@4.3.6): + openai@6.22.0(ws@8.18.3)(zod@4.3.6): optionalDependencies: ws: 8.18.3 zod: 4.3.6 diff --git a/src/lib/ai/ai-operations.svelte.ts b/src/lib/ai/ai-operations.svelte.ts index f35f80d..ad180c5 100644 --- a/src/lib/ai/ai-operations.svelte.ts +++ b/src/lib/ai/ai-operations.svelte.ts @@ -25,7 +25,6 @@ import type { RemoveKeyframeInput, RemoveKeyframeOutput } from './schemas'; -import { SvelteMap } from 'svelte/reactivity'; import { mutateCreateLayer, mutateAnimateLayer, @@ -43,28 +42,13 @@ import { // Layer ID Tracking // ============================================ -// Track layer IDs created during this conversation -// Maps index (layer_0 = 0, layer_1 = 1) to actual layer ID -const layerIdMap = new SvelteMap(); -let layerCreationIndex = 0; - -/** - * Reset layer tracking for new conversation turn - */ -export function resetLayerTracking() { - layerIdMap.clear(); - layerCreationIndex = 0; -} - // ============================================ // Context Helper // ============================================ function getContext(projectStore: ProjectStore): MutationContext { return { - project: projectStore.state, - layerIdMap: layerIdMap, - layerCreationIndex: layerCreationIndex + project: projectStore.state }; } @@ -82,17 +66,8 @@ export function executeCreateLayer( const ctx = getContext(projectStore); const result = mutateCreateLayer(ctx, input); - // Update local index tracker - if (result.nextLayerCreationIndex !== undefined) { - layerCreationIndex = result.nextLayerCreationIndex; - } - if (result.output.success && result.output.layerId) { projectStore.selectedLayerId = result.output.layerId; - - // Trigger reactivity update if needed (Svelte 5 runes usually handle deep obj mutation if proxied, - // but array push might need trigger dependin on implementation. - // projectStore.project is a Rune, so mutations should be fine.) } return result.output; diff --git a/src/lib/ai/models.ts b/src/lib/ai/models.ts index 5912ada..becbd5b 100644 --- a/src/lib/ai/models.ts +++ b/src/lib/ai/models.ts @@ -98,45 +98,54 @@ export const AI_MODELS = { // input: 3, // output: 15 // } + // }, + // 'openai/gpt-5.2': { + // id: 'openai/gpt-5.2', + // name: 'GPT-5.2', + // provider: 'OpenAI', + // description: 'Excellent for creative and complex tasks with 128K context', + // recommended: false, + // costTier: 'high', + // pricing: { + // input: 1.75, + // output: 14 + // } // } - - // // Claude 4.5 Sonnet - Great reasoning and creativity - // 'anthropic/claude-sonnet-4.5': { - // id: 'anthropic/claude-sonnet-4.5', - // name: 'Claude Sonnet 4.5', - // provider: 'Anthropic', - // description: 'Excellent reasoning and creative capabilities', + // NOT GOOD + // 'qwen/qwen3-max-thinking': { + // id: 'qwen/qwen3-max-thinking', + // name: 'Qwen 3 Max Thinking', + // provider: 'Qwen', + // description: 'Excellent for creative and complex tasks with 128K context', // recommended: false, // costTier: 'high', // pricing: { - // input: 3.0, - // output: 15.0 + // input: 1.2, + // output: 6 // } // }, - - // // GPT-5.1 - Strong all-rounder - // 'openai/gpt-5.1': { - // id: 'openai/gpt-5.1', - // name: 'GPT-5.1', - // provider: 'OpenAI', - // description: 'Fast and capable multimodal model', - // costTier: 'medium', + // 'qwen/qwen3.5-397b-a17b': { + // id: 'qwen/qwen3.5-397b-a17b', + // name: 'Qwen 3.5 397B A17B', + // provider: 'Qwen', + // description: 'Excellent for creative and complex tasks with 128K context', + // recommended: false, + // costTier: 'high', // pricing: { - // input: 1.25, - // output: 10.0 + // input: 0.15, + // output: 1 // } - // }, - - // // Gemini 3 Pro - Good for structured output - // 'google/gemini-3-pro-preview': { - // id: 'google/gemini-3-pro-preview', - // name: 'Gemini 3 Pro', - // provider: 'Google', - // description: 'Strong structured output and reasoning', + // } + // 'deepseek/deepseek-v3.2': { + // id: 'deepseek/deepseek-v3.2', + // name: 'DeepSeek V3.2', + // provider: 'DeepSeek', + // description: 'Excellent for creative and complex tasks with 128K context', + // recommended: false, // costTier: 'high', // pricing: { - // input: 2, - // output: 12 + // input: 0.26, + // output: 0.38 // } // } } as const satisfies Record; diff --git a/src/lib/ai/mutations.ts b/src/lib/ai/mutations.ts index d51cd7b..6760860 100644 --- a/src/lib/ai/mutations.ts +++ b/src/lib/ai/mutations.ts @@ -7,8 +7,6 @@ * - MCP server: Server-side project modifications (+server.ts) * * All mutations accept a MutationContext and return typed results. - * The context includes the project and optional layer ID tracking for - * resolving temporary layer references (layer_0, layer_1, etc.). */ import { nanoid } from 'nanoid'; import type { Interpolation, AnimatableProperty, Keyframe } from '$lib/types/animation'; @@ -37,50 +35,19 @@ import type { import type { Layer, ProjectData } from '$lib/schemas/animation'; import { defaultLayerStyle, defaultTransform } from '$lib/schemas/base'; -/** - * Context for resolving layer IDs (e.g. "layer_0" -> "actual-uuid") - */ export interface MutationContext { project: ProjectData; - /** - * Map of temporary IDs (e.g. from tool calls) to actual layer IDs. - * This should be persisted/passed along if you want continuity between calls. - */ - layerIdMap?: Map; - - /** - * Current index for assigning new temporary IDs (layer_0, layer_1...) - */ - layerCreationIndex?: number; } -/** - * Helper to update the context after layer creation - */ export interface MutationResult { output: T; - nextLayerCreationIndex?: number; } // ============================================ // Layer Resolution // ============================================ -function resolveLayerId( - project: ProjectData, - ref: string, - layerIdMap?: Map -): string | null { - // Check for "layer_N" pattern - const layerMatch = ref.match(/^layer_(\d+)$/); - if (layerMatch && layerIdMap) { - const index = parseInt(layerMatch[1], 10); - const resolvedId = layerIdMap.get(index); - if (resolvedId) { - return resolvedId; - } - } - +function resolveLayerId(project: ProjectData, ref: string): string | null { // Check if it's an actual layer ID const existingLayer = project.layers.find((l) => l.id === ref); if (existingLayer) { @@ -106,8 +73,7 @@ function layerNotFoundError(project: ProjectData, ref: string): string { const available = project.layers.map((l) => `"${l.name}" (id: ${l.id})`).join(', '); return ( `Layer "${ref}" not found. ` + - `Use layer_N for layers you just created in this conversation, ` + - `or reference existing layers by id/name. ` + + `Use the exact layer id returned by create_layer, or reference existing layers by id/name. ` + `Available layers: ${available}` ); } @@ -161,13 +127,6 @@ export function mutateCreateLayer( // Mutate project ctx.project.layers.push(layer); - // Track ID - let nextIndex = ctx.layerCreationIndex ?? 0; - if (ctx.layerIdMap) { - ctx.layerIdMap.set(nextIndex, layer.id); - nextIndex++; - } - // Apply animation if (input.animation?.preset) { applyPresetToProject( @@ -183,11 +142,9 @@ export function mutateCreateLayer( output: { success: true, layerId: layer.id, - layerIndex: ctx.layerCreationIndex, // Return the index used for this layer layerName: layer.name, message: `Created ${input.layer.type} layer "${layer.name}"` - }, - nextLayerCreationIndex: nextIndex + } }; } catch (err) { return { @@ -204,7 +161,7 @@ export function mutateAnimateLayer( ctx: MutationContext, input: AnimateLayerInput ): AnimateLayerOutput { - const resolvedId = resolveLayerId(ctx.project, input.layerId, ctx.layerIdMap); + const resolvedId = resolveLayerId(ctx.project, input.layerId); if (!resolvedId) { const errMsg = layerNotFoundError(ctx.project, input.layerId); @@ -273,7 +230,7 @@ export function mutateAnimateLayer( } export function mutateEditLayer(ctx: MutationContext, input: EditLayerInput): EditLayerOutput { - const resolvedId = resolveLayerId(ctx.project, input.layerId, ctx.layerIdMap); + const resolvedId = resolveLayerId(ctx.project, input.layerId); if (!resolvedId) { const errMsg = layerNotFoundError(ctx.project, input.layerId); return { success: false, message: errMsg, error: errMsg }; @@ -352,7 +309,7 @@ export function mutateRemoveLayer( ctx: MutationContext, input: RemoveLayerInput ): RemoveLayerOutput { - const resolvedId = resolveLayerId(ctx.project, input.layerId, ctx.layerIdMap); + const resolvedId = resolveLayerId(ctx.project, input.layerId); if (!resolvedId) { const errMsg = layerNotFoundError(ctx.project, input.layerId); return { success: false, message: errMsg, error: errMsg }; @@ -392,7 +349,7 @@ export function mutateGroupLayers( try { const resolvedIds: string[] = []; for (const ref of input.layerIds) { - const id = resolveLayerId(ctx.project, ref, ctx.layerIdMap); + const id = resolveLayerId(ctx.project, ref); if (!id) { return { success: false, @@ -484,7 +441,7 @@ export function mutateUngroupLayers( ctx: MutationContext, input: UngroupLayersInput ): UngroupLayersOutput { - const resolvedId = resolveLayerId(ctx.project, input.groupId, ctx.layerIdMap); + const resolvedId = resolveLayerId(ctx.project, input.groupId); if (!resolvedId) { const errMsg = layerNotFoundError(ctx.project, input.groupId); return { success: false, message: errMsg, error: errMsg }; @@ -587,7 +544,7 @@ export function mutateUpdateKeyframe( ctx: MutationContext, input: UpdateKeyframeInput ): UpdateKeyframeOutput { - const resolvedId = resolveLayerId(ctx.project, input.layerId, ctx.layerIdMap); + const resolvedId = resolveLayerId(ctx.project, input.layerId); if (!resolvedId) { const errMsg = layerNotFoundError(ctx.project, input.layerId); return { success: false, message: errMsg, error: errMsg }; @@ -640,7 +597,7 @@ export function mutateRemoveKeyframe( ctx: MutationContext, input: RemoveKeyframeInput ): RemoveKeyframeOutput { - const resolvedId = resolveLayerId(ctx.project, input.layerId, ctx.layerIdMap); + const resolvedId = resolveLayerId(ctx.project, input.layerId); if (!resolvedId) { const errMsg = layerNotFoundError(ctx.project, input.layerId); return { success: false, message: errMsg, error: errMsg }; diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index 73c33e7..500112f 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -143,7 +143,6 @@ export interface CreateLayerOutput { success: boolean; message: string; layerId?: string; - layerIndex?: number; layerName?: string; error?: string; } @@ -153,7 +152,7 @@ export interface CreateLayerOutput { // ============================================ export const AnimateLayerInputSchema = z.object({ - layerId: z.string().describe('Layer ID or reference (layer_0, layer_1, or actual ID)'), + layerId: z.string().describe('Layer ID (returned by create_layer) or layer name'), preset: z .object({ id: z.string().describe('Preset ID: ' + getPresetIds().join(', ')), @@ -373,14 +372,14 @@ export const animationTools = { animate_layer: tool({ description: 'Add animation to an existing layer via preset or custom keyframes. ' + - 'Use layer_N for layers you just created, or actual ID/name for existing layers.', + 'Use the layer id returned by create_layer, or the layer name for existing layers.', inputSchema: AnimateLayerInputSchema }), edit_layer: tool({ description: 'Modify an existing layer (position, scale, 3D rotation, anchor, opacity, or props). ' + - 'Use layer_N for layers you just created, or actual ID/name for existing layers.', + 'Use the layer id returned by create_layer, or the layer name for existing layers.', inputSchema: EditLayerInputSchema }), @@ -405,7 +404,7 @@ export const animationTools = { description: 'Group multiple layers together so they share a common transform. ' + 'Moving/rotating/scaling the group affects all children. ' + - 'Use layer_N for layers you just created, or actual ID/name for existing layers.', + 'Use layer ids returned by create_layer, or layer names for existing layers.', inputSchema: GroupLayersInputSchema }), diff --git a/src/lib/ai/system-prompt.ts b/src/lib/ai/system-prompt.ts index 40e9712..4b90ab9 100644 --- a/src/lib/ai/system-prompt.ts +++ b/src/lib/ai/system-prompt.ts @@ -96,8 +96,8 @@ Distribute layers across the canvas — never stack everything at (0,0). ## Layer references -- **Layers you create**: use layer_0, layer_1, ... (assigned in creation order within this conversation). -- **Pre-existing layers**: use the exact \`id\` or \`name\` shown in PROJECT STATE. layer_N does NOT work for pre-existing layers. +- **Layers you create**: use the \`layerId\` returned in the create_layer response. +- **Pre-existing layers**: use the exact \`id\` or \`name\` shown in PROJECT STATE. ## Animation tips @@ -127,59 +127,3 @@ ${JSON.stringify(exampleProject)} 5. Always animate every layer. 6. Create layers one at a time; do not batch unrelated layers in a single call.`; } - -/** - * Build the dynamic project state section (injected as a separate message - * so the static system prompt above can be cached by OpenRouter). - */ -export function buildProjectStateSection(project: Project): string { - return `## Project state\n${buildCanvasState(project)}`; -} - -/** - * Build a compact view of the current canvas state for the AI. - */ -function buildCanvasState(project: Project): string { - if (project.layers.length === 0) { - return 'Empty canvas — no layers yet.'; - } - - const layerList = project.layers - .map((layer, index) => { - // Show ALL props - const propsPreview = Object.entries(layer.props) - .map(([k, v]) => `${k}=${JSON.stringify(v)}`) - .join(', '); - - // Group keyframes by property and show all details - const keyframesByProp = new Map(); - for (const kf of layer.keyframes) { - if (!keyframesByProp.has(kf.property)) { - keyframesByProp.set(kf.property, []); - } - keyframesByProp.get(kf.property)!.push(kf); - } - - // Build detailed keyframe info - let keyframesDetail = ''; - if (keyframesByProp.size > 0) { - keyframesDetail = '\n keyframes:'; - for (const [prop, kfs] of keyframesByProp) { - const kfList = kfs - .sort((a, b) => a.time - b.time) - .map( - (kf) => `t=${kf.time}s: ${JSON.stringify(kf.value)} (${kf.interpolation?.strategy})` - ) - .join(', '); - keyframesDetail += `\n ${prop}: [${kfList}]`; - } - } - - return `${index}. "${layer.name}" (id: "${layer.id}", type: ${layer.type}) - pos: (${layer.transform.position.x}, ${layer.transform.position.y}) | scale: (${layer.transform.scale.x}, ${layer.transform.scale.y}) | rotation: ${layer.transform.rotation.z} rad | opacity: ${layer.style.opacity} - props: {${propsPreview || 'none'}}${keyframesDetail || '\n keyframes: none'}`; - }) - .join('\n\n'); - - return `${project.layers.length} layer(s):\n${layerList}`; -} diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index 45b9fb3..bbafd52 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -22,8 +22,7 @@ executeGroupLayers, executeUngroupLayers, executeUpdateKeyframe, - executeRemoveKeyframe, - resetLayerTracking + executeRemoveKeyframe } from '$lib/ai/ai-operations.svelte'; import { type AnimateLayerInput, @@ -63,7 +62,7 @@ [] ); - function scrollToBottom() { + function scrollToBottom(force = false) { let isAtBottom = false; if (scrollRef) { const top = scrollRef.scrollTop; @@ -73,7 +72,7 @@ isAtBottom = top >= diff - 100; } // allow the user to scroll up to read previous messages - if (isAtBottom) { + if (force || isAtBottom) { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: 'instant' @@ -151,13 +150,10 @@ }); async function sendMessage() { - // Reset layer tracking for new message - resetLayerTracking(); - chat.sendMessage({ text: prompt.current }); prompt.current = ''; await tick(); - scrollToBottom(); + scrollToBottom(true); } function onSubmit(event?: Event) { @@ -180,7 +176,10 @@ toast.success('Chat history cleared'); } - watch(() => $state.snapshot(chat.messages), scrollToBottom); + watch( + () => $state.snapshot(chat.messages), + () => scrollToBottom() + );
diff --git a/src/lib/components/ai/tool-part.svelte b/src/lib/components/ai/tool-part.svelte index 65abe17..d67ebaf 100644 --- a/src/lib/components/ai/tool-part.svelte +++ b/src/lib/components/ai/tool-part.svelte @@ -13,71 +13,61 @@ const message = $derived( hasOutput && typeof output.message === 'string' ? output.message : undefined ); + const isError = $derived(tool.state === 'output-error'); + const errorText = $derived(isError ? (tool as { errorText?: string }).errorText : undefined); -
- -
- +
+ + {#if success} ✅ - {:else if hasOutput && output.success === false} + {:else if isError || (hasOutput && output.success === false)} ❌ - {:else} + {:else if tool.state === 'input-streaming'} ⏳ - {/if} - -
- {#if message} -

{message}

{:else} -

- {#if tool.state === 'output-available'} - Completed - {:else if tool.state === 'input-streaming'} - Processing... - {:else if tool.state === 'output-error'} - Error - {/if} -

+ 🔧 {/if} -
-
- - -
- - 🔧 {tool.type.replace('tool-', '')} - {#if hasOutput && Object.keys(output).length > 2} - • {Object.keys(output).length - 2} more fields - {/if} - -
- {#if tool.input} + + {tool.type.replace('tool-', '')} + {#if message} + {message} + {/if} + +
+ {#if errorText} +
+
Error:
+
{errorText}
+
+ {/if} + {#if tool.input} +
+
Input:
+
{JSON.stringify(
+            tool.input,
+            null,
+            2
+          )}
+
+ {/if} + {#if hasOutput} + {@const otherFields = Object.fromEntries( + Object.entries(output).filter(([key]) => key !== 'success' && key !== 'message') + )} + {#if Object.keys(otherFields).length > 0}
-
Input:
+
Details:
{JSON.stringify(
-              tool.input,
+              otherFields,
               null,
               2
             )}
{/if} - {#if hasOutput} - {@const otherFields = Object.fromEntries( - Object.entries(output).filter(([key]) => key !== 'success' && key !== 'message') - )} - {#if Object.keys(otherFields).length > 0} -
-
Details:
-
{JSON.stringify(
-                otherFields,
-                null,
-                2
-              )}
-
- {/if} - {/if} -
-
-
+ {/if} +
+ diff --git a/src/routes/(app)/chat/+server.ts b/src/routes/(app)/chat/+server.ts index 1cdbe2e..f14d849 100644 --- a/src/routes/(app)/chat/+server.ts +++ b/src/routes/(app)/chat/+server.ts @@ -2,14 +2,14 @@ * AI Chat API Routes * Progressive tool-calling system for animation generation */ -import { convertToModelMessages, ToolLoopAgent, type UIMessage } from 'ai'; +import { convertToModelMessages, ToolLoopAgent, type ModelMessage, type UIMessage } from 'ai'; import { error, isHttpError } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { z } from 'zod'; import { ProjectSchema } from '$lib/schemas/animation'; import { animationTools } from '$lib/ai/schemas'; -import { buildSystemPrompt, buildProjectStateSection } from '$lib/ai/system-prompt'; +import { buildSystemPrompt } from '$lib/ai/system-prompt'; import { getModel } from '$lib/ai/models'; import { writeFileSync, mkdirSync, existsSync } from 'fs'; import { join } from 'path'; @@ -154,19 +154,31 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Static system prompt — no project state here so OpenRouter can cache it // across requests (automatic caching for Moonshot AI, OpenAI-compatible models). const systemPrompt = buildSystemPrompt(project); - // Dynamic project state injected as a separate second message so it doesn't - // invalidate the cached system prompt on every request. - const projectState = buildProjectStateSection(project); + const model = getModel(modelId); console.log(`[AI] Using model: ${model.name} (${model.id})`); console.log(`[AI] System prompt length: ${systemPrompt.length} chars`); const agent = new ToolLoopAgent({ - model: openrouter(model.id), - instructions: systemPrompt, + model: openrouter(model.id, { + reasoning: { + effort: 'none' + } + }), + instructions: { + role: 'system', + content: systemPrompt, + providerOptions: { + openrouter: { + cacheControl: { + type: 'ephemeral' + }, + sort: 'price' + } + } + }, tools: animationTools, - async onFinish(event) { logAIInteraction({ timestamp: new Date().toISOString(), @@ -197,32 +209,28 @@ export const POST: RequestHandler = async ({ request, locals }) => { } }); - // Prepend project state as the first exchange so the static system prompt - // above stays cacheable. On Anthropic-compatible models the cacheControl - // marker instructs OpenRouter to cache everything before this point (i.e. - // the system prompt). On auto-caching models (Moonshot AI, OpenAI-compatible) - // the unchanged system prompt prefix is cached automatically. - const projectStateMessages = [ - { - role: 'user' as const, - content: [ - { - type: 'text' as const, - text: projectState, - providerOptions: { - openrouter: { cacheControl: { type: 'ephemeral' } } - } + function enableCacheControl(messages: ModelMessage[]) { + return messages.map((message) => { + if (typeof message.content !== 'string') { + return message; + } + return { + ...message, + providerOptions: { + openrouter: { cacheControl: { type: 'ephemeral' /* ttl: '1h' */ } } } - ] - }, - { - role: 'assistant' as const, - content: [{ type: 'text' as const, text: 'Understood.' }] - } - ]; + }; + }); + } const result = await agent.stream({ - messages: [...projectStateMessages, ...(await convertToModelMessages(messages))] + messages: [ + ...enableCacheControl([...(await convertToModelMessages(messages))]), + { + role: 'system', + content: JSON.stringify(project) + } + ] }); return result.toUIMessageStreamResponse(); diff --git a/src/routes/mcp/+server.ts b/src/routes/mcp/+server.ts index e2b28f0..99ca14a 100644 --- a/src/routes/mcp/+server.ts +++ b/src/routes/mcp/+server.ts @@ -14,8 +14,7 @@ * - Each tool call modifies the project data and saves it back to DB * * Layer References: - * - Use actual layer IDs (returned from create_layer tools) or layer names - * - "layer_N" references (layer_0, layer_1) are NOT supported in MCP (stateless) + * - Use the layer id returned by create_layer, or layer names for existing layers * - Use get_project to inspect current project state and layer IDs */ import { z } from 'zod'; @@ -194,8 +193,6 @@ const handler = createMcpHandler( const projectData = dbProject.data; // Prepare mutation context - // NOTE: layer_N references (e.g., "layer_0") only work within a single LLM conversation - // For MCP (stateless), users should refer to layers by actual ID or name const ctx: MutationContext = { project: projectData }; From 7ee9471d873702500ef9e5eb22aed3f92e10f79e Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 18 Feb 2026 19:09:11 +0100 Subject: [PATCH 3/5] auth --- drizzle/0008_ambiguous_rafael_vega.sql | 1 + drizzle/meta/0008_snapshot.json | 823 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app.d.ts | 3 +- src/hooks.server.ts | 6 +- src/lib/functions/auth.remote.ts | 9 +- src/lib/roles.ts | 2 + src/lib/server/auth.ts | 13 + src/lib/server/db/schema/auth.ts | 3 +- 9 files changed, 863 insertions(+), 4 deletions(-) create mode 100644 drizzle/0008_ambiguous_rafael_vega.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/lib/roles.ts diff --git a/drizzle/0008_ambiguous_rafael_vega.sql b/drizzle/0008_ambiguous_rafael_vega.sql new file mode 100644 index 0000000..68b3048 --- /dev/null +++ b/drizzle/0008_ambiguous_rafael_vega.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "role" text DEFAULT 'user' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..74617a9 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,823 @@ +{ + "id": "0fedfccd-5ead-4aaf-824e-cf793c84c228", + "prevId": "2ee788bc-0588-4f89-84a3-6266cdb49f64", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_usage_log": { + "name": "ai_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "total_tokens": { + "name": "total_tokens", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_log_user_id_idx": { + "name": "ai_usage_log_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_log_created_at_idx": { + "name": "ai_usage_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_log_user_id_user_id_fk": { + "name": "ai_usage_log_user_id_user_id_fk", + "tableFrom": "ai_usage_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_user_unlock": { + "name": "ai_user_unlock", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_cost_per_month": { + "name": "max_cost_per_month", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_user_unlock_user_id_idx": { + "name": "ai_user_unlock_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_user_unlock_user_id_user_id_fk": { + "name": "ai_user_unlock_user_id_user_id_fk", + "tableFrom": "ai_user_unlock", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset": { + "name": "asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_project_id_idx": { + "name": "asset_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_user_id_idx": { + "name": "asset_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_project_id_project_id_fk": { + "name": "asset_project_id_project_id_fk", + "tableFrom": "asset", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_user_id_user_id_fk": { + "name": "asset_user_id_user_id_fk", + "tableFrom": "asset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_mcp": { + "name": "is_mcp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_id": { + "name": "forked_from_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_user_id_idx": { + "name": "project_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_is_public_idx": { + "name": "project_is_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_views_idx": { + "name": "project_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_user_id_user_id_fk": { + "name": "project_user_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_forked_from_id_project_id_fk": { + "name": "project_forked_from_id_project_id_fk", + "tableFrom": "project", + "tableTo": "project", + "columnsFrom": [ + "forked_from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e6e88ae..08b4394 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1770548347159, "tag": "0007_fine_human_cannonball", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1771435843012, + "tag": "0008_ambiguous_rafael_vega", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts index d3e8d1e..04fe9c0 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,6 @@ // See https://svelte.dev/docs/kit/types#app.d.ts +import type { UserRole } from '$lib/roles'; import type { Session, User } from 'better-auth'; // for information about these interfaces @@ -20,7 +21,7 @@ declare global { // interface Error {} interface Locals { session: Session | null; - user: User | null; + user: (Omit & { role: UserRole }) | null; } // interface PageData {} // interface PageState {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 494f095..f6a5054 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,6 +5,7 @@ import { svelteKitHandler } from 'better-auth/svelte-kit'; import { building } from '$app/environment'; import { sequence } from '@sveltejs/kit/hooks'; import '$lib/server/thumbnail-queue'; +import type { UserRole } from '$lib/roles'; const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { @@ -23,7 +24,10 @@ const authHandle: Handle = async ({ event, resolve }) => { // Make session and user available on server if (session) { event.locals.session = session.session; - event.locals.user = session.user; + event.locals.user = { + ...session.user, + role: (session.user.role ?? 'user') as UserRole + }; } return svelteKitHandler({ event, resolve, auth, building }); }; diff --git a/src/lib/functions/auth.remote.ts b/src/lib/functions/auth.remote.ts index 8ffea0a..3c93f9a 100644 --- a/src/lib/functions/auth.remote.ts +++ b/src/lib/functions/auth.remote.ts @@ -1,4 +1,4 @@ -import { form, getRequestEvent, query } from '$app/server'; +import { command, form, getRequestEvent, query } from '$app/server'; import { auth } from '$lib/server/auth'; import { redirect } from '@sveltejs/kit'; import { z } from 'zod'; @@ -61,3 +61,10 @@ export const getUser = query(async () => { const { locals } = getRequestEvent(); return locals.user; }); + +export const checkRole = command(z.enum(['admin', 'user']), async (role) => { + const { locals } = getRequestEvent(); + if (locals.user?.role !== role) { + throw redirect(303, '/'); + } +}); diff --git a/src/lib/roles.ts b/src/lib/roles.ts new file mode 100644 index 0000000..50d2768 --- /dev/null +++ b/src/lib/roles.ts @@ -0,0 +1,2 @@ +export const userRoles = ['user', 'admin'] as const; +export type UserRole = (typeof userRoles)[number]; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 8189227..3040d38 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -61,5 +61,18 @@ export const auth = betterAuth({ } } }) + }, + user: { + additionalFields: { + role: { + type: 'string', + required: true, + defaultValue: 'user', + input: false + } + }, + deleteUser: { + enabled: true + } } }); diff --git a/src/lib/server/db/schema/auth.ts b/src/lib/server/db/schema/auth.ts index 9a184f0..29e43e3 100644 --- a/src/lib/server/db/schema/auth.ts +++ b/src/lib/server/db/schema/auth.ts @@ -10,7 +10,8 @@ export const user = pgTable('user', { updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull() + .notNull(), + role: text('role').default('user').notNull() }); export const session = pgTable('session', { From 960b38eb24bcf3c755905df6fe175b29d425bf60 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 18 Feb 2026 19:36:45 +0100 Subject: [PATCH 4/5] admin --- package.json | 1 + pnpm-lock.yaml | 8 ++ src/lib/components/ai/ai-chat.svelte | 27 ++-- src/lib/functions/admin.remote.ts | 95 +++++++++++++++ src/lib/functions/auth.remote.ts | 2 +- src/lib/functions/projects.remote.ts | 3 +- src/routes/(marketing)/admin/+page.svelte | 142 ++++++++++++++++++++++ 7 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 src/lib/functions/admin.remote.ts create mode 100644 src/routes/(marketing)/admin/+page.svelte diff --git a/package.json b/package.json index 1e562eb..6de2ca0 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "better-auth": "^1.3.27", "bezier-easing": "^2.1.0", "colord": "^2.9.3", + "date-fns": "^4.1.0", "dompurify": "^3.3.1", "drizzle-orm": "^0.45.1", "fluent-ffmpeg": "^2.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5798399..050ed3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: colord: specifier: ^2.9.3 version: 2.9.3 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 dompurify: specifier: ^3.3.1 version: 3.3.1 @@ -2063,6 +2066,9 @@ packages: engines: {node: '>=4'} hasBin: true + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5953,6 +5959,8 @@ snapshots: cssesc@3.0.0: {} + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index bbafd52..5d1ed46 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -1,7 +1,7 @@ + +
+

Admin Dashboard

+ +
+ + +
+ +

+ {totalUsers} users · {totalProjects} projects · {cents(totalCostCents)} total AI spend in range +

+ + {#if adminStats.length === 0} +

No users found.

+ {:else} + + + + + + + + + + + + + {#each adminStats as u, i (u.id)} + + + + + + + + + {/each} + +
#UserRegisteredProjectsAI spendProjects
{i + 1} +
{u.name}
+
{u.email}
+
+ {format(u.createdAt, 'yyyy-MM-dd HH:mm')} + + {u.projectCount} + + {#if u.models.length === 0} + + {:else} +
+ {#each u.models as m (m.modelId)} +
+ {m.modelId} + ×{m.runs} + + {cents(m.costCents)} + +
+ {/each} +
+ total ×{u.models.reduce((s, m) => s + m.runs, 0)} + + {cents(u.totalCostCents)} + +
+
+ {/if} +
+ {#if u.projects.length === 0} + + {:else} +
+ {#each u.projects as p (p.id)} + + {p.isPublic ? 'pub' : 'priv'} + {p.name} + 👁 {p.views} + + {/each} +
+ {/if} +
+ {/if} +
From 332a44d848c98ec3c947472ebdf8f2a1ee16cf55 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 18 Feb 2026 21:11:08 +0100 Subject: [PATCH 5/5] fix alol --- drizzle/0009_user_role_enum.sql | 7 +++++++ drizzle/meta/_journal.json | 9 ++++++++- src/app.d.ts | 2 +- src/lib/ai/ai-operations.svelte.ts | 13 ++++--------- src/lib/ai/system-prompt.ts | 7 ++++--- src/lib/components/ai/ai-chat.svelte | 7 +++++-- src/lib/components/ai/tool-part.svelte | 11 ++++++++++- src/lib/functions/auth.remote.ts | 4 ++-- src/lib/functions/projects.remote.ts | 7 +++++-- src/lib/roles.ts | 4 ++++ src/lib/server/auth.ts | 3 ++- src/lib/server/db/schema/auth.ts | 8 ++++++-- src/routes/(app)/chat/+server.ts | 8 +++++++- 13 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 drizzle/0009_user_role_enum.sql diff --git a/drizzle/0009_user_role_enum.sql b/drizzle/0009_user_role_enum.sql new file mode 100644 index 0000000..b99c2b7 --- /dev/null +++ b/drizzle/0009_user_role_enum.sql @@ -0,0 +1,7 @@ +CREATE TYPE "public"."user_role" AS ENUM('user', 'admin'); +--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "role" DROP DEFAULT; +--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "role" TYPE "public"."user_role" USING "role"::"public"."user_role"; +--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'user'::"public"."user_role"; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 08b4394..b3f48f2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1771435843012, "tag": "0008_ambiguous_rafael_vega", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1771500000000, + "tag": "0009_user_role_enum", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app.d.ts b/src/app.d.ts index 04fe9c0..230eca4 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,7 +1,7 @@ // See https://svelte.dev/docs/kit/types#app.d.ts -import type { UserRole } from '$lib/roles'; import type { Session, User } from 'better-auth'; +import type { UserRole } from '$lib/roles'; // for information about these interfaces interface DevMotionAPI { diff --git a/src/lib/ai/ai-operations.svelte.ts b/src/lib/ai/ai-operations.svelte.ts index ad180c5..cc2e643 100644 --- a/src/lib/ai/ai-operations.svelte.ts +++ b/src/lib/ai/ai-operations.svelte.ts @@ -1,8 +1,7 @@ /** * AI Tool Executor - Client-side execution of AI tool calls * - * Handles progressive tool execution with layer ID tracking across tool calls. - * Refactored to use shared 'mutations.ts' logic. + * Client-side AI tool execution using shared mutations.ts logic. */ import type { ProjectStore } from '$lib/stores/project.svelte'; import type { @@ -38,10 +37,6 @@ import { type MutationContext } from './mutations'; -// ============================================ -// Layer ID Tracking -// ============================================ - // ============================================ // Context Helper // ============================================ @@ -100,12 +95,12 @@ export function executeRemoveLayer( projectStore: ProjectStore, input: RemoveLayerInput ): RemoveLayerOutput { - const result = mutateRemoveLayer(getContext(projectStore), input); + const ctx = getContext(projectStore); + const result = mutateRemoveLayer(ctx, input); if (result.success) { if ( projectStore.selectedLayerId && - getContext(projectStore).project.layers.find((l) => l.id === projectStore.selectedLayerId) === - undefined + ctx.project.layers.find((l) => l.id === projectStore.selectedLayerId) === undefined ) { projectStore.selectedLayerId = null; } diff --git a/src/lib/ai/system-prompt.ts b/src/lib/ai/system-prompt.ts index 4b90ab9..74bc73f 100644 --- a/src/lib/ai/system-prompt.ts +++ b/src/lib/ai/system-prompt.ts @@ -57,9 +57,10 @@ IMPORTANT: All messages must be in PLAIN TEXT without markdown formatting. ## Keyframe Management -- **animate_layer**: Add new keyframes (preset or custom) -- **update_keyframe**: Modify existing keyframe (time, value, or interpolation). You'll need the keyframe ID from the PROJECT STATE. -- **remove_keyframe**: Delete a specific keyframe by ID +- **animate_layer**: Add new keyframes (preset or custom). Pass the layer id returned by create_layer or the layer name for pre-existing layers. +- **edit_layer**: Modify layer properties. Same id-or-name lookup as animate_layer. +- **update_keyframe**: Modify an existing keyframe (time, value, or interpolation). The keyframeId is included in the compact project JSON sent with every request (field: layers[].keyframes[].id). Identify the right keyframe by matching layers[].keyframes[].property and layers[].keyframes[].time, then pass that id. +- **remove_keyframe**: Delete a specific keyframe by its id. Discover ids the same way as update_keyframe (layers[].keyframes[].id in the project JSON). ## Interpolation Options diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index 5d1ed46..888be6d 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -157,7 +157,8 @@ function onSubmit(event?: Event) { event?.preventDefault(); - if (!prompt.current.trim() || chat.status === 'streaming') return; + if (!prompt.current.trim() || chat.status === 'streaming' || chat.status === 'submitted') + return; uiStore.requireLogin('send a message', sendMessage); } @@ -251,7 +252,9 @@ bind:value={prompt.current} onkeydown={handleKeyDown} placeholder="Describe your animation... e.g., 'Create a title that fades in with a subtitle below'" - disabled={chat.status === 'streaming' || projectStore.isRecording} + disabled={chat.status === 'streaming' || + chat.status === 'submitted' || + projectStore.isRecording} class="mb-3 flex min-h-20 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" > diff --git a/src/lib/components/ai/tool-part.svelte b/src/lib/components/ai/tool-part.svelte index d67ebaf..4aea332 100644 --- a/src/lib/components/ai/tool-part.svelte +++ b/src/lib/components/ai/tool-part.svelte @@ -2,6 +2,15 @@ import type { AnimationUITools } from '$lib/ai/schemas'; import type { DynamicToolUIPart, ToolUIPart } from 'ai'; + interface OutputErrorTool { + state: 'output-error'; + errorText?: string; + } + + function isOutputError(t: unknown): t is OutputErrorTool { + return typeof t === 'object' && t !== null && (t as OutputErrorTool).state === 'output-error'; + } + const { tool }: { tool: DynamicToolUIPart | ToolUIPart } = $props(); const output = $derived( @@ -14,7 +23,7 @@ hasOutput && typeof output.message === 'string' ? output.message : undefined ); const isError = $derived(tool.state === 'output-error'); - const errorText = $derived(isError ? (tool as { errorText?: string }).errorText : undefined); + const errorText = $derived(isOutputError(tool) ? tool.errorText : undefined);
diff --git a/src/lib/functions/auth.remote.ts b/src/lib/functions/auth.remote.ts index d56a849..473ffa7 100644 --- a/src/lib/functions/auth.remote.ts +++ b/src/lib/functions/auth.remote.ts @@ -1,4 +1,4 @@ -import { command, form, getRequestEvent, query } from '$app/server'; +import { form, getRequestEvent, query } from '$app/server'; import { auth } from '$lib/server/auth'; import { redirect } from '@sveltejs/kit'; import { z } from 'zod'; @@ -65,6 +65,6 @@ export const getUser = query(async () => { export const checkRole = query(z.enum(['admin', 'user']), async (role) => { const { locals } = getRequestEvent(); if (locals.user?.role !== role) { - throw redirect(303, '/'); + redirect(303, '/'); } }); diff --git a/src/lib/functions/projects.remote.ts b/src/lib/functions/projects.remote.ts index 5a5c2b7..533183c 100644 --- a/src/lib/functions/projects.remote.ts +++ b/src/lib/functions/projects.remote.ts @@ -9,6 +9,7 @@ import { invalid } from '@sveltejs/kit'; import { projectDataSchema } from '$lib/schemas/animation'; import { thumbnailQueue } from '$lib/server/thumbnail-queue'; import { deleteFile } from '$lib/server/storage'; +import { ADMIN_ROLE } from '$lib/roles'; export const saveProject = command( z.object({ @@ -78,7 +79,9 @@ export const getProject = query(z.object({ id: z.string() }), async ({ id }) => } const isOwner = locals.user?.id === result.userId; - const isAdmin = locals.user?.role === 'admin'; + // Admins can view all projects (public and private) but intentionally have view-only access + // to other users' private projects — canEdit is restricted to the project owner only. + const isAdmin = locals.user?.role === ADMIN_ROLE; if (!result.isPublic && !isOwner && !isAdmin) { return { error: 'access_denied' as const }; @@ -87,7 +90,7 @@ export const getProject = query(z.object({ id: z.string() }), async ({ id }) => return { project: result, isOwner, - canEdit: isOwner + canEdit: isOwner // Admins are intentionally read-only on others' private projects }; }); diff --git a/src/lib/roles.ts b/src/lib/roles.ts index 50d2768..3ea315d 100644 --- a/src/lib/roles.ts +++ b/src/lib/roles.ts @@ -1,2 +1,6 @@ export const userRoles = ['user', 'admin'] as const; export type UserRole = (typeof userRoles)[number]; + +/** Canonical role constants for type-safe role comparisons. */ +export const ADMIN_ROLE = 'admin' as const satisfies UserRole; +export const USER_ROLE = 'user' as const satisfies UserRole; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 3040d38..5b9a97a 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -13,6 +13,7 @@ import { import { aiUserUnlock } from './db/schema'; import { eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; +import { userRoles } from '$lib/roles'; export const auth = betterAuth({ secret: PRIVATE_BETTER_AUTH_SECRET, @@ -65,7 +66,7 @@ export const auth = betterAuth({ user: { additionalFields: { role: { - type: 'string', + type: [...userRoles], required: true, defaultValue: 'user', input: false diff --git a/src/lib/server/db/schema/auth.ts b/src/lib/server/db/schema/auth.ts index 29e43e3..771f9c9 100644 --- a/src/lib/server/db/schema/auth.ts +++ b/src/lib/server/db/schema/auth.ts @@ -1,4 +1,8 @@ -import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core'; +import { pgTable, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core'; +import { userRoles } from '$lib/roles'; + +/** Postgres enum that enforces valid role values at the DB level. */ +export const userRoleEnum = pgEnum('user_role', [...userRoles]); export const user = pgTable('user', { id: text('id').primaryKey(), @@ -11,7 +15,7 @@ export const user = pgTable('user', { .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), - role: text('role').default('user').notNull() + role: userRoleEnum('role').default('user').notNull() }); export const session = pgTable('session', { diff --git a/src/routes/(app)/chat/+server.ts b/src/routes/(app)/chat/+server.ts index f14d849..c929fcd 100644 --- a/src/routes/(app)/chat/+server.ts +++ b/src/routes/(app)/chat/+server.ts @@ -214,10 +214,16 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (typeof message.content !== 'string') { return message; } + // Shallow-merge into existing providerOptions so other provider keys + // (e.g. sort, reasoning) are preserved alongside the cacheControl entry. return { ...message, providerOptions: { - openrouter: { cacheControl: { type: 'ephemeral' /* ttl: '1h' */ } } + ...message.providerOptions, + openrouter: { + ...(message.providerOptions?.openrouter as Record | undefined), + cacheControl: { type: 'ephemeral' /* ttl: '1h' */ } + } } }; });